Skip to content

Black map on cold start: /api/proxy-tiles 500s on style.json/sprite/tiles.json (UND_ERR_CONNECT_TIMEOUT) and MapLibre never retries #250

Description

@Aiacos

Summary

On a cold start (empty Next fetch cache), the first page load races the basemap requests against the app's own data-layer fetch storm. /api/proxy-tiles then returns 500 for style.json / sprite.* / tiles.json, and because MapLibre never retries a failed style, the map stays black for the whole browser session. A later reload works, which makes this look intermittent — but it reproduces 100% of the time from a cold cache.

This bites every self-hoster on first run, and again after every image update (a fresh container = empty /app/.next/cache/fetch-cache).

Environment

  • Image: ghcr.io/simplifaisoul/osiris@sha256:92eeda3bdeda0d5292241ca25dfdbbd44e8ecb0f1212c06a57599949c64d73dc (rev c91e1ea, built 2026-07-30)
  • App version: v4.1 — Next.js 16.2.6, Node 22.23.2
  • Docker on Linux, bridge network, port 3005 → 3000, no proxy, no egress filtering

Symptoms

The UI shell renders fine — header, ticker, sidebars, entity counter all alive — but the map canvas is pure black.

Browser console:

Error: AJAXError: Internal Server Error (500):
  http://host:3005/api/proxy-tiles?url=https%3A%2F%2Fbasemaps.cartocdn.com%2Fgl%2Fdark-matter-gl-style%2Fstyle.json

Depending on timing it is style.json, sprite.json, sprite.png, or vector/carto.streets/v1/tiles.json that loses the race.

Server logs at the same moment:

Tile proxy error: TypeError: fetch failed
  [cause]: Error [ConnectTimeoutError]: Connect Timeout Error
           (attempted address: basemaps.cartocdn.com:443, timeout: 10000ms)
           code: 'UND_ERR_CONNECT_TIMEOUT'
Tile proxy error: TypeError: fetch failed
  [cause]: ConnectTimeoutError (attempted address: tiles-c.basemaps.cartocdn.com:443, timeout: 10000ms)
Tile proxy error: TypeError: fetch failed
  [cause]: ConnectTimeoutError (attempted address: tiles-d.basemaps.cartocdn.com:443, timeout: 10000ms)

The same connect timeouts hit unrelated upstreams in the same window, which is the giveaway that this is contention inside the Node process rather than an issue with Carto:

[OSIRIS] Taiwan THB fetch failed: ConnectTimeoutError (thbapp.thb.gov.tw:443, timeout: 10000ms)
[OSIRIS] GDACS fetch error:      ConnectTimeoutError (www.gdacs.org:443, timeout: 10000ms)
SatNOGS fetch error:             ConnectTimeoutError (db.satnogs.org:443, timeout: 10000ms)
NASA EONET normalization error:  AbortError: This operation was aborted

Reproduction (deterministic)

docker exec osiris rm -rf /app/.next/cache/fetch-cache
docker restart osiris
sleep 8
# open the dashboard immediately  ->  black map, 500 on proxy-tiles

Warm the six basemap assets once and the map renders on every subsequent load:

for u in \
  "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" \
  "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite.json" \
  "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite.png" \
  "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite@2x.json" \
  "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite@2x.png" \
  "https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json" ; do
  curl -s -o /dev/null -w "%{http_code} $u\n" \
    "http://localhost:3005/api/proxy-tiles?url=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$u")"
done

What I ruled out

This is not DNS, not egress filtering, and not Carto rate-limiting:

  • dns.promises.lookup() from inside the container resolves basemaps.cartocdn.com and tiles.basemaps.cartocdn.com instantly.
  • 50 concurrent raw net.connect(443, ...) from inside the container: 50/50 ok in 340 ms.
  • 50 concurrent plain fetch() (bypassing the route) from inside the container: 50/50 ok in 487 ms.
  • Each failing URL, requested on its own through /api/proxy-tiles, returns 200 in ~100 ms.

It only fails when the proxy requests are in flight at the same time as the app's own cold-start data fetches. Those are heavy — measured on this instance:

Endpoint Time Payload
/api/fires 17.3 s 258 KB
/api/cctv 7.1 s 3.37 MB
/api/satellites 0.08 s 2.35 MB
/api/flights 2.5 MB (Failed to set Next.js data cache … items over 2MB can not be cached)

Likely mechanism

src/app/api/proxy-tiles/route.ts does the upstream fetch through the Next data cache:

const res = await fetch(url.toString(), {
  headers: { Accept: '*/*', 'User-Agent': 'Osiris-Tile-Proxy/1.0' },
  next: { revalidate: 31536000 },
});

Two consequences:

  1. Every failure is a hard 500 with no retry. catch → NextResponse.json({error:'Internal server error'}, {status:500}). One 10 s hiccup on style.json and MapLibre is done — it does not re-request a failed style, so the map is black until the user manually reloads.
  2. Cache misses land in the libuv threadpool. On a cold cache, ~100 tile responses plus multi-MB API responses are being gzip-inflated (zlib → threadpool) and written to fetch-cache on disk (fs → threadpool) while undici's connect path needs dns.lookup() — which is also threadpool work, and only 4 threads by default. Queued getaddrinfo past 10 s presents exactly as UND_ERR_CONNECT_TIMEOUT even though the network is idle. That matches every observation above: fine in isolation, fine at 50 concurrent raw sockets, broken only when the cache-write/inflate storm is running.

After the assets are cached (revalidate: 31536000), the proxy answers in ~2 ms from disk and the problem disappears — until the next fresh container.

Suggested fixes

Roughly in order of effort:

  1. Retry + don't fail hard. Wrap the upstream fetch in 2–3 retries with backoff, and give the connect a longer budget than undici's default 10 s for style/sprite/tiles.json specifically. These six requests are the difference between a working map and a black screen.
  2. Warm the basemap assets at boot (server-side, on first request or via instrumentation.ts), so the style path is never a cold miss racing the data layers.
  3. Stagger the cold-start data fetches. The layers do not all need to fire in the same tick; a small concurrency cap on the ingestion side would take the pressure off everything else, including the >2MB payloads that can't be cached anyway.
  4. Consider not proxying the immutable Carto assets at all. The response already sets Cache-Control: public, max-age=31536000, immutable and Access-Control-Allow-Origin: * — for the vanilla Carto basemap the browser can fetch it directly (which is what the older OsirisMap.tsx did), keeping the server out of the critical path for rendering.
  5. Document that /app/.next/cache is worth persisting in DOCKER.md — a volume there makes image updates non-disruptive.

(1) and (2) alone would fix the black map for self-hosters. Happy to send a PR for the retry + warm-up if that direction sounds right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions