Reruns and State Explained: Why Your Map App Redraws Everything

Problem statement

The behaviour that confuses everyone new to Streamlit: change one filter, and the whole app rebuilds. The file is re-read, the projection is redone, the map is reconstructed, and any state the user had โ€” a zoom, a drawing, a selection โ€” is gone.

That is not a bug. Streamlit's model is that the script is the app: every interaction re-executes it from the first line. There is no callback graph and no component lifecycle, which is why an app takes an afternoon to write.

The consequences are measurable. On a 15 MB shapefile, an uncached app spent 0.23โ€“0.25 s on every widget change re-reading the file it had already read; with @st.cache_data on the loader the same interaction cost 0.04โ€“0.06 s.

Understanding the model turns three separate mysteries โ€” the slow app, the resetting map, the vanishing drawing โ€” into one explanation with three fixes.

Quick answer

Three tools, and each one solves a different part of the model:

import streamlit as st


@st.cache_data                 # values: skipped on rerun, returns a copy
def load(path):
    import geopandas as gpd
    return gpd.read_file(path)


@st.cache_resource             # connections and models: one shared instance
def connect(dsn):
    import duckdb
    return duckdb.connect(dsn, read_only=True)


if "view" not in st.session_state:      # state: survives reruns within a session
    st.session_state.view = default_view()
what happens on a rerun

script body                re-executed, top to bottom
@st.cache_data results     skipped, if the arguments match
@st.cache_resource objects skipped, the same object returned
st.session_state           preserved
widget values              preserved, keyed by their key=
anything else in memory    gone
Checklist of four things that survive a Streamlit rerun and one that does not.
That is the whole explanation for the slow app, the resetting map and the vanishing drawing.

Step-by-step solution

1. Know exactly what triggers a rerun

  • Any widget's value changing.
  • st.rerun().
  • A file being edited in development, if auto-rerun is on.
  • Some components returning a value โ€” st_folium returns map state on every pan by default, which is why a folium map can re-run the script continuously.

Nothing else. In particular, another user's interaction does not re-run your session, because sessions are independent.

2. Put expensive, reusable work behind @st.cache_data

@st.cache_data(show_spinner="Loadingโ€ฆ", ttl=3600, max_entries=8)
def load_districts(path: str, simplify_deg: float) -> "gpd.GeoDataFrame":
    ...

The cache key is the function plus its arguments. Same arguments, no execution โ€” which is why the measured rerun fell from 0.23โ€“0.25 s to 0.04โ€“0.06 s.

cache_data serialises the return value and hands back a copy, so a mutation in the app cannot corrupt the cached value. That is the property that makes it safe by default.

3. Use @st.cache_resource for things that must not be copied

A database connection cannot be serialised, and a loaded model should not be duplicated per session:

@st.cache_resource
def connection():
    con = duckdb.connect("data.duckdb", read_only=True)
    con.execute("load spatial")
    return con

cache_resource returns the same object to every session. That is what you want for a connection pool and dangerous for anything mutable โ€” a dictionary stored there is shared across all users.

4. Keep client-side state in session_state

The map's view, a drawing, a multi-step wizard's position: all of these live on the client and are lost when the component is rebuilt.

if "view" not in st.session_state:
    st.session_state.view = pdk.ViewState(latitude=54.0, longitude=-2.0, zoom=5)

deck = pdk.Deck(layers=layers, initial_view_state=st.session_state.view)

session_state is a per-session dictionary that survives reruns. It is the only thing that does, besides caches and widget values.

5. Give widgets keys, and understand what that does

region = st.selectbox("Region", options, key="region")

A key binds the widget to st.session_state["region"]. Two effects follow: the value survives layout changes, and you can set it programmatically โ€” which is how a "reset filters" button works.

Without a key, Streamlit derives an internal one from the widget's position and parameters, so moving a widget between branches loses its value.

6. Batch interactions with a form when the work is expensive

Within a form, widgets do not trigger a rerun until the submit button is pressed:

with st.form("filters"):
    year = st.select_slider("Year", options=YEARS)
    low, high = st.slider("Rate", 0.0, 20.0, (0.0, 20.0))
    submitted = st.form_submit_button("Apply")

A slider dragged across twenty positions is twenty reruns without a form and one with it. At 0.25 s each, that is the difference between five seconds and a quarter of one.

Triage table of four Streamlit rerun triggers and what to do about each.
Another userโ€™s interaction never re-runs your session โ€” sessions are independent.

Code examples

Example 1 โ€” a diagnostic that shows the model at work

import time
import streamlit as st

st.session_state.setdefault("runs", 0)
st.session_state["runs"] += 1
started = time.perf_counter()


@st.cache_data
def load(path):
    time.sleep(1.0)                     # stand-in for a real read
    return f"data from {path}"


data = load("districts.gpkg")           # slow once, free thereafter
uncached = time.sleep(0.05) or "recomputed every time"

st.write(f"rerun #{st.session_state['runs']}")
st.write(f"this rerun took {(time.perf_counter() - started) * 1000:.0f} ms")
st.selectbox("Change me to trigger a rerun", ["a", "b", "c"], key="trigger")

The first run takes over a second; every subsequent one takes about 50 ms. The counter demonstrates that the script really is re-executing, and the timing demonstrates what the cache removed.

