My Web Map Is Blank or the Layer Never Appears

On this page

Problem statement

The map loads, the basemap renders, and your data is not there. No error in the console, no failed request in the network tab, nothing.

Web mapping fails silently by design: a missing layer in a tile, an empty tile and a filtered-out feature are all normal conditions, so none of them logs anything. Several distinct causes produce the same blank map, and they need different fixes.

Every cause on this page is reproduced for real: Rutland's 5,513 OpenStreetMap buildings, served as a PMTiles archive and as GeoJSON from a local server, drawn by MapLibre GL JS in headless Chromium, with the map centred on Oakham at zoom 15. The baseline draws them; each broken variant changes one thing.

Quick answer

Work down the list β€” each check eliminates a whole class:

map.on("error", (e) => console.error("map error:", e && e.error));

// 1. is the source loading at all?
map.on("sourcedata", (e) => {
  if (e.sourceId === "buildings" && e.isSourceLoaded) console.log("source loaded");
});

// 2. is anything in the tiles?
console.log(map.querySourceFeatures("buildings", { sourceLayer: "buildings" }).length);

// 3. is anything rendered?
console.log(map.queryRenderedFeatures({ layers: ["buildings-fill"] }).length);

Those three questions, asked in Chromium of the baseline and of each broken variant:

for name, variant in CASES.items():
    result = probe(**variant)
    print(f"{name:26} in source {result['in_source']:5}  rendered {result['rendered']:5}  "
          f"error: {result['error'] or '-'}")
baseline (PMTiles)         in source   441  rendered   331  error: -
baseline (GeoJSON)         in source   854  rendered   331  error: -
GeoJSON in EPSG:27700      in source     0  rendered     0  error: -
source-layer 'building'    in source     0  rendered     0  error: -
layer minzoom 16           in source     0  rendered     0  error: -
filter type == 'yes'       in source   441  rendered     0  error: -
fill-opacity 0             in source   441  rendered   331  error: -
no CORS header             in source     0  rendered     0  error: Failed to fetch
server ignores Range       in source     0  rendered     0  error: Server returned no content-length header or content-length exceeding r

querySourceFeatures returning zero means the data is not reaching the layer β€” a wrong source-layer, a projected GeoJSON, a request the browser refused, or a layer outside its zoom range: with minzoom: 16 at zoom 15, MapLibre did not load the source's tiles at all, so the zoom mistake looks like a data mistake. Returning features while queryRenderedFeatures returns none means the style is hiding them β€” here, the filter. And the paint case returns features from both queries while drawing nothing visible β€” the one failure these queries cannot see.

Six causes of a blank web map: wrong CRS, wrong source-layer, zoom range, filter, paint properties and CORS. measured in Chromium, Oakham at zoom 15causehow to tellGeoJSON in EPSG:277000 in source; bounds outside Β±180source-layer 'building'0 in source; read vector_layerslayer minzoom 16 at zoom 150 in source: tiles never fetchedfilter type == 'yes'441 in source, 0 renderedfill-opacity 0331 "rendered", none visibleno CORS, or Range ignoredthe only two with a map errorFive of the seven were silent; only the refused requests reached map.on("error") spatialworkflow.io
Every one of these is silent. The order of checks is what makes them separable.

Wrong CRS, wrong source layer, zoom range, filters

1. Wrong CRS in the data

GeoJSON must be EPSG:4326. Clients assume it rather than reading it, so a file written in British National Grid has coordinates like [486000, 308800] β€” read as longitude 486,000, which is off the world.

for path in ("data/buildings_27700.geojson", "data/buildings.geojson"):
    first = json.loads(Path(path).read_text())["features"][0]["geometry"]["coordinates"][0][0]
    print(f"{path:30} first vertex {[round(c, 4) for c in first]}")
data/buildings_27700.geojson   first vertex [485000.741, 308910.4166]
data/buildings.geojson         first vertex [-0.7444, 52.6712]

