Debugging Missing Icons in MapLibre Style Sprites
A symbol layer that renders nothing has four likely causes — a name that is not in the index, a missing pixel-ratio variant, a sprite URL that includes an extension, or a CORS failure on the JSON — and none of them produce an error the pipeline can see.
Core Diagnostic Workflow
The reason this needs a procedure rather than intuition is that the renderer’s response to every one of these faults is identical: draw nothing, continue. So the diagnosis has to work outward from what is definitely true.
- Are the features there at all? Query rendered features for the layer. If none come back, the sprite is irrelevant — the fault is in the source, the filter or the zoom range.
- Did all four sprite files load? The renderer requests base
.pngand.json, plus@2xversions of each. Check the network log for four 200 responses, and check that the JSON response really is JSON rather than an HTML error page served with a 200. - Does the referenced name exist in the index? Extract every
icon-imagevalue from the style and diff it against the index keys, in both directions. - Is the missing variant density-specific? A missing
@2xpair fails only on high-density displays, which is precisely why it survives desktop testing. - Is the icon drawn but invisible? A size expression evaluating to zero, or an offset larger than the tile, draws nothing while passing every reference check.
Production-Ready Python Implementation
import json
import re
import urllib.request
ICON_REF = re.compile(r'"icon-image"\s*:\s*(?:"([^"]+)"|\[[^\]]*"([^"]+)"\s*\])')
def audit_sprite(style_url: str, expect_ratios=(1, 2)) -> dict:
"""Check a published style against its published sprite, both directions."""
style = json.loads(urllib.request.urlopen(style_url).read())
sprite = style.get("sprite")
if not sprite:
return {"ok": False, "reason": "style declares no sprite"}
if sprite.endswith((".png", ".json")):
return {"ok": False,
"reason": f"sprite URL carries an extension: {sprite!r}"}
referenced = set()
for m in ICON_REF.finditer(json.dumps(style)):
referenced.add(m.group(1) or m.group(2))
problems, indexes = [], {}
for ratio in expect_ratios:
suffix = "" if ratio == 1 else f"@{ratio}x"
for ext in (".png", ".json"):
url = f"{sprite}{suffix}{ext}"
try:
body = urllib.request.urlopen(url).read()
except Exception as exc: # noqa: BLE001
problems.append(f"{url}: {exc!r}")
continue
if ext == ".json":
try:
indexes[ratio] = json.loads(body)
except json.JSONDecodeError:
problems.append(f"{url}: returned non-JSON (error page?)")
base = indexes.get(1, {})
missing = sorted(n for n in referenced if n and n not in base)
if missing:
problems.append(f"style references icons absent from the sprite: {missing}")
for ratio, idx in indexes.items():
extra = sorted(set(base) ^ set(idx))
if extra:
problems.append(f"@{ratio}x index differs from 1x by: {extra[:5]}")
return {"ok": not problems, "problems": problems,
"referenced": len(referenced), "indexed": len(base)}
The extension check comes first because it is the fault that most convincingly looks like something else: the sprite URL opens perfectly in a browser, and the renderer is requesting basemap.png.png. Comparing the 1x and 2x index key sets catches the other silent case, where one variant was rebuilt and the other was not.
Header-Inspection and Verification Best Practices
- Check the JSON is JSON. A CDN or an SPA router that serves an HTML error page with a 200 status satisfies a naive
status == 200test and fails to parse. Assert the parse, not the status. - Test at both device pixel ratios. Emulate a 2x device in headless testing. Desktop-only testing systematically misses the missing
@2xpair, which is the most common density-specific fault. - Compare index key sets, not counts. Two indexes with the same number of entries and different names pass a count check and fail on the specific icons that differ.
- Bisect with a hard-coded style. Replace the
icon-imageexpression with a literal known-good name and the size with1. If the icon appears, the fault is in the expression rather than the sprite — which halves the search space in one edit. - Log the sprite version in the page. Stamping the sprite build hash somewhere visible turns “which sprite is this client using” from an investigation into a glance, and that question comes up on every stale-icon report.
Integration and Next Steps
Everything here is a diagnosis for a fault that should have been caught at build time. Move the name diff into the sprite build described in Building Mapbox Sprite Sheets from an SVG Icon Library, and run the published-artefact audit above as a post-deploy smoke test so a CDN or CORS fault is caught by the pipeline rather than by a reader. Publishing sheet and index as a versioned pair, as Symbol and Sprite Pipelines recommends, removes the mismatched-variant class entirely.
Frequently Asked Questions
Why does MapLibre not report a missing icon?
Because icon names can be computed from feature properties through expressions, so at draw time the renderer cannot distinguish an icon name that was computed and does not exist from a feature that legitimately has no icon. It skips the symbol and carries on. Some builds log a console warning, but nothing propagates anywhere a pipeline can observe it, which is exactly why the name diff has to run at build time rather than being relied on at runtime.
The sprite URL works in a browser but the icons are still missing. Why?
Usually because the style includes the file extension. A sprite value of ".../basemap.png" causes the renderer to request ".../basemap.png.png" and ".../basemap.png.json", both of which 404 — while the URL you pasted into a browser is perfectly fine. The second common cause is a CORS policy that permits the image request but not the JSON fetch, so the sheet arrives and the index does not, and the renderer has pixels it cannot address.
Icons appear on my laptop and not on a phone. What differs?
Device pixel ratio. A device requesting the @2x variant receives a 404 when only the 1x pair was published, and the symbol layer renders empty on that device while working everywhere else. Publish both pairs from a single build invocation rather than producing one now and the other later, and emulate a high-density device in the headless smoke test so this fault is caught by the pipeline.
Everything checks out and the icons are still invisible. What is left?
Size and anchor. An icon-size expression that evaluates to zero at the current zoom draws nothing while passing every reference check, and an icon-offset larger than the symbol can place it outside the tile containing its feature. Both are style logic faults rather than sprite faults. Hard-code a size of one and an offset of zero; if the icon appears, the expression is the problem and the sprite was never involved.
Related
- Building Mapbox Sprite Sheets from an SVG Icon Library — where the build-time name diff belongs.
- Sizing Point Symbols Across Zoom Levels Deterministically — the size expressions that can evaluate to zero.
Back to Symbol and Sprite Pipelines