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.

  1. 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.
  2. Did all four sprite files load? The renderer requests base .png and .json, plus @2x versions 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.
  3. Does the referenced name exist in the index? Extract every icon-image value from the style and diff it against the index keys, in both directions.
  4. Is the missing variant density-specific? A missing @2x pair fails only on high-density displays, which is precisely why it survives desktop testing.
  5. 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.
Working outward from what is definitely true A five-step diagnostic path. Step one asks whether features render at all, which separates source faults from sprite faults. Step two checks that four sprite files returned 200. Step three diffs style icon names against index keys. Step four checks the pixel-ratio variant. Step five rules out zero size and off-canvas offset. Each step names the fault it isolates. 1 · do features render at all? no → source or filter 2 · did all four sprite files load? no → URL or CORS 3 · is the name in the index? no → rename or build gap 4 · size zero or offset off-canvas? yes → style expression Every step above produces the same visible symptom, which is why guessing between them is slow.
Four distinct faults with one symptom. The order matters because each step eliminates a whole class of cause rather than testing a hypothesis.

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 == 200 test 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 @2x pair, 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-image expression with a literal known-good name and the size with 1. 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.
What each fault looks like, and where A table of four faults against two display types. A sprite URL with an extension gives no icons on either display. A CORS failure on the JSON gives no icons on either. A missing 2x pair gives icons on standard displays and none on high-density ones. A renamed icon gives no icons for that one layer on both. Only the third row is display-dependent, which is why it survives desktop testing. fault standard high density sprite URL includes .png none none CORS blocks the JSON none none @2x pair not published all present none icon renamed in the set that layer only that layer only Only the highlighted row depends on the device, and it is the row a desktop test cannot see. Emulating a 2× device in the headless check costs one line and covers it.
Three of the four faults are visible everywhere, so any test finds them. The fourth is visible only on the devices least likely to be in the test matrix.

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.

Moving each check to the earliest place it can run Three stages. At build time, the style-to-index name diff catches renames and gaps before anything is published. At deploy time, an artefact audit catches URL, CORS and missing-variant faults against the real published files. At runtime only genuine expression bugs remain, and those are style logic rather than sprite problems. build — diff style icon names against the index catches renames and build gaps before anything is published deploy — audit the published artefacts catches URL extensions, CORS and a missing @2x pair against the real files runtime — only expression bugs remain zero size, off-canvas offset: style logic, not sprite faults Each stage removes a class of fault from the one below it, which is what shrinks the runtime search.
The runtime row is short because the two rows above it have removed everything that could have been checked earlier. That is the goal — not a better debugger, but fewer things left to debug.

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.


Back to Symbol and Sprite Pipelines