MapLibre drew nothing from the projected file and raised nothing. The fix is one line before export β€” gdf.to_crs(4326) β€” and the tell is fitBounds jumping somewhere absurd, or nothing visible at any zoom.

2. Wrong source-layer

For vector tiles, source-layer must exactly match a layer name inside the tiles. It is not the source name in the style, and it is not the file name. Read it from the archive:

with open("data/buildings.pmtiles", "rb") as f:
    meta = Reader(MmapSource(f)).metadata()
print([layer["id"] for layer in meta["vector_layers"]], meta["vector_layers"][0]["fields"])
['buildings'] {'osm_id': 'String', 'type': 'String', 'name': 'String'}

A mismatch β€” "building" for "buildings" above β€” means MapLibre fetches the tiles successfully, finds no matching layer, and draws nothing. querySourceFeatures with the wrong name returned 0; with the right one it returned hundreds. No error at all.

3. Outside the zoom range

{ id: "buildings-fill", minzoom: 16, ... }

A layer with minzoom: 16 is invisible at zoom 15, correctly and silently β€” and, in MapLibre 4, so is its data: with no layer able to draw at this zoom, the source's tiles were never requested, and querySourceFeatures returned 0 exactly as it did for the wrong source-layer. Check the zoom before concluding the data is missing. Check both the layer's zoom range and the source's β€” a source whose maxzoom is higher than the archive's makes the client ask for tiles that do not exist, which, unlike everything else here, shows in the network tab.

4. A filter that matches nothing

filter: ["==", ["get", "type"], "yes"]

In OpenStreetMap most buildings are tagged building=yes. Geofabrik's shapefile keeps the tag in a column called type β€” and leaves it empty where the value was yes:

print(buildings["type"].value_counts(dropna=False).head(4).to_dict())
{nan: 3583, 'house': 1131, 'industrial': 155, 'residential': 109}

So a filter written from OSM's tagging matches nothing in this data. Printing the first feature's properties from querySourceFeatures settles it immediately β€” the property is absent, not "yes".

5. Paint properties that render nothing

Opacity zero, a colour matching the background, a line width of zero, or a fill on a point layer. All valid, all invisible β€” and, as the table above shows, queryRenderedFeatures still returns the features, because it asks what the renderer placed, not what anyone can see.

paint: { "fill-opacity": 0.6, "fill-color": "#ff00ff" }

Setting a garish colour and full opacity temporarily separates "not rendering" from "rendering invisibly".

6. CORS or range requests

A PMTiles archive on another origin needs Access-Control-Allow-Origin on its responses, and the host must answer a Range request with 206 Partial Content:

for name in ("baseline (PMTiles)", "no CORS header", "server ignores Range"):
    result = probe(**CASES[name])
    print(f"{name:22} status codes {sorted(set(result['statuses']))}, rendered {result['rendered']}, "
          f"error: {result['error'] or '-'}")
baseline (PMTiles)     status codes [200, 206], rendered 331, error: -
no CORS header         status codes [200, 206], rendered 0, error: Failed to fetch
server ignores Range   status codes [200], rendered 0, error: Server returned no content-length header or content-length exceeding r

These two are the exceptions to the silence: both reach map.on("error"), which is why attaching that handler is step zero. A host that returns 200 instead of 206 has sent the whole archive, and the PMTiles client refuses to use it.

A decision path from source loaded, to features in source, to features rendered, isolating the cause at each step. two queries sort the causes into three groupsdoes querySourceFeatures returnanything?nonothing loadedsource-layer, CRS, CORS, zoomyes, none renderedthe style hides itthe filteryes, rendereddrawn but unseenopacity, colour, extentquerySourceFeatures asks what is there; queryRenderedFeatures asks what is drawn spatialworkflow.io
Three queries separate six causes: is the source loading, are there features in it, are any rendered.

A browser diagnostic and a tile check

The data used on this page

Rutland's buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL). The setup writes them as a PMTiles archive with GDAL and as GeoJSON in two CRSs, serves them from a local server on a second origin β€” with CORS and range requests switchable per path β€” and drives MapLibre GL JS 4 with the PMTiles 3 client in headless Chromium through Playwright.

