Skip to content

getExpiryDataFromHeaders fails to read cache headers from Map due to case-sensitivity mismatch — tiles never auto-refresh #13625

@nab0y4enko

Description

@nab0y4enko

mapbox-gl-js version

3.18.1

Browser and version

Chrome 145.0.7632.76

Expected behavior

After the max-age duration expires, mapbox-gl should automatically re-request the visible tiles, matching the behavior in v2.x.

Actual behavior

Description

Vector tile sources with Cache-Control: max-age=N response headers no longer auto-refresh after tile expiry. The refreshExpiredTiles mechanism is completely broken for all non-Mapbox tile sources since v3.0.0.

Root Cause

In src/util/ajax.js, the getExpiryDataFromHeaders function reads headers using title-case keys:

function getExpiryDataFromHeaders(responseHeaders) {
    const cacheControl = responseHeaders.get('Cache-Control');
    const expires = responseHeaders.get('Expires');
    return { cacheControl, expires };
}

This works when responseHeaders is a Fetch API Headers object (which is case-insensitive per the Fetch spec).

However, in the vector tile worker pipeline (src/source/vector_tile_worker_source.jsloadVectorTile), the Headers object is serialized into a JavaScript Map before being sent back from the worker:

responseHeaders: new Map(responseHeaders.entries())

Per the Fetch specification, Headers.entries() yields lowercase header names. So the Map contains keys like 'cache-control' and 'expires'.

When getExpiryDataFromHeaders later calls Map.get('Cache-Control'), this returns undefined because Map.get() is case-sensitive — unlike Headers.get().

Impact

The entire tile refresh chain is broken:

  1. getExpiryDataFromHeaders() → returns { cacheControl: undefined, expires: undefined }
  2. Tile.setExpiryData() → no-op (both values undefined)
  3. Tile.expirationTime → never set
  4. Tile.getExpiryTimeout() → returns undefined
  5. SourceCache._setTileReloadTimer() → never creates a setTimeout
  6. Tiles are never automatically re-fetched, even though refreshExpiredTiles defaults to true

This affects all custom vector tile sources (MVT, GeoJSON served as vector tiles, etc.) that rely on Cache-Control or Expires response headers for automatic refresh. Mapbox's own tiles are not affected because they use a separate internal cache mechanism with SKU-based cache defeating (cacheIgnoringSearch / hasCacheDefeatingSku).

Suggested Fix

Change the header key lookups in getExpiryDataFromHeaders to use lowercase keys, matching what Headers.entries() produces:

function getExpiryDataFromHeaders(responseHeaders) {
    const cacheControl = responseHeaders.get('cache-control');
    const expires = responseHeaders.get('expires');
    return { cacheControl, expires };
}

This is safe because:

  • When responseHeaders is a Headers object (Fetch API), get() is case-insensitive — lowercase works fine
  • When responseHeaders is a Map (after worker serialization), get() is case-sensitive — lowercase matches the keys from Headers.entries()

Alternatively, use a case-insensitive lookup helper, or convert the Map back to a Headers object on the main thread.

The same case-sensitivity issue also applies to the two other Cache-Control lookups in ajax.js related to the cacheOpen/cachePut service worker cache logic, though those operate on Headers objects directly and are not currently broken (they would be if ever passed a Map).

Related Code Paths

  • src/util/ajax.jsgetExpiryDataFromHeaders() — the buggy function
  • src/source/vector_tile_worker_source.jsloadVectorTile()new Map(responseHeaders.entries()) serialization
  • src/source/vector_tile_source.jsloadTile done callback → tile.setExpiryData()
  • src/source/source_cache.js_setTileReloadTimer() — the timer that should trigger re-fetch
  • src/source/tile.jssetExpiryData() / getExpiryTimeout() — expiration time calculation

Link to the demonstration

No response

Steps to trigger the unexpected behavior

  1. Set up a vector tile source (MVT) with a server that returns Cache-Control: max-age=20 response headers
  2. Add the source to a map:
    <Source type="vector" tiles={["https://example.com/tiles/{z}/{x}/{y}.mvt"]} />
  3. Wait more than 20 seconds
  4. Observe: tiles are never re-requested despite cache expiry
  5. In v2.x with the same setup, tiles were automatically re-fetched every 20 seconds

Relevant log output

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions