Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Terrain-DEM tiles that arrive already expired are re-downloaded in full every 1, 2, 4… 64 s while visible

Open
#2,733 0 comments 0 reactions 0 assignees View on GitHub

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
Tech stack
android, kotlin
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:

  1. Open it over a city centre. The DEM tiles are CloudFront misses (no Age) and cache normally.
  2. 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 above max-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-age for this endpoint, and/or an ETag/Last-Modified so 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.
  • 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 Age from DEM responses in an HttpServiceInterceptor. 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 Expires in the past)
Dominant language
Kotlin
Stars
577
Forks
161
PR merge metrics
No merged PRs in 30d

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from mapbox/mapbox-maps-android

All issues in mapbox/mapbox-maps-android

Similar issues

More Kotlin issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.