import json
import re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import geopandas as gpd
import requests
from osgeo import gdal
from playwright.sync_api import sync_playwright
from pmtiles.reader import MmapSource, Reader

gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
    Path("data/rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
SHP = "/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp"
buildings = gpd.read_file(SHP)
if not Path("data/buildings.pmtiles").exists():
    gdal.VectorTranslate("data/buildings.pmtiles", SHP, format="PMTiles", layerName="buildings",
                         selectFields=["osm_id", "type", "name"], datasetCreationOptions=["MINZOOM=10", "MAXZOOM=15"])
buildings.to_file("data/buildings.geojson", driver="GeoJSON")
buildings.to_crs(27700).to_file("data/buildings_27700.geojson", driver="GeoJSON")
FILES = {p.name: p.read_bytes() for p in Path("data").glob("buildings*")}
PAGES, STATUS = {}, []


class Host(BaseHTTPRequestHandler):
    """/full/, /nocors/ and /norange/ serve the same files under different rules."""
    protocol_version = "HTTP/1.1"

    def log_message(self, *args):
        pass

    def reply(self, status, body=b"", headers=()):
        STATUS.append(status)
        self.send_response(status)
        for key, value in headers:
            self.send_header(key, value)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def do_OPTIONS(self):
        self.reply(204, headers=[("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Headers", "Range")])

    def do_GET(self):
        if self.path.startswith("/page/"):
            return self.reply(200, PAGES[self.path[6:]].encode(), [("Content-Type", "text/html")])
        mode, name = self.path.split("/")[1:3]
        body = FILES[name]
        headers = [("Content-Type", "application/octet-stream")]
        if mode != "nocors":
            headers += [("Access-Control-Allow-Origin", "*"), ("Access-Control-Expose-Headers", "Content-Range, ETag")]
        found = re.match(r"bytes=(\d+)-(\d*)", self.headers.get("Range") or "")
        if found and mode != "norange":
            first, last = int(found.group(1)), min(int(found.group(2) or len(body) - 1), len(body) - 1)
            return self.reply(206, body[first:last + 1], headers + [
                ("Content-Range", f"bytes {first}-{last}/{len(body)}"), ("Accept-Ranges", "bytes")])
        return self.reply(200, body, headers)

    do_HEAD = do_GET


servers = [ThreadingHTTPServer(("127.0.0.1", 0), Host) for _ in range(2)]
for server in servers:
    server.daemon_threads = True
    threading.Thread(target=server.serve_forever, daemon=True).start()
SITE, DATA = (f"http://127.0.0.1:{s.server_address[1]}" for s in servers)       # two origins

TEMPLATE = """<!doctype html><meta charset="utf-8"><style>html,body,#map { height:100%; margin:0 }</style>
<div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pmtiles@3/dist/pmtiles.js"></script>
<script>
window.errors = [];
const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile);
window.map = new maplibregl.Map({ container: "map", center: [-0.7265, 52.6700], zoom: 15, style: STYLE });
map.on("error", e => window.errors.push(String(e && e.error && e.error.message)));
</script>"""


def probe(source, layer_overrides=None, script=None, wait=6):
    """Load one style in Chromium; count features in the source and rendered, and collect errors."""
    layer = {"id": "buildings-fill", "type": "fill", "source": "buildings",
             "paint": {"fill-color": "#0ea5e9", "fill-opacity": 0.6}}
    if source["type"] == "vector":
        layer["source-layer"] = "buildings"
    layer.update(layer_overrides or {})
    style = {"version": 8, "sources": {"buildings": source}, "layers": [layer]}
    name = f"p{len(PAGES)}"
    PAGES[name] = TEMPLATE.replace("STYLE", json.dumps(style))
    STATUS.clear()
    with sync_playwright() as p:
        browser = p.chromium.launch()
        tab = browser.new_page(viewport={"width": 800, "height": 600})
        tab.goto(f"{SITE}/page/{name}")
        time.sleep(wait)
        opts = {"sourceLayer": layer["source-layer"]} if "source-layer" in layer else {}
        result = {
            "in_source": tab.evaluate(f"map.querySourceFeatures('buildings', {json.dumps(opts)}).length"),
            "rendered": tab.evaluate("map.queryRenderedFeatures({layers: ['buildings-fill']}).length"),
            "error": (tab.evaluate("window.errors") or [None])[0],
            "statuses": list(STATUS),
        }
        if script:
            result["script"] = tab.evaluate(script)
        browser.close()
    if result["error"]:
        result["error"] = result["error"][:70]
    return result


PMTILES = {"type": "vector", "url": f"pmtiles://{DATA}/full/buildings.pmtiles"}
CASES = {
    "baseline (PMTiles)": {"source": PMTILES},
    "baseline (GeoJSON)": {"source": {"type": "geojson", "data": f"{DATA}/full/buildings.geojson"}},
    "GeoJSON in EPSG:27700": {"source": {"type": "geojson", "data": f"{DATA}/full/buildings_27700.geojson"}},
    "source-layer 'building'": {"source": PMTILES, "layer_overrides": {"source-layer": "building"}},
    "layer minzoom 16": {"source": PMTILES, "layer_overrides": {"minzoom": 16}},
    "filter type == 'yes'": {"source": PMTILES, "layer_overrides": {"filter": ["==", ["get", "type"], "yes"]}},
    "fill-opacity 0": {"source": PMTILES,
                       "layer_overrides": {"paint": {"fill-color": "#0ea5e9", "fill-opacity": 0}}},
    "no CORS header": {"source": {"type": "vector", "url": f"pmtiles://{DATA}/nocors/buildings.pmtiles"}},
    "server ignores Range": {"source": {"type": "vector", "url": f"pmtiles://{DATA}/norange/buildings.pmtiles"}},
}
print(f"{len(buildings):,} buildings; files:", {k: f"{len(v) / 1e6:.2f} MB" for k, v in FILES.items()})
5,513 buildings; files: {'buildings.pmtiles': '0.83 MB', 'buildings.geojson': '2.16 MB', 'buildings_27700.geojson': '3.13 MB'}

Example 1 β€” a diagnostic that runs in the browser

The function below is plain JavaScript; here it is passed to Chromium as a string and run against three of the broken maps:

DIAGNOSE = """(() => {
  const [sourceId, layerId] = ["buildings", "buildings-fill"];
  const problems = [];
  const layer = map.getLayer(layerId);
  const zoom = map.getZoom();
  const min = layer.minzoom ?? 0, max = layer.maxzoom ?? 24;
  if (zoom < min || zoom > max) problems.push(`zoom ${zoom.toFixed(1)} is outside the layer range ${min}-${max}`);
  const opacity = map.getPaintProperty(layerId, layer.type + "-opacity");
  if (opacity === 0) problems.push("opacity is 0");
  const sourceLayer = layer.sourceLayer;
  const inSource = map.querySourceFeatures(sourceId, sourceLayer ? { sourceLayer } : {});
  if (inSource.length === 0) problems.push("no features in the source: check source-layer, viewport, CRS, requests");
  const rendered = map.queryRenderedFeatures({ layers: [layerId] });
  if (inSource.length > 0 && rendered.length === 0) problems.push("features exist but none render: check filter and zoom");
  if (inSource.length && !("type" in inSource[0].properties)) problems.push("first feature has no 'type' property");
  return problems;
})()"""

for name in ("layer minzoom 16", "fill-opacity 0", "source-layer 'building'"):
    print(f"{name:24}", probe(**CASES[name], script=DIAGNOSE)["script"])
layer minzoom 16         ['zoom 15.0 is outside the layer range 16-24', 'no features in the source: check source-layer, viewport, CRS, requests']
fill-opacity 0           ['opacity is 0', "first feature has no 'type' property"]
source-layer 'building'  ['no features in the source: check source-layer, viewport, CRS, requests']

The distinction between querySourceFeatures and queryRenderedFeatures is what makes this work. The first asks "is the data here"; the second asks "was any of it placed". The opacity check is there because neither query can answer "can anyone see it".

Buildings MapLibre reported as rendered for the baseline and each broken variant. queryRenderedFeatures, same view, one change eachbaseline331fill-opacity 0331, and nothing visiblefilter matches nothing0 (441 in source)minzoom, source-layer0 (0 in source)EPSG:27700 GeoJSON0 (0 in source)no CORS, Range ignored0, with a map error0331The one variant the query calls a success is the one nobody can see spatialworkflow.io
The quick-answer table as a chart: only paint fools the rendered-features query.

Example 2 β€” checking the data before it reaches the browser

def check_web_layer(path, expect_columns=()):
    """Everything a browser will silently reject."""
    gdf = gpd.read_file(path)
    problems = []
    if gdf.crs is None:
        problems.append("no CRS set")
    elif gdf.crs.to_epsg() != 4326:
        problems.append(f"CRS is {gdf.crs.to_string()}, must be EPSG:4326")
    bounds = gdf.total_bounds
    if not (-180 <= bounds[0] <= 180 and -90 <= bounds[1] <= 90):
        problems.append(f"bounds {[round(b) for b in bounds]} are not longitude/latitude β€” the data is projected")
    empty = int((gdf.geometry.isna() | gdf.geometry.is_empty).sum())
    if empty:
        problems.append(f"{empty:,} null or empty geometries")
    invalid = int((~gdf.geometry.is_valid).sum())
    if invalid:
        problems.append(f"{invalid:,} invalid geometries")
    for column in expect_columns:
        if column not in gdf.columns:
            problems.append(f"expected attribute '{column}' is missing")
        elif gdf[column].isna().all():
            problems.append(f"attribute '{column}' is entirely null")
    return problems

for path in ("data/buildings.geojson", "data/buildings_27700.geojson"):
    print(path, check_web_layer(path, expect_columns=["type", "building"]))
data/buildings.geojson ["expected attribute 'building' is missing"]
data/buildings_27700.geojson ['CRS is EPSG:27700, must be EPSG:4326', 'bounds [480135, 293137, 504972, 318199] are not longitude/latitude β€” the data is projected', "expected attribute 'building' is missing"]

Checking the bounds against the longitude and latitude ranges catches the projected case even when the CRS metadata is missing or wrong β€” GeoJSON written by GDAL in another CRS records it in a legacy crs member that browsers ignore. The attribute check finds the filter problem from step 4 before any style is written.

Example 3 β€” verifying the tiles themselves

import gzip

import mapbox_vector_tile as mvt
from pmtiles.tile import zxy_to_tileid


def check_tile(archive, z, x, y):
    """Read one tile from a PMTiles archive and report what is actually inside it."""
    with open(archive, "rb") as f:
        reader = Reader(MmapSource(f))
        payload = reader.get(z, x, y)
    if payload is None:
        print(f"  {z}/{x}/{y}: no tile in the archive")
        return None
    if payload[:2] == b"\x1f\x8b":
        payload = gzip.decompress(payload)
    decoded = mvt.decode(payload)
    for name, layer in decoded.items():
        fields = set()
        for feature in layer["features"][:50]:
            fields.update(feature.get("properties", {}))
        print(f"  {z}/{x}/{y} layer '{name}': {len(layer['features'])} features, fields {sorted(fields)}")
    return decoded


check_tile("data/buildings.pmtiles", 15, 16317, 10723)      # central Oakham
check_tile("data/buildings.pmtiles", 16, 32635, 21447)      # past the archive's maxzoom
  15/16317/10723 layer 'buildings': 183 features, fields ['name', 'osm_id', 'type']
  16/32635/21447: no tile in the archive

This is the step that resolves the source-layer question definitively: the layer names printed here are exactly what the style must use. A tile that is simply absent above maxzoom is the normal case β€” MapLibre overzooms the last level β€” and is not an error.

Why everything fails silently

Why everything fails silently

Web map rendering treats absence as normal. A tile with no features is normal over the sea. A layer missing from a tile is normal where that layer has no data. A feature filtered out is the filter working.

So none of these can raise an error without producing false alarms on every ordinary map. The cost is that a genuine misconfiguration is indistinguishable from an empty area β€” of the seven broken variants above, only the two where the browser refused a request said anything.

The defence is the query API: querySourceFeatures and queryRenderedFeatures tell you what the renderer actually has, which no amount of staring at the style will.

Why the CRS mistake is so common

Desktop GIS reprojects on the fly. A British National Grid layer and a WGS 84 layer overlay correctly in QGIS, so the CRS never comes up.

GeoJSON has no CRS field in current practice β€” RFC 7946 fixes it as WGS 84. Clients therefore do not check, and a projected GeoJSON is not an error, just coordinates in the wrong place. The bounds check is the reliable test: longitude outside Β±180 or latitude outside Β±90 means the data is projected.

Why source-layer is a separate concept

A vector tile source can contain many layers, and one style layer draws one of them. So the style needs to name both the source and the layer within it. The names are independent: a source called buildings can contain layers with any names. There is no default and no fallback, and because an absent layer is a normal condition, a typo produces silence. Reading vector_layers from the archive metadata is the only reliable way to get the name right.

Why to check rendering separately from data

The two failure classes need opposite fixes. If querySourceFeatures returns zero, the data is not arriving: wrong URL, wrong source-layer, data in the wrong CRS, tiles not generated for this area or zoom, a refused request β€” or a layer zoom range that stopped the tiles being requested at all. If it returns features and queryRenderedFeatures returns none, the data is there and the style is hiding it: a filter. And if both return features and the screen is empty, the paint is the problem.

Confusing the classes sends people to rebuild tiles when the problem is a filter expression.

querySourceFeatures ignoring filters and styling against queryRenderedFeatures respecting both. two query functions answering different questionsrespects filtersrespects zoomanswersquerySourceFeaturesnonois the data here?queryRenderedFeaturesβœ“yesβœ“yesis any of it drawn?Both only see loaded tiles β€” and a source whose layers cannot draw at this zoom loads none spatialworkflow.io
Confusing the two sends people to rebuild tiles when the problem is a filter expression.

GeoJSON CRS, source layers and error handlers

  • GeoJSON must be EPSG:4326. Check the bounds, not just the metadata.
  • source-layer is the layer inside the tile, not the source name.
  • Attach map.on("error", ...) β€” MapLibre is otherwise silent.
  • querySourceFeatures and queryRenderedFeatures answer different questions.
  • Null and empty geometries make many clients throw or skip.
  • A layer's minzoom hides it silently below that zoom.
  • Cross-origin PMTiles needs Content-Range exposed in CORS.
  • Test with a garish colour and full opacity to rule out paint properties.

FAQ

Why is my web map blank?

Six common causes: a projected CRS, a wrong source-layer, being outside the layer's zoom range, a filter matching nothing, paint properties rendering nothing, or a CORS failure. All are silent.

How do I tell whether the data is loading?

map.querySourceFeatures(sourceId, { sourceLayer }). Zero features with successful tile requests means the source-layer name is wrong.

What is the difference between querySourceFeatures and queryRenderedFeatures?

The first asks what is in the source; the second asks what is actually drawn. Features in one and not the other means the style is hiding them.

Why does my GeoJSON not appear?

Usually a projected CRS. Check total_bounds against Β±180 and Β±90 β€” anything outside means the data is not longitude and latitude.

How do I find the right source-layer name?

Read vector_layers from the archive metadata, or decode one tile and print its layer names.

Why does it work locally and not when deployed?

CORS. Cross-origin PMTiles needs Access-Control-Allow-Origin and Content-Range in Access-Control-Expose-Headers.

Why does nothing render even though features are in the source?

A filter matching nothing, a zoom range excluding the current view, or paint properties that draw nothing. Set a garish colour at full opacity to check.