Fixing Blank or Grey Tiles in an Embedded Map
Problem statement
The app renders, the map container is the right size, and it is a grey grid. Or the basemap appears and your data does not. Or it works locally and not in production.
For an embedded map โ folium, pydeck, MapLibre inside Streamlit or Dash โ there are six causes, and the browser's console and network panel distinguish them in about a minute:
- the tile URL is wrong, or needs a key that is not being sent
- mixed content โ an HTTPS page requesting HTTP tiles, which browsers block silently
- CORS โ the tiles load as images and fail when a canvas renderer reads them
- the data layer is outside the view, usually a CRS problem
- the layer is there and invisible โ zero opacity, zero radius, or a style that draws nothing
- the container has no height, so the map is a zero-pixel element
The last one accounts for more grey maps in dashboards than any tile problem.
Quick answer
Work down this list in the browser's developer tools:
1. Console any error at all? mixed content and CORS both appear here
2. Network are tile requests being made? what status do they return?
3. Network is the data request being made, and what size is the response?
4. Elements does the map container have a non-zero height?
5. App does the layer's style draw anything at this zoom?
# the two most common fixes
folium.Map(tiles="https://โฆ/{z}/{x}/{y}.png", # https, not http
attr="ยฉ OpenStreetMap contributors") # required for a custom tile URL
st_folium(m, height=560) # an explicit height
A folium map with a custom tiles URL and no attr raises; a map in a container with no height renders as nothing at all.
Step-by-step solution
1. Check the console first
Two errors appear there and nowhere else:
Mixed Content: The page at 'https://app.example.org/' was loaded over HTTPS,
but requested an insecure resource 'http://tiles.example.org/โฆ'. This request
has been blocked.
Access to image at 'https://tiles.example.org/โฆ' from origin
'https://app.example.org' has been blocked by CORS policy.
Mixed content is blocked with no network entry at all, which is why the network panel alone can be misleading. The fix is HTTPS tiles โ every public provider offers them.
2. Check whether tiles are being requested, and what they return
In the network panel, filter for the tile path:
- no requests โ the layer was never added, or the container has no size
- 404 โ the URL template, the zoom range, or the XYZ/TMS scheme
- 403 โ an API key is missing, wrong, or restricted to another domain
- 429 โ a rate limit; public tile servers have usage policies
- 200 with tiny bodies โ the server is returning an empty or error tile
A 403 from a keyed provider in production and not locally is almost always a domain restriction on the key.
3. Give the container an explicit height
Leaflet and deck.gl size themselves from their container. In a dashboard layout the container frequently has no intrinsic height, so the map is zero pixels tall and renders as nothing:
st_folium(m, height=560, width=None) # explicit height
st.pydeck_chart(deck, use_container_width=True)
.folium-map { height: 560px !important; }
The tell is an empty area rather than grey tiles: the map element exists in the DOM and has no size.
4. Check the data layer separately from the basemap
If the basemap draws and your data does not, the layer is present and not visible. Three usual causes:
- the CRS is wrong. Web maps need EPSG:4326 for folium and deck.gl; a layer in a national grid has coordinates in the hundreds of thousands and lands nowhere near the view.
- the geometry is empty or null.
gdf.geometry.is_empty.sum()and.isna().sum()before rendering. - the style draws nothing โ
radiusin metres at a low zoom,fillOpacity=0, or a colour expression that evaluates to fully transparent.
assert gdf.crs.to_epsg() == 4326, f"reproject: layer is {gdf.crs}"
assert not gdf.geometry.is_empty.all(), "every geometry is empty"
print(gdf.total_bounds) # is this near the map's view?
5. Fit the view to the data rather than guessing
A hard-coded centre and zoom is the reason a correct layer is off screen:
minx, miny, maxx, maxy = gdf.total_bounds
m = folium.Map()
m.fit_bounds([[miny, minx], [maxy, maxx]])
fit_bounds takes [[south, west], [north, east]] โ latitude first, unlike the bounds tuple, which is a transposition waiting to happen.
6. Check the tile provider's terms before blaming the code
Public tile servers have usage policies, and the OpenStreetMap Foundation's tiles in particular are not for embedding in applications at volume. A 429, an error tile, or a sudden block is enforcement, not a bug.
For anything beyond development, use a provider with a plan, or serve your own tiles.
Code examples
Example 1 โ a preflight for an embedded map
import geopandas as gpd
def map_preflight(gdf: gpd.GeoDataFrame, tile_url: str | None = None,
container_height: int | None = None):
problems = []
if gdf.crs is None:
problems.append("the layer has no CRS")
elif gdf.crs.to_epsg() != 4326:
problems.append(f"the layer is {gdf.crs.to_string()}; web maps need "
f"EPSG:4326 โ call .to_crs(4326)")
empty = int(gdf.geometry.is_empty.sum() + gdf.geometry.isna().sum())
if empty:
problems.append(f"{empty} empty or null geometries")
if len(gdf) == 0:
problems.append("the layer has no features โ is the filter too narrow?")
if len(gdf):
minx, miny, maxx, maxy = gdf.total_bounds
if not (-180 <= minx <= 180 and -90 <= miny <= 90):
problems.append(f"bounds {gdf.total_bounds} are outside lon/lat "
f"range โ wrong CRS, or coordinates transposed")
if tile_url and tile_url.startswith("http://"):
problems.append("the tile URL is http; an https page will block it "
"as mixed content")
if container_height is not None and container_height <= 0:
problems.append("the map container has no height โ it will render as "
"nothing")
for problem in problems:
print(f" ! {problem}")
return problems
Example 2 โ a map that fits its data and declares its height
import folium
import streamlit as st
from streamlit_folium import st_folium
def safe_map(gdf, tiles="cartodbpositron", height=560):
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(4326)
gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty]
if gdf.empty:
st.warning("Nothing to draw โ the filter matched no features.")
return None
m = folium.Map(tiles=tiles)
folium.GeoJson(gdf.to_json(),
style_function=lambda _: {"color": "#0ea5e9", "weight": 1,
"fillOpacity": 0.4}).add_to(m)
minx, miny, maxx, maxy = gdf.total_bounds
m.fit_bounds([[miny, minx], [maxy, maxx]]) # south,west then north,east
return st_folium(m, height=height, width=None,
returned_objects=["last_active_drawing"])
Example 3 โ checking a tile URL from Python
import math
import httpx
def check_tiles(url_template, lon, lat, zooms=(4, 8, 12), headers=None):
"""Request real tiles and report what the server says."""
print(f"{'z/x/y':14} {'status':>6} {'bytes':>9} note")
for z in zooms:
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
url = url_template.format(z=z, x=x, y=y, s="a")
try:
response = httpx.get(url, headers=headers or {}, timeout=15,
follow_redirects=True)
except httpx.HTTPError as exc:
print(f"{f'{z}/{x}/{y}':14} {'---':>6} {'---':>9} {exc}")
continue
note = ""
if response.status_code == 403:
note = "key missing, invalid, or domain-restricted"
elif response.status_code == 404:
note = "wrong template, zoom range, or TMS/XYZ scheme"
elif response.status_code == 429:
note = "rate limited โ check the provider's usage policy"
elif len(response.content) < 500:
note = "suspiciously small โ probably an error tile"
print(f"{f'{z}/{x}/{y}':14} {response.status_code:6} "
f"{len(response.content):9,} {note}")
if url_template.startswith("http://"):
print("\n! http tiles will be blocked as mixed content on an https page")
Running this from Python separates a tile-server problem from a browser problem in a few seconds, and it can go in the test suite.
Explanation
Why mixed content is invisible in the network panel
The browser blocks the request before it is made, so there is no network entry to inspect โ only a console message. An engineer looking at the network panel sees no tile requests and concludes the map never asked for any.
The rule is absolute in modern browsers: an HTTPS page may not load HTTP subresources. Every serious tile provider offers HTTPS, and the fix is one character in the URL.
Why a container with no height is the commonest dashboard cause
Leaflet and deck.gl compute their size from the element they mount into. In a page with an explicit layout that is fine; in a dashboard with flexible columns the container frequently has no intrinsic height, so the map is zero pixels tall.
Nothing errors. The map initialises, requests no tiles because none are visible, and renders as an empty region โ which reads as a broken map rather than as a layout problem.
Why the basemap can draw while your data does not
They are independent layers with independent failure modes. The basemap comes from a URL and appears whenever that URL works; your data comes from the app and appears whenever it is in the view and styled visibly.
So "basemap yes, data no" narrows the problem immediately to the data layer: its CRS, its geometry, or its style. Printing gdf.total_bounds and comparing with the map's view resolves most of those in one line.
Why a keyed provider fails only in production
API keys for tile services are usually restricted to a set of referring domains. localhost is in the allowed list during development and the production hostname is not, so the same code returns 200 locally and 403 deployed.
The console message names the provider and the status; the fix is in the provider's dashboard rather than in the code.
Edge cases or notes
- An https page cannot load http tiles. No network entry, console only.
- A custom
tilesURL in folium requiresattr, or it raises. fit_boundstakes[[south, west], [north, east]]โ latitude first.get_radiusin pydeck is metres; setradius_min_pixelsor low zooms show nothing.- Check
gdf.total_boundsagainst the map's view โ a national grid is nowhere near lon/lat. - Public tile servers have usage policies; a 429 is enforcement.
- A canvas renderer reading tiles needs CORS on the tile response and
crossOriginon the client. - Give the map an explicit height in any dashboard layout.
Internal links
- Fixing a web map layer that does not appear โ the wider diagnosis
- Fixing a folium map that is not showing โ the notebook version
- Fixing a tile endpoint that returns 404 or blank tiles โ when you serve the tiles
- Fixing CORS errors on a spatial API โ the canvas case
- Fixing tiles that are offset or misplaced โ when tiles load in the wrong place
- How to build a Streamlit app with an interactive map โ the container and the height
- Web map tiles explained โ the scheme
- How to make interactive maps with folium โ the component
FAQ
Why is my embedded map grey?
Tiles are not arriving. Check the console for mixed content or CORS, then the network panel for the tile requests and their status codes.
Why does the map render as an empty box?
The container has no height. Leaflet and deck.gl size themselves from their container, and a dashboard column frequently has none โ pass an explicit height.
The basemap shows but my data does not. Why?
The data layer is present and not visible: usually the wrong CRS, empty geometry, or a style that draws nothing. Compare gdf.total_bounds with the map's view.
Why do tiles work locally and 403 in production?
API keys are usually restricted by referring domain. localhost is allowed and the production hostname is not; the fix is in the provider's dashboard.
Why are my http tiles blocked?
An HTTPS page may not load HTTP subresources. The request is blocked before it is made, so it appears only in the console โ use the provider's HTTPS URL.
Can I use OpenStreetMap's tiles in my app?
Not at volume โ the Foundation's tile servers have a usage policy that excludes application embedding. Use a provider with a plan, or serve your own.