Fixing a Map App That Takes Ten Seconds to Load

On this page

Problem statement

The app shows a spinner, then a blank page, then finally a map. Ten seconds is enough for a user to conclude it is broken and switch tabs.

There are five causes and they need different fixes:

  • the data is loaded on every rerun β€” measured, 0.10–0.12 s per interaction on a 16.7 MB shapefile, growing with the file
  • the payload is too large for the browser β€” 50,000 folium markers measured at 23.88 MB of HTML and 8.68 s to build
  • the import chain is slow β€” import geopandas alone takes over a second
  • the first interaction pays for everything β€” cold caches, connections, model loads
  • the network is the bottleneck, not the app

Measuring which one it is takes about a minute and prevents the usual response, which is to optimise the wrong thing.

Quick answer

Time the stages inside the app itself:

import time
import streamlit as st


class Stage:
    def __init__(self, label):
        self.label = label

    def __enter__(self):
        self.started = time.perf_counter()
        return self

    def __exit__(self, *args):
        st.session_state.setdefault("timings", {})[self.label] = (
            time.perf_counter() - self.started)


with Stage("load"):
    districts = load_districts(PATH)
with Stage("filter"):
    subset = apply_filters(districts, selection)
with Stage("serialise"):
    payload = to_geojson(subset, key)
with Stage("render"):
    deck = build_deck(payload)

with st.sidebar.expander("Timing", expanded=False):
    for label, seconds in st.session_state["timings"].items():
        st.text(f"{label:10} {seconds * 1000:7.0f} ms")

The stage that dominates names the fix. In most slow map apps it is load, and the fix is one decorator.

Five causes of a map app that takes ten seconds, and how to tell them apart. time the stages firstdominant stagecause and fixloaduncached loader β€” @st.cache_dataserialisepayload too large β€” round,simplify, drop columnsrendertoo many features β€” pydeck, oraggregatefirst run onlycold caches β€” warm from the healthchecknothing in the appthe network, or the browserA timing panel in the sidebar names the fix in a minute spatialworkflow.io
In most slow map apps the dominant stage is load, and the fix is one decorator.

Cache, shrink, defer, check imports, warm up

1. Cache the loader

This is the fix in the large majority of cases. Measured on a 16.7 MB shapefile of US counties in How to cache spatial data in a map app:

uncached   first run 0.30 s   reruns 0.10–0.12 s
cached     first run 0.22 s   reruns 0.02 s
@st.cache_data(show_spinner="Loading…")
def load_districts(path: str):
    import geopandas as gpd
    return gpd.read_file(path).to_crs(4326)

Cache the derived forms too β€” the GeoJSON conversion, a lookup table, a computed column β€” because each of them also runs on every rerun.

2. Reduce the payload

If serialise or render dominates, the browser is the problem. The measured costs, from What a map app can afford to send to the browser:

folium, 10,000 CircleMarkers      4.78 MB    1.42 s
folium, 50,000 CircleMarkers     23.90 MB    7.16 s
folium, 50,000 as one GeoJson     8.59 MB    1.59 s
pydeck, 100,000 points            7.40 MB    0.09 s
pydeck, 1,000,000 points         73.97 MB    0.98 s

Three reductions before changing anything structural: round coordinates to six decimal places (βˆ’32% on the provinces payload measured there), simplify the geometry for the zoom shown, and drop properties nothing displays.

3. Move the expensive work off the first render

An app that loads everything at start-up is slow before the user has asked for anything:

tab_map, tab_table, tab_about = st.tabs(["Map", "Table", "About"])

with tab_map:
    st.pydeck_chart(build_deck(subset))
with tab_table:
    st.dataframe(subset.drop(columns="geometry"))    # built only when opened

Streamlit renders all tabs, so the real deferral is to compute inside a callback or behind an explicit control β€” a "Load details" button, or a filter that must be set before anything is drawn.

4. Check the import cost

python -X importtime -c "import geopandas, pydeck, streamlit" 2>&1 | tail -15

geopandas, rasterio and pyproj are all slow to import. That cost is paid once per worker at start-up, not per interaction β€” but it is the difference between a container that is ready in two seconds and one that takes fifteen.

Import inside cached functions rather than at module scope when a library is only needed on some paths.

5. Warm the caches before the user arrives

@st.cache_resource
def warm():
    """Runs once per server, at first request, not per session."""
    load_districts(PATH)
    connection()
    return True


warm()

