Fixing an App Whose Memory Grows With Every User
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:
- per-session state โ anything in
st.session_stateexists once per browser tab, so a 200 MB filtered frame costs that per concurrent viewer - per-worker data โ module-level layers are loaded once per process, and copy-on-write does not help
- 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
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"sessions {len(st.session_state):8,} keys 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 reduce the worker count, because module data is per process
Step-by-step solution
1. Measure the baseline per worker, and multiply
The floor for the whole service is what one worker uses after start-up, times the worker count:
after loading layers 612 MB per worker
4 workers 2,448 MB
8 workers 4,896 MB โ idle, before any session
If the container limit is 4 GB, an eight-worker configuration is dead before the first request, and 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.region == region]
# cheap: store the selection, recompute the subset
st.session_state.region = region
subset = districts[districts.region == st.session_state.region]
Store what was chosen, not what it produced. Recomputing a filter is milliseconds; keeping the result is megabytes per user.
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. A loader parameterised by year and region with ten years and twelve regions is up to 120 layers in memory โ and 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
round 1: rss 742 MB (+130 from baseline)
round 2: rss 748 MB (+136)
round 3: rss 751 MB (+139) โ flat: spikes, not a leak
round 1: rss 742 MB (+130)
round 2: rss 903 MB (+291)
round 3: rss 1,067 MB (+455) โ climbing: a leak
Flat between rounds with a high peak is a big-response problem โ cap what a session can materialise. Climbing every round is accumulation, and tracemalloc will name it.
Code examples
Example 1 โ a memory panel that stays in the app
import gc
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
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()
Example 2 โ finding what accumulates
import tracemalloc
import streamlit as st
@st.cache_resource
def start_tracing():
tracemalloc.start(10)
return {"baseline": tracemalloc.take_snapshot()}
def leak_report(top=8):
"""Compare against the first snapshot: what has grown since start-up?"""
state = start_tracing()
current = tracemalloc.take_snapshot()
stats = current.compare_to(state["baseline"], "lineno")
with st.sidebar.expander("Allocation growth"):
for stat in stats[:top]:
frame = stat.traceback[0]
filename = frame.filename.split("/")[-1]
st.text(f"{stat.size_diff / 1e6:+7.1f} MB "
f"{filename}:{frame.lineno}")
The usual suspects in a map app, in order: an lru_cache or cache_data with no bound on a function returning GeoDataFrames, a module-level list appended per request, and a connection pool without a maximum.
Example 3 โ a session budget
import streamlit as st
MAX_SESSION_FEATURES = 50_000
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
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.
Explanation
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. A 200 MB filtered frame with ten viewers is 2 GB โ which is why storing the selection rather than the result is the single most effective change.
Why module-level data multiplies with workers
Each worker process imports the module separately and gets its own copy of everything at module scope. Copy-on-write after a fork helps for a moment and stops helping quickly, because CPython's reference counting writes to object headers and dirties the page.
A 612 MB baseline is therefore 4.9 GB across eight workers before anybody connects. Reducing the worker count is a legitimate fix, particularly for I/O-bound apps where threads serve concurrency instead.
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.
Edge cases or notes
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.
- Copy-on-write does not save you โ reference counting dirties pages.
- Fewer workers with more threads suits I/O-bound apps and shares the data.
- Streamlit sessions expire after a browser disconnect, but not instantly.
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, so a large filtered frame is multiplied by the audience.
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 worker count?
Each worker is a separate process with its own copy of module-level data. A 612 MB baseline is 4.9 GB across eight workers, idle.
How do I tell a leak from a spike?
Run several rounds of load and watch the resident size between them. Flat with a high peak is a spike; climbing every round is accumulation.
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.