Debugging Missing Icons and Fonts in MapLibre

Labels or icons disappearing is one symptom with four causes, and MapLibre reports none of them. The network panel distinguishes three of them immediately; the fourth — a name that resolves but does not match — needs one comparison. Work through them in that order and the cause is usually found before the second cup of coffee.

When to Use This

Text renders nowhere on the map. Symbol layers place empty gaps where icons should be. Or either of those happens only in staging, only on a phone, or only for one layer.

The Four Causes

Four causes, distinguished by what the network panel showsA decision tree separating a 404 on the asset URL, a CORS failure, a name mismatch with a successful fetch, and a style layer that is not being drawn at all.DIAGNOSEWhat does the network panel show for the sprite orglyph request?404 on the assetURLThe asset was neverpublished, or the path iswrongCORS error, or noresponseThe bucket policy does notallow the map's origin200, but nothingrendersA name mismatch — theasset loaded, the key doesnot exist in itNo request at allThe layer is filtered out,zoomed out, or has notext-field

The last branch is worth checking first despite being listed last, because it is the cheapest: if MapLibre never requested the asset, no amount of debugging the asset will help.

Production Command

Four commands, in order of how much they rule out:

bash
BASE=https://tiles.example.com/v43
ORIGIN=https://maps.example.com

# 1. Does the sprite index exist at all?
curl -sfI "$BASE/sprite.json" | head -1
curl -sfI "$BASE/sprite.png"  | head -1
curl -sfI "$BASE/[email protected]" | head -1     # requested on high-DPI screens

# 2. Does the exact glyph URL exist? Encode the fontstack exactly as MapLibre does.
STACK=$(jq -r '.layers[] | select(.layout["text-font"]) | .layout["text-font"] | join(",")' \
        style.json | head -1)
curl -sfI "$BASE/../fonts/$(printf %s "$STACK" | jq -sRr @uri)/0-255.pbf" | head -1

# 3. Does CORS allow the map's origin?
curl -sI -H "Origin: $ORIGIN" "$BASE/sprite.json" \
  | grep -i "access-control-allow-origin" || echo "NO CORS HEADER"

# 4. Do the names in the style exist in the sprite index?
curl -s "$BASE/sprite.json" | jq -r 'keys[]' | sort > /tmp/have
jq -r '[.layers[] | (.layout // {})["icon-image"] | strings] | unique | .[]' style.json \
  | sort > /tmp/want
comm -23 /tmp/want /tmp/have | sed 's/^/style names an icon that is not packed: /'

Command three is the one people skip, and it is the difference between “works locally, fails deployed” and a five-minute fix. A missing Access-Control-Allow-Origin produces a console error naming CORS, but the symptom on the map is identical to a 404.

When MapLibre asks for each assetThe style is parsed, the sprite index and image are fetched, glyph ranges are fetched as labels are laid out, and symbols are drawn only after both have resolved.MapLibreSprite URLGlyph URLGET sprite.jsonindex of icon positionsGET sprite.pngGET fontstack/0-255.pbfonly when a label needs itSDF outlineslabels appear only now
Both fetches happen after the style parses and before the first labelled frame. A failure at either point leaves the geometry visible and the symbols absent.

Reading the Symptoms

Text missing everywhere, icons fine. A glyph problem. Almost always the fontstack directory name — check the URL-encoded path against what is on disk, character for character.

Icons missing, text fine. A sprite problem. If sprite.json is a 200, the index loaded and the icon name is not in it; compare with command four.

Icons blank only on a phone. The @2x pair is missing. The 1× files resolve, the high-DPI request 404s, and MapLibre draws nothing rather than falling back.

Everything missing on one layer only. Not an asset problem. The layer’s filter matches nothing, its zoom range excludes the current view, or its text-field evaluates to null — which is a tile attribute question rather than an asset one.

Works locally, fails deployed. CORS, nearly every time. Local development serves the style and its assets from one origin, so no policy is exercised.

The order that finds the cause fastestFive diagnostic steps ordered by how much each rules out: whether a request was made, whether it succeeded, whether CORS allowed it, whether the name matches, and whether the layer draws at all.IN THIS ORDERConfirm a request was made at allno request means the layer is not being drawn, not that the asset ismissingCheck the status code on the exact URLcopy it from the network panel, do not reconstruct itCheck CORS with an explicit Origin headerthe symptom is identical to a 404 on the map itselfCompare the names the style wants against the indexa 200 that renders nothing is always a name mismatchOnly then read the style layerthe least likely cause and the most time-consuming to inspect
Each step is seconds and eliminates a whole class. Starting at the bottom — reading the style — is what turns a five-minute problem into an afternoon.
Reading the symptom back to the layer that failedFive symptoms mapped to which asset failed, what the network panel shows, and the fix for each.SYMPTOM TABLEassetnetwork panel showsfixNo text anywhereGlyphs404 on a .pbfFontstack directorynameBlank icon slotsSprite200 on sprite.jsonIcon not in the indexIcons soft on mobileSprite @2x404 on sprite@2xPublish the 2x pairFine locally, brokendeployedEitherCORS errorAllow the map's originIcons invisible on darkNeitherNothing at allRe-tint the sprite
The bottom row is the one that fools people: nothing is failing, so no diagnostic tool reports anything.

Interaction Effects

With the style validation suite. Every check on this page belongs in the dependency phase of style validation, where it runs before deploy rather than after a report. The commands are the same; only the timing differs.

With versioned deploys. A sprite index cached from one version against an atlas from another draws the wrong icons — a symptom that looks like a packing bug and is a caching one. Both files must share a version prefix and an immutable policy.

With themes. A dark-theme style pointing at a light-theme sprite resolves every name successfully and draws icons that are invisible against the background. Nothing in this diagnostic tree catches that, because nothing is failing.

Performance Impact

Debugging aside, these assets sit on the path to the first labelled frame. A sprite index and image plus one glyph range is typically 150–300 KB and three requests, all of which must complete before symbols appear.

That is why a map often renders geometry promptly and then populates with labels and icons a moment later, and why serving both bundles from the same origin as the tiles — no extra DNS lookup, no extra TLS handshake — is worth doing even though the bytes are modest.

Common Mistakes

Reconstructing the URL by hand. The fontstack encoding is easy to get subtly wrong. Copy the failing URL out of the network panel.

Testing CORS without an Origin header. curl without one gets a response that tells you nothing about the browser’s outcome.

Assuming a 200 means success. A sprite index that loads and lacks the icon is a 200 and an empty map symbol.

Debugging the style first. It is the least likely cause and the most time-consuming to read.

FAQ

Why does MapLibre not log a warning for a missing icon?

Because an absent icon is indistinguishable from an intentionally conditional one — an icon-image expression that legitimately evaluates to nothing for some features. Warning on it would produce false positives on most real styles.

Can I use a fallback icon?

Yes: an icon-image expression wrapped in coalesce with a known-present name renders a placeholder rather than nothing, which turns a silent failure into a visible one.

Why do labels sometimes appear after a delay?

Because glyph ranges are fetched lazily, only when a label that needs them is laid out. Panning into an area with different characters can trigger a new range fetch mid-session.

Does an @2x sprite need its own index?

Yes — [email protected] as well as [email protected]. Publishing only the image leaves the index 404ing and no icons at all on high-DPI screens.