Fixing an App Whose Memory Grows With Every User
On this page
Problem statement
The app is fine with one user and dies on a busy afternoon. The container is killed with no traceback, the supervisor restarts it, and everybody's session is lost.
For a Streamlit map app there are four distinct causes, and a real server shows all of them (Example 5):
- per-session state — anything in
st.session_stateexists once per browser tab, so a filtered copy of the data — about 19 MB for 1,333 regions, measured — costs that per concurrent viewer - per-process data — a Streamlit server is one process serving every session, so a cached layer is held once; run several replicas behind a load balancer and each holds its own
- unbounded caches —
@st.cache_datawith nomax_entriesaccumulates one entry per argument combination, and each entry is the size of what the function returns - an actual leak — an appending list, a growing dictionary, a connection pool with no maximum
The first two are architecture and the last two are bugs, and telling them apart is a matter of watching the resident size across several rounds of load.
Quick answer
%%writefile quick_panel.py
import os
import streamlit as st
def rss_mb() -> float:
with open("/proc/self/statm") as handle:
pages = int(handle.read().split()[1])
return pages * os.sysconf("SC_PAGE_SIZE") / 1e6
if "baseline_mb" not in st.session_state:
st.session_state.baseline_mb = rss_mb()
with st.sidebar.expander("Memory"):
current = rss_mb()
st.text(f"rss {current:8,.0f} MB")
st.text(f"since 1st{current - st.session_state.baseline_mb:8,.0f} MB")
st.text(f"keys {len(st.session_state):8,} in this session")
Then the four fixes:
@st.cache_data(max_entries=8) # bound the cache
@st.cache_resource # share connections, do not copy them
del st.session_state["big_frame"] # do not keep large objects per session
# and run fewer replicas, because data is per process
Baseline, session state, bounded caches, shared data
1. Measure the baseline per server process, and multiply
The floor for the whole service is what one server process uses once its data is loaded, times the number of processes. Measured for the app in Example 5, over Europe's 1,333 NUTS 3 regions:
server started 75 MB
after the first session loads 266 MB
The first session is what loads the layer; after that, one process serves every session. Replicas multiply it: four of them are four times the floor before anybody connects, and if that is above the container limit no per-session optimisation helps.
2. Stop putting large objects in session_state
st.session_state is per browser tab. A filtered GeoDataFrame stored there is duplicated for every concurrent viewer:
# expensive: one copy per session
st.session_state.subset = districts[districts.country == country]
# cheap: store the selection, recompute the subset
st.session_state.country = country
subset = districts[districts.country == st.session_state.country]
Store what was chosen, not what it produced. Recomputing a filter is milliseconds; keeping the result is megabytes per user — measured, ten open sessions each keeping theirs added 186 MB to the server (Example 5). A result from @st.cache_data is a full copy, geometries included, so keeping it keeps all of it.
3. Bound every cache
An unbounded @st.cache_data is a memory leak with a friendly name:
@st.cache_data(max_entries=8, ttl=3600)
def load_year(year: int):
...
One entry per distinct argument combination, each the size of the return value. Measured with thirty tolerances of one simplified layer, an unbounded cache grew the process by 263 MB; with max_entries=8 it never passed 132 MB (Example 4). The cache is per server, so it accumulates as users explore.
4. Share what can be shared
@st.cache_resource
def connection():
import duckdb
return duckdb.connect("data.duckdb", read_only=True)
cache_resource returns one object to every session. For a connection or a model that is exactly right, and it is the difference between one handle and one per viewer.
For data, cache_data is per server too — it returns a copy per caller, but the cached value itself is stored once.
5. Move the data out of the process
The structural fix for a large layer is not to hold it at all:
@st.cache_data(max_entries=32)
def query(_con, region, year):
return _con.execute(SQL, [region, year]).df()
Each cache entry is then the size of a filtered result rather than of the whole dataset. That is what makes an app over a 50-million-row table have a memory footprint measured in tens of megabytes.
6. Distinguish a leak from a spike
Measured on a real server, three rounds of ten sessions each, opened and then closed (Example 5):
round 1 round 2 round 3
keep selection 286 MB 286 MB 286 MB
keep result 445 MB 618 MB 618 MB
leak 445 MB 618 MB 791 MB
Flat between rounds with a high level is sessions holding too much — cap what a session can keep, and the memory is reused once they close. Climbing every round is accumulation. Note that the resident size never falls when tabs close: freed memory stays with the process for reuse, so "it went back down" is not a test you can run.
Finding what accumulates per session
The data used on this page
The "districts" are Europe's NUTS 3 regions — 1,333 of them once joined to data — from Eurostat GISCO at 1:1 million, with GISCO's urban–rural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 … rate_2023. Eurostat refuses the default python-requests user agent, hence the header.
import math
import os
import geopandas as gpd
import pandas as pd
import requests
YEARS = [2019, 2020, 2021, 2022, 2023]
def eurostat(table, **filters):
"""A Eurostat table as a Series indexed by every dimension."""
stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
params={"format": "JSON", **filters},
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
for d in stat["id"]]
values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)
if not os.path.exists("districts.gpkg"):
nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
"NUTS_RG_01M_2021_4326_LEVL_3.geojson")
older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
.droplevel(["freq", "indic_de", "unit"]).unstack("time"))
older.columns = [f"rate_{year}" for year in older.columns]
kind = {1: "urban", 2: "intermediate", 3: "rural"}
(nuts.assign(category=nuts["URBN_TYPE"].map(kind))
.rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
[["code", "country", "name", "category", "geometry"]]
.merge(older, left_on="code", right_index=True)
.dropna(subset=[f"rate_{YEARS[-1]}"])
.to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}–"
f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
f"{districts.name.nunique():,} distinct names")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names
Example 1 — a memory panel that stays in the app
%%writefile panel_app.py
import gc
import os
import geopandas as gpd
import streamlit as st
def rss_mb() -> float:
with open("/proc/self/statm") as handle:
pages = int(handle.read().split()[1])
return pages * os.sysconf("SC_PAGE_SIZE") / 1e6
def memory_panel(warn_growth_mb=500):
if "baseline_mb" not in st.session_state:
st.session_state.baseline_mb = rss_mb()
current = rss_mb()
growth = current - st.session_state.baseline_mb
with st.sidebar.expander(f"Memory · {current:,.0f} MB"):
st.text(f"process rss {current:9,.0f} MB")
st.text(f"since first run {growth:9,.0f} MB")
st.text(f"session keys {len(st.session_state):9,}")
big = [(key, len(value)) for key, value in st.session_state.items()
if hasattr(value, "__len__") and len(value) > 1000]
for key, size in sorted(big, key=lambda kv: -kv[1])[:5]:
st.text(f" {key:14} {size:,} items")
if growth > warn_growth_mb:
st.warning(f"grown {growth:,.0f} MB since start — check the caches "
f"and session_state")
if st.button("Collect garbage"):
gc.collect()
st.rerun()
st.session_state.kept = gpd.read_file("districts.gpkg") # 1,333 rows, kept per session
memory_panel()
from streamlit.testing.v1 import AppTest
at = AppTest.from_file("panel_app.py", default_timeout=120).run()
for line in at.sidebar.text:
print(line.value)
process rss 274 MB
since first run 0 MB
session keys 2
kept 1,333 items
The panel names the large object in session_state by key — the first thing to look at when memory grows with users.
Example 2 — finding what accumulates
%%writefile leak_app.py
import os
import tracemalloc
import geopandas as gpd
import streamlit as st
@st.cache_resource
def start_tracing():
tracemalloc.start(25)
return {"baseline": tracemalloc.take_snapshot()}
def leak_report(top=5):
"""What has grown since start-up, attributed to the app's own line that caused it."""
state = start_tracing()
current = tracemalloc.take_snapshot()
stats = current.compare_to(state["baseline"], "traceback")
app_file = os.path.basename(__file__)
with st.sidebar.expander("Allocation growth"):
for stat in stats[:top]:
ours = [f for f in stat.traceback if os.path.basename(f.filename) == app_file]
where = f"line {ours[-1].lineno}" if ours else stat.traceback[0].filename.split("/")[-1]
st.text(f"{stat.size_diff / 1e6:+7.1f} MB {where}")
start_tracing()
@st.cache_data
def load(path):
return gpd.read_file(path).to_crs(3035)
@st.cache_resource
def history():
return []
country = st.selectbox("Country", ["All", "PT", "ES"], key="country")
districts = load("districts.gpkg")
history().append(districts) # the leak
leak_report()
at = AppTest.from_file("leak_app.py", default_timeout=120).run()
before = int(next(l for l in open("/proc/self/status") if l.startswith("VmRSS")).split()[1]) / 1024
for country in ["PT", "ES", "All"] * 4:
at.selectbox(key="country").set_value(country).run()
after = int(next(l for l in open("/proc/self/status") if l.startswith("VmRSS")).split()[1]) / 1024
print(f"resident size grew {after - before:.0f} MB over 12 reruns; tracemalloc reports:")
for line in at.sidebar.text:
print(" ", line.value)
print(" line 42 is:", open("leak_app.py").read().splitlines()[41].strip())
resident size grew 194 MB over 12 reruns; tracemalloc reports:
+10.3 MB line 42
+1.7 MB line 42
+1.0 MB line 42
+0.1 MB line 18
+0.1 MB line 33
line 42 is: districts = load("districts.gpkg")
Two things the report does not do. It names the line that created the leaked object — the cached load — not the append that keeps it alive. And it sees only a fraction of the growth: tracemalloc tracks Python's allocator, while a shapely geometry lives in GEOS's C++ heap, so most of a GeoDataFrame is invisible to it. Grouping by "traceback" and walking back to the app's own file is what makes it point at app code at all; grouped by "lineno", the top entry is inside Streamlit's cache module.
Example 3 — a session budget
%%writefile budget_app.py
import geopandas as gpd
import streamlit as st
MAX_SESSION_FEATURES = 500
def guard_session_size(subset, label="selection"):
"""Refuse to materialise something that will not scale across users."""
if len(subset) > MAX_SESSION_FEATURES:
st.warning(
f"This {label} has {len(subset):,} features. The app holds at most "
f"{MAX_SESSION_FEATURES:,} per session, because that memory is used "
f"once per concurrent user. Narrow the filter, or use the bulk "
f"download link.")
st.stop()
return subset
districts = gpd.read_file("districts.gpkg")
country = st.selectbox("Country", ["All", "PT", "ES"], key="country")
subset = districts if country == "All" else districts[districts["country"] == country]
st.session_state.subset = guard_session_size(subset)
st.metric("Regions kept", len(st.session_state.subset))
at = AppTest.from_file("budget_app.py", default_timeout=60).run()
print("All:", at.warning[0].value[:60] + "…" if at.warning else at.metric[0].value)
at.selectbox(key="country").set_value("PT").run()
print("PT: ", at.metric[0].value, "regions kept")
All: This selection has 1,333 features. The app holds at most 500…
PT: 25 regions kept
A per-session cap is the control that makes the app's total memory predictable: worst case is the cap times the concurrent session count, plus the shared caches.
Example 4 — what an unbounded cache costs
Streamlit's caches work outside a running app, so the growth can be measured directly: thirty calls with distinct arguments, unbounded and then with max_entries=8, each in a fresh process.
%%writefile cache_growth.py
import sys
import geopandas as gpd
import streamlit as st
def rss_mb():
with open("/proc/self/status") as status:
return int(next(l for l in status if l.startswith("VmRSS")).split()[1]) / 1024
base = gpd.read_file("districts.gpkg")
bound = sys.argv[1]
@st.cache_data(**({} if bound == "none" else {"max_entries": int(bound)}))
def simplified(tolerance_m: int):
return base.to_crs(3035).simplify(tolerance_m)
start = rss_mb()
growth = []
for count, tolerance in enumerate(range(10, 310, 10), 1):
simplified(tolerance)
if count in (1, 8, 16, 30):
growth.append(f"{count} entries +{rss_mb() - start:.0f} MB")
print(f"max_entries={bound:4}", " · ".join(growth))
import subprocess
import sys
for bound in ("none", "8"):
result = subprocess.run([sys.executable, "cache_growth.py", bound],
capture_output=True, text=True, check=True)
print(result.stdout.strip())
max_entries=none 1 entries +61 MB · 8 entries +123 MB · 16 entries +184 MB · 30 entries +263 MB
max_entries=8 1 entries +61 MB · 8 entries +123 MB · 16 entries +132 MB · 30 entries +90 MB
Example 5 — real sessions against a real server
AppTest runs in one process with one session at a time, so it cannot show memory growing with users. This runs the app under streamlit run and opens real sessions in headless Chromium — each tab is one session — reading the server's resident size from /proc. Three versions of the same app: one that keeps only the selection, one that keeps its filtered result in session_state, and one with a genuine leak, a list in cache_resource that every run appends to. --server.disconnectedSessionTTL 5 shortens the default two minutes Streamlit keeps a closed tab's session, so a round can end.
%%writefile memory_app.py
import os
import geopandas as gpd
import streamlit as st
MODE = os.environ.get("MODE", "selection")
@st.cache_data
def load(path):
return gpd.read_file(path).to_crs(3035)
country = st.selectbox("Country", ["All", "PT", "ES"], key="country")
districts = load("districts.gpkg") # cache_data: a fresh copy per call
subset = districts if country == "All" else districts[districts.country == country]
if MODE == "result":
st.session_state.subset = subset # the copy now lives as long as the tab
@st.cache_resource
def history():
return [] # one list for the whole server
if MODE == "leak":
history().append(subset) # and it only ever grows
st.metric("Regions", len(subset))
import os, socket, subprocess, sys, time, urllib.request
from playwright.sync_api import sync_playwright
def rss_mb(pid):
with open(f"/proc/{pid}/status") as status:
return int(next(l for l in status if l.startswith("VmRSS")).split()[1]) / 1024
def serve(mode):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0)); port = probe.getsockname()[1]
server = subprocess.Popen([sys.executable, "-m", "streamlit", "run", "memory_app.py", "--server.headless", "true",
"--server.port", str(port), "--browser.gatherUsageStats", "false",
"--server.disconnectedSessionTTL", "5"],
env=dict(os.environ, MODE=mode), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
for _ in range(120):
try: urllib.request.urlopen(url + "/_stcore/health", timeout=1); break
except OSError: time.sleep(0.5)
return server, url
for mode in ("selection", "result", "leak"):
server, url = serve(mode)
readings = [f"started {rss_mb(server.pid):,.0f}"]
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
first = browser.new_page(); first.goto(url)
first.wait_for_selector('[data-testid="stMetricValue"]', timeout=60_000)
time.sleep(2)
readings.append(f"one session {rss_mb(server.pid):,.0f}")
first.close()
time.sleep(15)
for round_ in (1, 2, 3):
pages = []
for _ in range(10):
page = browser.new_page(); page.goto(url)
page.wait_for_selector('[data-testid="stMetricValue"]', timeout=60_000)
pages.append(page)
time.sleep(2)
open_mb = rss_mb(server.pid)
for page in pages: page.close()
time.sleep(15)
readings.append(f"round {round_}: {open_mb:,.0f} open, {rss_mb(server.pid):,.0f} closed")
browser.close()
finally:
server.terminate(); server.wait(timeout=30)
print(f"{mode:9}", " · ".join(readings))
selection started 75 · one session 266 · round 1: 286 open, 286 closed · round 2: 286 open, 286 closed · round 3: 286 open, 286 closed
result started 75 · one session 259 · round 1: 445 open, 445 closed · round 2: 618 open, 618 closed · round 3: 618 open, 618 closed
leak started 75 · one session 259 · round 1: 445 open, 445 closed · round 2: 618 open, 618 closed · round 3: 791 open, 791 closed
Memory scales with viewers, not requests
Why memory scales with viewers rather than requests
An HTTP API holds a request's data only while the request runs. A Streamlit app holds a session: a websocket, a session_state dictionary, and whatever that session has put in it, for as long as the tab is open.
So the unit of memory is the concurrent viewer, and anything in session_state is multiplied by it. Measured, each session that kept its copy of 1,333 regions cost the server about 19 MB — which is why storing the selection rather than the result is the single most effective change.
Why data multiplies with processes, not with sessions
A Streamlit server is a single process: every session is served from it, and a layer behind @st.cache_data is stored once however many people are connected. What multiplies is the number of processes. Scaling out means several replicas behind a load balancer, and each loads its own copy of everything — measured here, a server that started at 75 MB held 266 MB once the first session had loaded the regions, so four replicas are four times that before anybody connects.
If you run the app under a forking process manager instead, copy-on-write after the fork helps for a moment and then stops helping, because CPython's reference counting writes to object headers and dirties the pages.
Why unbounded caches are the commonest actual leak
A cache with no max_entries grows one entry per distinct argument combination. Users exploring an app generate combinations, so the cache grows through the day and empties only on restart.
It is a leak in every practical sense, and it is invisible because it looks like a performance feature. max_entries turns it into a bounded memory cost that can be reasoned about.
Why a spike and a leak need different fixes
A spike is one session materialising something large: cap what a session may build, and add a semaphore if several can do it at once.
A leak is accumulation across sessions: find it with tracemalloc, bound the cache, and stop appending to module-level structures. Applying the spike fix to a leak, or the reverse, wastes the afternoon — which is why the several-rounds measurement comes first.
Open tabs, shared resources and container limits
st.session_stateis per browser tab, and a tab left open holds its memory.cache_resourceis shared across sessions; anything mutable there is shared too.max_entrieson every cache. Each entry is the size of the return value.- Container limits are invisible to Python — the process sees the host's RAM.
- A SIGKILL with no traceback is the OS out-of-memory killer.
- A Streamlit server is one process; replicas multiply the data, sessions multiply
session_state. - Resident size does not fall when tabs close — freed memory is kept for reuse, so watch it across rounds, not after one.
- Streamlit keeps a disconnected session for 120 s by default (
server.disconnectedSessionTTL), so memory from closed tabs is not free at once.
Internal links
- How to cache spatial data in a map app — bounding the caches
- Reruns and state explained: why your map app redraws everything — what lives where
- How to deploy a Python map app with Docker — sizing the container
- Fixing a map app that takes ten seconds to load — the related performance failure
- Fixing an API worker that runs out of memory — the same problem in a service
- Fixing a batch job whose memory grows — leak diagnosis
- What a map app can afford to send to the browser — bounding what a session builds
- Fixing memory errors in GeoPandas with large files — the library-level version
FAQ
Why does my Streamlit app use more memory with more users?
Because st.session_state is per browser tab. Anything stored there is duplicated per concurrent viewer — measured, ten sessions each keeping a filtered copy of 1,333 regions added 186 MB to the server.
What should I store in session state?
The selection, not the result. Storing which region was chosen costs bytes; storing the filtered GeoDataFrame costs megabytes per user.
Is @st.cache_data per user?
No, it is per server process — which is why it is the right place for shared data. But it is unbounded unless you set max_entries, and each entry is the size of the return value.
Why does memory scale with the number of servers?
A Streamlit server is one process, and its cached data is held once. Run several replicas and each holds its own — measured, 266 MB per server once the first session had loaded 1,333 regions.
How do I tell a leak from a spike?
Run several rounds of load and watch the resident size between them. Measured, sessions keeping their results levelled off after the first rounds; a list in cache_resource that grew every run climbed about 173 MB a round.
What is the structural fix for a large dataset?
Keep it out of the process: query a database or DuckDB and cache the filtered result. Each cache entry is then the size of a result rather than of the dataset.