My Web Map Is Blank or the Layer Never Appears
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. Six distinct causes produce the same blank map, and they need different fixes.
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 === "mydata" && e.isSourceLoaded) {
console.log("source loaded", map.getSource("mydata"));
}
});
// 2. is anything in the tiles?
console.log(map.querySourceFeatures("mydata", { sourceLayer: "buildings" }).length);
// 3. is anything rendered?
console.log(map.queryRenderedFeatures({ layers: ["buildings-fill"] }).length);
querySourceFeatures returning zero with successful tile requests means the source-layer name is wrong. Returning features while queryRenderedFeatures returns none means styling or zoom range.
Step-by-step solution
1. Wrong CRS in the data
GeoJSON must be EPSG:4326. Clients assume it rather than reading it, so a file written in UTM has coordinates like [422200, 5886250] β interpreted as longitude 422,200, which is off the world.
print(gdf.crs)
gdf = gdf.to_crs(4326)
The tell is map.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 MVT. It is not the source name in the style.
from pmtiles.reader import Reader, MmapSource
with open("tiles.pmtiles", "rb") as f:
print([l["id"] for l in Reader(MmapSource(f)).metadata()["vector_layers"]])
A mismatch means MapLibre fetches tiles successfully, finds no matching layer, and draws nothing. This is the most common cause and produces no error at all.
3. Outside the zoom range
{ id: "buildings", minzoom: 14, maxzoom: 22, ... }
A layer with minzoom: 14 is invisible at zoom 13, correctly and silently. Check both the layer's zoom range and the source's.
A source with the wrong maxzoom also produces 404s past the pyramid β visible in the network tab, unlike everything else here.
4. A filter that matches nothing
filter: ["==", ["get", "building"], "yes"]
If the attribute is missing from the tiles, or the value is "Yes", or the property was dropped during generalisation, the filter matches nothing.
const features = map.querySourceFeatures("mydata", { sourceLayer: "buildings" });
console.log(features.length, features[0] && features[0].properties);
Printing the first feature's properties settles it immediately.
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.
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
Cross-origin data needs Access-Control-Allow-Origin, and PMTiles additionally needs Content-Range in Access-Control-Expose-Headers.
A host that returns 200 instead of 206 for a range request has sent the whole archive, and the client cannot use it.
Code examples
Example 1 β a diagnostic that runs in the browser
function diagnose(map, sourceId, layerId, sourceLayer) {
const problems = [];
const source = map.getSource(sourceId);
if (!source) {
problems.push(`source '${sourceId}' does not exist in the style`);
return problems;
}
console.log("source loaded:", map.isSourceLoaded(sourceId));
const layer = map.getLayer(layerId);
if (!layer) {
problems.push(`layer '${layerId}' does not exist in the style`);
} else {
const zoom = map.getZoom();
const min = layer.minzoom ?? 0;
const 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 inSource = map.querySourceFeatures(sourceId,
sourceLayer ? { sourceLayer } : {});
console.log(`features in source: ${inSource.length}`);
if (inSource.length === 0) {
problems.push("no features in the source β check source-layer, the " +
"current viewport, and whether tiles are loading");
} else {
console.log("first feature properties:", inSource[0].properties);
}
const rendered = map.queryRenderedFeatures({ layers: [layerId] });
console.log(`features rendered: ${rendered.length}`);
if (inSource.length > 0 && rendered.length === 0) {
problems.push("features exist but none render β check the filter, " +
"the zoom range and the paint properties");
}
problems.forEach((p) => console.warn("!", p));
return problems;
}
The distinction between querySourceFeatures and queryRenderedFeatures is what makes this work. The first asks "is the data here"; the second asks "is any of it drawn".
Example 2 β checking the data before it reaches the browser
import json
import geopandas as gpd
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 {bounds} are not longitude/latitude β "
"the data is probably projected")
empty = int((gdf.geometry.isna() | gdf.geometry.is_empty).sum())
if empty:
problems.append(f"{empty:,} null or empty geometries β many clients "
"throw on these")
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")
print(f" {len(gdf):,} features, bounds "
f"{[round(b, 4) for b in bounds]}")
for issue in problems:
print(f" ! {issue}")
return problems
Checking the bounds against the longitude and latitude ranges catches the projected-CRS case even when the CRS metadata is missing or wrong, which is the situation that produces the most confusing blank maps.
Example 3 β verifying the tiles themselves
import gzip
import mapbox_vector_tile as mvt
import requests
def check_tile(url_template, z, x, y):
"""Fetch one tile and report what is actually inside it."""
url = url_template.format(z=z, x=x, y=y)
response = requests.get(url, timeout=30)
print(f" {url} -> HTTP {response.status_code}, "
f"{len(response.content)} bytes")
if response.status_code != 200:
print(" ! tile request failed β check the URL template and the "
"zoom range")
return None
payload = response.content
if payload[:2] == b"\x1f\x8b":
payload = gzip.decompress(payload)
print(" tile was gzipped")
try:
decoded = mvt.decode(payload)
except Exception as exc:
print(f" ! not a valid vector tile: {type(exc).__name__}: {exc}")
return None
if not decoded:
print(" ! tile decoded but contains no layers")
for name, layer in decoded.items():
fields = set()
for feature in layer["features"][:20]:
fields.update(feature.get("properties", {}))
print(f" layer '{name}': {len(layer['features'])} features, "
f"fields {sorted(fields)}")
return decoded
This is the step that resolves the source-layer question definitively. The layer names printed here are exactly what the style must use.
Explanation
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.
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 UTM layer and a WGS 84 layer overlay correctly in QGIS, so the CRS never comes up.
GeoJSON has no CRS field in current practice β the specification 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 mydata can contain layers called buildings and roads. There is no default and no fallback.
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, tiles not generated for this area or zoom, or a CORS failure.
If it returns features and queryRenderedFeatures returns none, the data is there and the style is hiding it: a filter, a zoom range, zero opacity, or a colour matching the background.
Confusing the two sends people to rebuild tiles when the problem is a filter expression.
Edge cases or notes
- 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.