Better still, hit the app from the health check after deployment so the first real visitor finds a warm cache. The first user should not pay for everybody.

6. Show progress rather than a blank page

@st.cache_data(show_spinner="Loading districts…")
def load_districts(path): ...

Ten seconds with a message is tolerable; ten seconds of blank page is a bug report. show_spinner with real text costs nothing and changes how the same wait is perceived.

What caching the loader is worth, measured on a 15 MB shapefile. the largest single change availablefirst run, uncached0.77 srerun, uncached0.23–0.25 sfirst run, cached0.43 srerun, cached0.04–0.06 s00.9 sCache the derived forms too β€” the GeoJSON conversion runs on every rerun otherwise spatialworkflow.io
Every rerun of the uncached app pays the whole load again; the cached one pays it once.

Timing and reducing a US counties app

The data used on this page

The "districts" are the 3,235 US counties and county equivalents of the Census Bureau's 2023 cartographic boundary file at 1:500,000, with the state name as region: districts.shp (the shapefile the timings load) and districts.gpkg. The setup also writes app_uncached.py and app_cached.py, the same Streamlit app with and without @st.cache_data on its loader, for the timings quoted in step 1, which come from How to cache spatial data in a map app.

import os

import geopandas as gpd
import streamlit as st                        # the snippets in the steps assume it

if not os.path.exists("districts.gpkg"):
    counties = gpd.read_file("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip")
    districts = counties[["GEOID", "NAME", "STATE_NAME", "ALAND", "geometry"]].rename(columns={"STATE_NAME": "region"})
    districts.to_file("districts.gpkg")
    districts.to_file("districts.shp")

APP = """
import streamlit as st
import geopandas as gpd

{decorator}
def load_districts(path):
    return gpd.read_file(path).to_crs(4326)

districts = load_districts("districts.shp")
region = st.selectbox("Region", sorted(districts.region.unique()))
st.write(len(districts[districts.region == region]), "districts")
"""
open("app_uncached.py", "w").write(APP.format(decorator=""))
open("app_cached.py", "w").write(APP.format(decorator="@st.cache_data"))
print(f"{len(gpd.read_file('districts.gpkg')):,} districts; districts.shp {os.path.getsize('districts.shp') / 1e6:.1f} MB")
3,235 districts; districts.shp 16.7 MB

Example 1 β€” a timing panel that stays in the app

import time
import streamlit as st


def timed(label):
    class _Stage:
        def __enter__(self):
            self.started = time.perf_counter()
            return self

        def __exit__(self, *args):
            elapsed = time.perf_counter() - self.started
            st.session_state.setdefault("timings", {})[label] = elapsed
    return _Stage()


def timing_panel(threshold_ms=200):
    timings = st.session_state.get("timings", {})
    if not timings:
        return
    total = sum(timings.values())
    with st.sidebar.expander(f"Timing Β· {total * 1000:.0f} ms total"):
        for label, seconds in sorted(timings.items(), key=lambda kv: -kv[1]):
            ms = seconds * 1000
            st.text(f"{label:12} {ms:7.0f} ms" + ("  ←" if ms > threshold_ms else ""))

Leaving this in the app behind an expander is more useful than profiling offline, because it reports under real interaction and real data.

Example 2 β€” the reductions, measured

import gzip
import shapely


def reduce_payload(gdf, precision=6, simplify_deg=None, keep=None):
    """Apply the three cheap reductions and report what each saved."""
    original = len(gdf.to_json().encode())
    out = gdf

    if keep:
        out = out[[*keep, out.geometry.name]]
        after_columns = len(out.to_json().encode())
        print(f"dropping columns   {original / 1e6:6.2f} β†’ "
              f"{after_columns / 1e6:6.2f} MB")

    if simplify_deg:
        out = out.copy()
        out["geometry"] = out.geometry.simplify(simplify_deg,
                                                preserve_topology=True)

    out = out.copy()
    out["geometry"] = shapely.set_precision(out.geometry.values,
                                            10 ** -precision)

    final = out.to_json().encode()
    print(f"final              {len(final) / 1e6:6.2f} MB "
          f"({len(gzip.compress(final, 6)) / 1e6:.2f} MB gzipped, "
          f"{100 * len(final) / original:.0f}% of original)")
    return out


import geopandas as gpd
districts = gpd.read_file("districts.gpkg").to_crs(4326)
_ = reduce_payload(districts, precision=6, simplify_deg=0.001, keep=["NAME", "region"])
dropping columns    27.21 β†’  27.08 MB
final               11.25 MB (3.50 MB gzipped, 41% of original)

