Terrain-DEM tiles that arrive already expired are re-downloaded in full every 1, 2, 4… 64 s while visible
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Domain
- mobile-dev
Research direction
Start with the Android Maps SDK terrain DEM path and the MainActivity.kt reproduction using SIMULATED_CDN_AGE; compare its behavior with the expired-response backoff described in mapbox/mapbox-gl-native#4019. Determine whether the client cache or the response headers account for the repeated downloads, then verify that visible expired DEM tiles no longer trigger repeated full 200 responses.
Written by the indexing model from the issue text.
Description
Environment
- Android OS version: 17
- Devices affected: Pixel 6a, Android emulator
- Maps SDK Version: 11.30.0 and 11.32.0
Observed behavior and steps to reproduce
Terrain-DEM tiles (https://api.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.png) are served with cache-control: max-age=43200,s-maxage=2592000 and no ETag or Last-Modified. So once a tile has been in a CloudFront edge cache for more than 12 hours, every device that gets that copy receives a tile that is already expired. The SDK then downloads it again in full, roughly 100 KB each time, at +1, 2, 4, 8, 16, 32, 64 s… for as long as it stays on screen. The camera doesn't move.
Steps, using the Use 3D terrain example with a fixed camera (zoom 17, pitch 45) and the logging activity below:
- Open it over a city centre. The DEM tiles are CloudFront misses (no
Age) and cache normally. - At least 12 hours later, clear app data and open it again at the same place, on the same network. The same tiles now come back as CloudFront hits with
Age> 43200 and are re-downloaded indefinitely.
We did exactly this on 2 October 2026 (POP FRA56-P13). At 00:34 UTC Munich 14/8718/5685 was a miss. At 14:28 UTC the same tile (tokens removed):
14:28:53.170 200 102869B age=50075 x-cache=Hit_from_cloudfront cache-control=max-age=43200,s-maxage=2592000 etag=null last-modified=null
14:28:54.371 200 102869B age=50076 …
14:28:56.404 200 102869B age=50079 …
14:29:00.469 200 102869B age=50083 …
14:29:08.540 200 102869B age=50091 …
14:29:24.655 200 102869B age=50107 …
In a 24-location sweep that afternoon, all 7 locations first loaded about 14 hours earlier looped: Berlin at zoom 12 and 17, London, Munich, Paris, New York and one other city. All 128 of their DEM responses arrived expired. All 17 locations not loaded before were fresh misses. The behaviour is the same on 11.30.0 and 11.32.0.
To reproduce without waiting 12 hours, set SIMULATED_CDN_AGE in the activity. It rewrites Date and Age on DEM responses to look like a hit on a 50,000 s-old copy. Berlin then shows the same loop: each of the 4 visible DEM tiles is downloaded 8 times in about 2 minutes, 32 full 200 responses, about 3 MB.
MainActivity.kt (Maps SDK 11.32.0, com.mapbox.maps:android-ndk27)
package com.example.demrepro
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import com.mapbox.bindgen.ExpectedFactory
import com.mapbox.common.HttpRequest
import com.mapbox.common.HttpRequestOrResponse
import com.mapbox.common.HttpResponse
import com.mapbox.common.HttpResponseData
import com.mapbox.common.HttpServiceFactory
import com.mapbox.common.HttpServiceInterceptorInterface
import com.mapbox.common.HttpServiceInterceptorRequestContinuation
import com.mapbox.common.HttpServiceInterceptorResponseContinuation
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.MapInitOptions
import com.mapbox.maps.MapView
import com.mapbox.maps.Style
import com.mapbox.maps.extension.style.sources.generated.rasterDemSource
import com.mapbox.maps.extension.style.style
import com.mapbox.maps.extension.style.terrain.generated.terrain
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
// The "Use 3D terrain" example with a fixed street-level camera, logging every terrain-DEM response.
// SIMULATED_CDN_AGE > 0 makes DEM responses look like a CloudFront hit on a copy that many seconds old
// (Date fixed at the copy's time, Age growing), so the loop shows without waiting 12 hours.
class MainActivity : ComponentActivity() {
private val copyTimes = mutableMapOf<String, Long>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
HttpServiceFactory.setHttpServiceInterceptor(DemLogger())
val camera = CameraOptions.Builder().center(Point.fromLngLat(13.4050, 52.5200)).zoom(17.0).pitch(45.0).build()
val mapView = MapView(this, MapInitOptions(this, cameraOptions = camera))
setContentView(mapView)
mapView.mapboxMap.loadStyle(
style(Style.STANDARD_SATELLITE) {
+rasterDemSource("TERRAIN_SOURCE") {
url("mapbox://mapbox.mapbox-terrain-dem-v1")
tileSize(514)
}
+terrain("TERRAIN_SOURCE") { exaggeration(1.5) }
},
)
}
private inner class DemLogger : HttpServiceInterceptorInterface {
override fun onRequest(request: HttpRequest, continuation: HttpServiceInterceptorRequestContinuation) =
continuation.run(HttpRequestOrResponse(request))
override fun onResponse(response: HttpResponse, continuation: HttpServiceInterceptorResponseContinuation) {
val data = response.result.value
val path = response.request.url.substringBefore('?')
if ("terrain-dem-v1/" !in path || data == null) return continuation.run(response)
val headers = HashMap(data.headers)
if (SIMULATED_CDN_AGE > 0) {
val now = System.currentTimeMillis() / 1000
val copyTime = copyTimes.getOrPut(path) { now - SIMULATED_CDN_AGE }
headers["age"] = (now - copyTime).toString()
headers["date"] = HTTP_DATE.format(Instant.ofEpochSecond(copyTime))
}
val h = headers.mapKeys { it.key.lowercase() }
Log.i(TAG, "${path.substringAfter("terrain-dem-v1/")} ${data.code} ${data.data.size}B age=${h["age"]} cache-control=${h["cache-control"]} etag=${h["etag"]} last-modified=${h["last-modified"]}")
continuation.run(HttpResponse(response.requestId, response.request, ExpectedFactory.createValue(HttpResponseData(headers, data.code, data.data))))
}
}
companion object {
const val TAG = "DemRepro"
const val SIMULATED_CDN_AGE = 50_000L // seconds; 0 = real headers only
val HTTP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).withZone(ZoneOffset.UTC)
}
}
Log with SIMULATED_CDN_AGE = 50_000, Berlin (one line per DEM response):
03:39:25.328 14/8801/5373.png 200 102803B age=50000 cache-control=max-age=43200,s-maxage=2592000 etag=null last-modified=null
03:39:26.354 14/8801/5373.png 200 102803B age=50001 …
03:39:28.430 14/8801/5373.png 200 102803B age=50003 …
03:39:32.616 14/8801/5373.png 200 102803B age=50007 …
03:39:40.688 14/8801/5373.png 200 102803B age=50015 …
03:39:56.791 14/8801/5373.png 200 102803B age=50031 …
03:40:29.488 14/8801/5373.png 200 102803B age=50064 …
03:41:34.339 14/8801/5373.png 200 102803B age=50129 …
(same for 14/8802/5372, 14/8801/5372, 14/8802/5373)
Impact in our cycling app, which has a moving, pitched navigation camera: repeating the same 4.1 km route used 9.0–9.6 MB per ride, nearly all of it DEM tiles fetched up to 31 times each. Vector tiles on that route were served from the disk cache.
Expected behavior
Each DEM tile is downloaded once and then served from the disk cache, or revalidated with a 304, like vector tiles. The cache management guide says an unchanged resource gets "a 'Not Modified' response … the data payload is not sent". The Raster Tiles API documents max-age=43200,s-maxage=300. There the CDN lifetime is shorter than the device lifetime, so a CDN copy can never arrive expired.
Notes / preliminary analysis
- Server side.
s-maxage(30 days) is far abovemax-age(12 hours), and there are no validators. A CloudFront copy older than 12 h is served already expired and can't be revalidated. Possible fixes:s-maxage≤max-agefor this endpoint, and/or anETag/Last-Modifiedso a refresh is a 304. - Client side.
- The SDK stores expiry as receipt time +
max-age−Age. That is identical on every refetch of the same CDN copy, so it ends up in the expired-retry backoff (expirationTimeout, mapbox/mapbox-gl-native#4019), which never stops while the tile is visible. - Vector and satellite tiles go through the same refetches but carry ETags, so each costs a 0-byte
304. - Not refetching responses that arrive expired with no validators, or capping it, would contain this.
- The SDK stores expiry as receipt time +
minimumTileUpdateInterval(86_400.0)on the raster-dem source only partly helps.- When the tile was already in the disk cache, it was fetched once and its expiry set to +86399 s. That took our repeated route from 9.0–9.6 MB to 0.54 MB.
- A tile first fetched in the current session still loops at 1, 2, 4… s with the property set.
- What works in both cases: removing
Agefrom DEM responses in anHttpServiceInterceptor. Each tile is then fetched once.
- Scope.
- Standard fades terrain out (exaggeration 0 from zoom 13.7), which disables it at street level, so the stock Standard style should only be exposed from zoom 7 to 13.7.
- Styles that keep terrain or hillshade on at high zoom, like the example above, are exposed at every zoom. The example loops the same way at zoom 12.
- iOS uses the same tiles; not tested.
Additional links and references
- mapbox/mapbox-gl-native#4019 (backoff for responses that arrive expired, "otherwise a faulty server can trigger an endless number of consecutive requests") and mapbox/mapbox-gl-js#4345 (same kind of loop for
Expiresin the past)
- Dominant language
- Kotlin
- Stars
- 577
- Forks
- 161
- PR merge metrics
- No merged PRs in 30d
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from mapbox/mapbox-maps-android
-
bug :beetle:
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
mapbox/mapbox-maps-android#2727 ·
-
bug :beetle:
Difficulty 4/5 3-5 days Newbie friendliness 50/100
mapbox/mapbox-maps-android#2732 ·
-
auto-triaged bug :beetle:
Difficulty 4/5 3-5 days Newbie friendliness 45/100
mapbox/mapbox-maps-android#2731 · 4 reactions ·
-
bug :beetle:
Difficulty 4/5 3-5 days Newbie friendliness 48/100
mapbox/mapbox-maps-android#2730 ·
-
bug :beetle:
Difficulty 4/5 3-5 days Newbie friendliness 52/100
mapbox/mapbox-maps-android#2729 ·
All issues in mapbox/mapbox-maps-android
Similar issues
-
Raijin Scans (FR)OpenBug Domain changed
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
keiyoushi/extensions-source#19644 · 1 reaction ·
Maintainers usually reply within 1 day
-
node: disconnection push notification is logged as "Unknown notification" instead of the disconnect warningPossibly taken @datatrigger claimed this today. Openbug 🐞 Untriaged user issue
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
valkey-io/valkey-glide#7278 ·
Maintainers usually reply within 2 days
-
design
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
enhancement triaged
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
Maintainers usually reply within 3 days
-
Difficulty 1/5 1-3 hours Newbie friendliness 88/100
mobile-dev-inc/Maestro#3659 · 1 comment ·
Maintainers usually reply within 1 day