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_state exists 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_data with no max_entries accumulates 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
Measured memory of a Streamlit map app over 1,333 NUTS 3 regions: one server with its data loaded, four replicas, ten sessions keeping their results, and an unbounded cache of thirty entries. three multipliers, measuredone server, data loaded266 MB4 replicas, idle4 × 266 MBkept by 10 sessions+186 MBcache, 30 entries+263 MB0550 MB1100 MBsession_state is per browser tab; data is per process; caches are per server spatialworkflow.io
Measured in Examples 4 and 5. Replicas multiply the data before anybody connects.

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.

Storing what was chosen instead of what it produced. the single most effective changethe result in session_state✕a filtered GeoDataFrame per tab✕≈19 MB × concurrent viewers here✕held while the tab is open✕invisible until a busy afternoonthe selection in session_state✓a region name and a threshold✓bytes per session✓recomputed in milliseconds✓memory independent of viewersRecomputing a filter is cheap; keeping the result is megabytes per user spatialworkflow.io
The unit of memory in a Streamlit app is the concurrent viewer.

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
Process memory growth as a cached function is called with thirty distinct arguments, unbounded and with max_entries set to 8. one entry per distinct argumententries requestedunboundedmax_entries=81+61 MB+61 MB8+123 MB+123 MB16+184 MB+132 MB30+263 MB+90 MBeach entry: one simplified copy of 1,333 regionsUnbounded, the cache grows as long as users find new arguments spatialworkflow.io
Measured in Example 4. max_entries turns a slow leak into a fixed cost.

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.

Server resident memory after three rounds of ten sessions: sessions keeping their results level off, a list in cache_resource keeps climbing. watch the rounds, not the peaksessions keep resultsleakround 1445 MB445 MBround 2618 MB618 MBround 3618 MB791 MBdiagnosiscap what a session keeps✕find what holds itLevelling off and climbing need different fixes spatialworkflow.io
Measured on a real server (Example 5): levelling off is sessions holding too much; climbing every round is accumulation.

Open tabs, shared resources and container limits

  • st.session_state is per browser tab, and a tab left open holds its memory.
  • cache_resource is shared across sessions; anything mutable there is shared too.
  • max_entries on 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.

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.