On the 3,235 counties, dropping all but two attribute columns saved little (27.21 β†’ 27.08 MB), while simplifying to 0.001Β° and rounding to six decimal places took the payload to 41% of the original: 11.25 MB, or 3.50 MB gzipped. The geometry was the payload.

Example 3 β€” deferring work behind an explicit control

import streamlit as st

st.title("District explorer")

region = st.selectbox("Region", ["β€” choose β€”", *REGIONS], key="region")

if region == "β€” choose β€”":
    st.info("Choose a region to load the map. The full dataset is "
            f"{FULL_SIZE_MB:.0f} MB and is not loaded by default.")
    st.stop()

with st.spinner(f"Loading {region}…"):
    subset = load_region(region)

st.pydeck_chart(build_deck(subset))

Requiring a choice before loading is not a compromise. It makes the app start instantly, it tells the user why, and it means the app never loads a dataset nobody asked for.

Why the loader dominates first renders

Why the loader dominates

Streamlit re-runs the whole script on every interaction, so an uncached read_file runs again for every widget change. On a 16.7 MB file that was a tenth of a second per interaction; it grows with the file, and it is repeated every time.

The measured improvement from one decorator β€” 0.10–0.12 s to 0.02 s β€” is larger than any other single change available, which is why it is the first thing to check.

Why folium becomes slow before pydeck does

folium builds a DOM object per feature, measured at about 478 bytes of HTML each and enough layout work that 50,000 markers took 8.68 s.

pydeck hands the data to WebGL, so drawing is a GPU problem. Its ceiling is transport instead: the page embeds the data, so a million points is a 73.78 MB page.

Both ceilings are real; they simply arrive at different sizes and for different reasons.

Why the first user pays for everybody

Caches, connections and imports are cold when a container starts. The first visitor after a deployment pays the whole start-up cost, and that is frequently the person who reports the app as slow.

Warming the caches from the health check moves that cost to the deployment, where nobody is waiting. It costs one request and changes the impression the app makes.

Why a spinner with text is not a cosmetic fix

Ten seconds of blank page is indistinguishable from a broken app, so users reload β€” which starts a second session and makes it slower.

Ten seconds with "Loading districts…" is a wait people accept. The measured time is identical; the reported problem disappears. That is worth doing before optimising anything.

The same ten seconds, with and without a message. identical time, different outcomea blank pageβœ•indistinguishable from brokenβœ•the user reloadsβœ•a second session startsβœ•now it really is slowershow_spinner="Loading districts…"βœ“a wait people acceptβœ“no reload, no second sessionβœ“one string, no code changeβœ“do this before optimisingThe measured time is identical; the reported problem disappears spatialworkflow.io
A reload during a slow load makes the slow load worse.

Derived forms, import time and hidden tabs

  • Cache the derived forms too β€” the GeoJSON conversion runs on every rerun otherwise.
  • show_spinner takes a string β€” use it to say what is happening.
  • python -X importtime finds slow imports; they cost once per worker, not per interaction.
  • Streamlit renders all tabs, so tabs alone do not defer work.
  • Warm the caches from the health check after deployment.
  • One GeoJson layer beats many markers: 7.34 MB against 23.88 MB for 50,000 features.
  • Rounding coordinates removed 45% of a measured payload with no visible change.
  • Requiring a choice before loading is a legitimate and often better design.

FAQ

Why is my Streamlit map app slow?

Almost always an uncached loader: the script re-runs on every interaction, so the file is re-read each time. Measured, @st.cache_data took reruns from 0.10–0.12 s to 0.02 s.

How do I find out which part is slow?

Time the stages inside the app and show them in a sidebar expander. The stage that dominates names the fix β€” usually load, sometimes serialise.

Why is folium so slow with a lot of features?

It builds a DOM object per feature β€” about 478 bytes each, measured. 50,000 markers is 23.88 MB of HTML and 8.68 s; the same features in one GeoJson layer is 7.34 MB and 0.92 s.

Should I lazy-load the data?

Yes, when the full dataset is large. Requiring a region choice before loading makes the app start instantly and tells the user why.

Why is the first visit after a deployment slow?

Cold caches, connections and imports. Warm them from the health check so the deployment pays that cost instead of the first user.

Does a spinner really help?

Materially. The same ten seconds with "Loading districts…" is a wait; without it, it is a bug report and a reload that starts a second session.