# Make a slow Streamlit app fast: measure the rerun, then cache, fragment, or batch
Use this when every click, slider move, or keystroke in a Streamlit app takes
seconds. The root cause is almost always the execution model: every widget
interaction reruns the entire script top to bottom, so any expensive line
re-executes on every interaction. Fix in order: measure, classify, then apply
the cheapest mechanism that fits.
## 1. Measure before changing anything
Time the suspect section so the fix is verifiable, not vibes:
```python
import time
t0 = time.perf_counter()
df = load_and_process() # suspect line
st.caption(f"load_and_process: {time.perf_counter() - t0:.2f}s")
```
Interact with an unrelated widget. If the caption re-renders with a fresh
multi-second number, that line re-runs on every interaction and is your target.
Fix the target, then confirm the number drops to ~0s on subsequent interactions.
## 2. Classify the bottleneck
- **A. The same expensive result is recomputed on every rerun** (data load, CSV
parse, model inference, DB query) → cache it. `@st.cache_data` for data
(fresh copy per call), `@st.cache_resource` for one shared model/connection.
Add `ttl` if the source changes over time.
- **B. A widget only affects one section, but heavy unrelated sections rerun
with it** → isolate the interactive section with `st.fragment`. A fragment
rerun re-executes only the fragment function; the rest of the app is frozen.
- **C. Users fill out many widgets and each keystroke triggers a rerun** →
batch with `st.form` + `st.form_submit_button`; the app reruns once, on
submit.
- **D. Rendering itself is slow** (huge `st.dataframe`, dozens of charts) →
shrink what you render: aggregate or paginate before display, put secondary
views behind tabs or expanders so they only render when opened, and render
from cached data rather than recomputing per rerun.
## 3. Apply the fix
### Cache the recompute (A)
```python
@st.cache_data(ttl=3600)
def load_and_process():
return expensive_pipeline()
df = load_and_process() # runs once; later reruns reuse the cached value
```
### Fragment the interactive section (B)
`st.fragment` turns a function into an independently-rerunning section. When a
widget inside the fragment changes, only the fragment function re-executes;
everything else on the page is left untouched. (Available since Streamlit 1.33
as `st.experimental_fragment`; stable `st.fragment` name since 1.37.)
```python
import streamlit as st
def chart_section():
metric = st.selectbox("Metric", ["revenue", "costs"])
st.line_chart(df[metric]) # only this function reruns on change
chart_section = st.fragment(chart_section)
chart_section() # call it like any function
```
Rules that bite:
- A fragment rerun ignores the function's return value — share data with the
rest of the app through `st.session_state`, not `return`.
- Widgets must live in the fragment's main body. Elements written to a
container created *outside* the fragment are not cleared on fragment reruns;
they accumulate duplicates until the next full-script rerun — guard with
`st.empty` or write inside the fragment.
- Don't stack caching on a fragment function: `@st.cache_data` +
`st.fragment` on the same function is unsupported.
- `st.rerun()` called inside a fragment triggers a *full* app rerun. To rerun
just one fragment from a widget outside it, give the fragment a key and
target it from a callback:
```python
def show_charts():
st.write(f"Data: {st.session_state.get('filter', 'all')}")
show_charts = st.fragment(show_charts, key="charts")
show_charts()
st.selectbox(
"Filter",
["all", "recent"],
key="filter",
on_change=lambda: st.rerun("charts"), # fragment-only rerun
)
```
- `run_every="10s"` makes a fragment refresh itself on a timer — the cheap way
to build a live dashboard section without rerunning the rest of the app:
```python
def live_status():
st.line_chart(fetch_latest())
live_status = st.fragment(live_status, run_every="10s")
live_status()
```
### Batch inputs with a form (C)
```python
with st.form("filters"):
region = st.selectbox("Region", ["us", "eu", "apac"])
start = st.date_input("Start")
submitted = st.form_submit_button("Apply")
if submitted:
show_results(region, start) # one rerun, after the user is done
```
Widgets inside a form don't trigger reruns until submit — and they can't
dynamically update each other in real time. That live interactivity is what
fragments are for; batching is what forms are for.
## 4. Verify and checklist
Interact with the widget that was slow. The timing caption from step 1 should
show ~0s for the cached or fragmented path, and unrelated sections should not
visibly recompute. If it's still slow, you classified wrong — re-measure; the
bottleneck is a different line.
1. Time the suspect section across two interactions.
2. Same result recomputed → cache it (`ttl` if the source changes).
3. One section's widgets rerun everything → `st.fragment` (return values
ignored; share via session state).
4. Many inputs, one intent → `st.form` + submit button.
5. Rendering heavy → aggregate/paginate, defer behind tabs/expanders.
6. Re-measure; don't ship on vibes.