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.
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 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".
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.
GeoJSON CRS, source layers and error handlers
- GeoJSON must be EPSG:4326. Check the bounds, not just the metadata.
source-layeris the layer inside the tile, not the source name.- Attach
map.on("error", ...)β MapLibre is otherwise silent. querySourceFeaturesandqueryRenderedFeaturesanswer different questions.- Null and empty geometries make many clients throw or skip.
- A layer's
minzoomhides it silently below that zoom. - Cross-origin PMTiles needs
Content-Rangeexposed in CORS. - Test with a garish colour and full opacity to rule out paint properties.
Internal links
- How to serve a web map from PMTiles with MapLibre β the setup this debugs
- How to prepare a GeoDataFrame for the web β the CRS and validity steps
- Vector tiles explained: MVT, layers and why they are not GeoJSON β where layer names come from
- My tiles are offset or in the wrong place β when it renders in the wrong location
- How to style data-driven colours in a web map β the paint properties
- My Folium map is blank or will not display β the same problem in Folium
- How to add interactivity and popups to a web map β the query API used here
- My GeoJSON is too big for the browser β when it stalls rather than blanks
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.