Fixing a Map App That Takes Ten Seconds to Load

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.23โ€“0.25 s per interaction on a 15 MB shapefile, and seconds on a large one
  • 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.

Triage table of five slow-app causes indexed by which stage dominates.
In most slow map apps the dominant stage is load, and the fix is one decorator.

Step-by-step solution

1. Cache the loader

This is the fix in the large majority of cases. Measured on a 15 MB shapefile:

uncached   first run 0.77 s   reruns 0.23โ€“0.25 s
cached     first run 0.43 s   reruns 0.04โ€“0.06 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:

folium, 10,000 CircleMarkers      4.78 MB    1.67 s
folium, 50,000 CircleMarkers     23.88 MB    8.68 s
folium, 50,000 as one GeoJson     7.34 MB    0.92 s
pydeck, 100,000 points            7.39 MB    0.06 s
pydeck, 1,000,000 points         73.78 MB    0.64 s

Three reductions before changing anything structural: round coordinates to six decimal places (measured elsewhere at โˆ’45% payload), 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.

Bar chart of first-run and rerun times with and without caching.
On a 500 MB layer the uncached version is unusable and the cached one is fine.

Code examples

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

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.

Explanation

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 small file that is a quarter of a second; on a large one it is seconds, repeated.

The measured improvement from one decorator โ€” 0.23โ€“0.25 s to 0.04โ€“0.06 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.

Two panels contrasting a blank loading page with one showing a message.
A reload during a slow load makes the slow load worse.

Edge cases or notes

  • 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.23โ€“0.25 s to 0.04โ€“0.06 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.