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.

Five diagnostic steps for a blank embedded map, from console to layer style.
The tell for a sizing problem is an empty area rather than grey tiles.

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 โ€” radius in 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.

Triage table of tile response statuses and their causes.
Mixed content is blocked before the request, so it has no network entry.

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.

Two panels separating a missing basemap from a missing data layer.
fit_bounds takes [[south, west], [north, east]] โ€” latitude first.

Edge cases or notes

  • An https page cannot load http tiles. No network entry, console only.
  • A custom tiles URL in folium requires attr, or it raises.
  • fit_bounds takes [[south, west], [north, east]] โ€” latitude first.
  • get_radius in pydeck is metres; set radius_min_pixels or low zooms show nothing.
  • Check gdf.total_bounds against 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 crossOrigin on the client.
  • Give the map an explicit height in any dashboard layout.

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.