Example 2 โ€” the three storage kinds side by side

import streamlit as st

# 1. cached value: recomputed only when the arguments change
@st.cache_data(ttl=3600)
def districts(path: str, simplify_deg: float = 0.001):
    import geopandas as gpd
    gdf = gpd.read_file(path).to_crs(4326)
    gdf["geometry"] = gdf.geometry.simplify(simplify_deg, preserve_topology=True)
    return gdf


# 2. cached resource: one instance for the whole server
@st.cache_resource
def db():
    import duckdb
    con = duckdb.connect("data.duckdb", read_only=True)
    con.execute("load spatial")
    return con


# 3. session state: per user, survives reruns
def remember(key, default):
    if key not in st.session_state:
        st.session_state[key] = default
    return st.session_state[key]


view = remember("view", {"latitude": 54.0, "longitude": -2.0, "zoom": 5})
history = remember("history", [])

choice = st.selectbox("Region", ["North", "South"], key="region")
if not history or history[-1] != choice:
    history.append(choice)                   # mutating session_state persists
st.caption(f"you have looked at: {' โ†’ '.join(history)}")

Example 3 โ€” resetting state deliberately

import streamlit as st

DEFAULTS = {"region": "All", "year": 2025, "min_rate": 0.0}


def reset_filters():
    """A callback runs before the rerun, so widget state can be set here."""
    for key, value in DEFAULTS.items():
        st.session_state[key] = value


st.sidebar.button("Reset filters", on_click=reset_filters)


def clear_caches():
    st.cache_data.clear()
    st.cache_resource.clear()


st.sidebar.button("Reload data", on_click=clear_caches)

The on_click callback matters: it runs before the next rerun, so setting a widget's session_state value there is applied. Setting it in the script body after the widget has been created raises an error.

Explanation

Why rerunning everything is a reasonable design

The alternative โ€” a callback graph โ€” requires declaring which outputs depend on which inputs, which is most of what makes Dash and Panel apps longer. Streamlit removes that entirely: there is one code path, executed in order, and reading it tells you what the app does.

The bet is that with caching, re-running is cheap enough. The measurements support it at ordinary sizes: 0.04โ€“0.06 s per interaction on a 15 MB layer. It stops holding when the expensive work depends on the widget that changed, because then the rerun is doing genuine work rather than repeating itself.

Why cache_data returns a copy and cache_resource does not

cache_data is for values. Returning the same object to every caller would let one session's mutation change another's data, so it serialises and returns a copy. That costs a little and removes a whole class of cross-session bugs.

cache_resource is for things that cannot be copied โ€” a database connection, a machine-learning model, a file handle. It returns the same object to everybody, which is correct for a connection and a hazard for a mutable dictionary.

Choosing the wrong one produces either an unpicklable-object error or a shared-state bug, and both are confusing until the distinction is clear.

Why the map's view resets

The deck or the folium map is rebuilt on every rerun from its initial parameters, so the front end receives a fresh component and applies its starting view.

Nothing has "reset"; the component is new. Storing the view in session_state and passing it back means the new component starts where the old one was, which is what the user expects.

Why a component that returns state can loop

st_folium returns the map's state โ€” bounds, last click, drawings โ€” and any component returning a value triggers a rerun. Panning the map therefore re-runs the script, which rebuilds the map, which returns state, which re-runs the script.

returned_objects=[...] restricts what it reports, and restricting it to what you actually use breaks the loop. This is the single most common cause of a Streamlit map app that feels like it is constantly reloading.

Four storage locations in a Streamlit app with their scope and lifetime.
Store the selection in session_state, and the data in a cache.

Edge cases or notes

  • Sessions are independent. One user's rerun does not affect another's.
  • st.session_state is per session; cache_resource is per server. Do not confuse them.
  • Setting a widget's session_state after it is created raises โ€” do it in an on_click callback.
  • cache_data hashes arguments; prefix unhashable ones with _ to skip them.
  • ttl and max_entries bound a cache that would otherwise grow.
  • Mutating a cache_data result is safe โ€” it is a copy. Mutating a cache_resource object is shared.
  • st.rerun() restarts the script immediately; use it sparingly, and never unconditionally.
  • Components that return values trigger reruns โ€” that is the folium loop.

FAQ

Why does my whole Streamlit app re-run when I change one widget?

Because that is the model: the script is the app and it re-executes top to bottom on every interaction. Caching is what makes that cheap.

What survives a rerun?

st.session_state, widget values keyed by key=, and anything behind @st.cache_data or @st.cache_resource. Everything else is rebuilt.

What is the difference between cache_data and cache_resource?

cache_data is for values and returns a copy per caller. cache_resource is for connections and models and returns the same object to every session.

Why does my map lose its zoom?

Because the map component is rebuilt from its initial view on every rerun. Store the view in st.session_state and pass it back.

How do I stop a slider re-running everything?

Put it in an st.form. Widgets inside a form do not trigger a rerun until the submit button is pressed โ€” one rerun instead of twenty.

Why does my folium map re-run the app when I pan it?

st_folium returns map state by default, and a component returning a value triggers a rerun. Restrict it with returned_objects=[...].