Architecture: sessions, threads and reruns

Streamlit · Expert · 16 min

Stale state, cross-user leaks, lost widget values and threading errors all trace back to how Streamlit runs scripts.

When a browser opens an app, it connects over a WebSocket to the Streamlit server (Starlette and uvicorn today). The server creates an AppSession per connection with its own session state and a ScriptRunner that executes your script in a separate thread.

Each run executes the script with exec. Every st.* call sends a delta (a protobuf message) describing an element; the front end applies deltas to the page in order. At the end of the run, elements not re-sent are removed. If a widget changes while a run is still executing, the current run is stopped at its next st.* call by an internal exception derived from BaseException, and a new run starts. A bare except: around st calls would swallow it; except Exception does not.

Widget identity is a hash of the widget type, its parameters and its key. Same ID across runs means the value is preserved; any parameter change creates a new widget with its default value. Widgets not rendered in a run are dropped from session state.

Caches are process-global, shared by all sessions and threads, so cache_resource objects must be thread-safe.

Threads you create do not have a script run context. Calling st.* from them fails or is ignored. Let such threads only compute and store results; display them from the script, for example in a fragment with run_every.

Globals in imported modules (unlike the main script, which is re-executed every run) are shared by all users; keep per-user data in session state.

Example

import threading, time
import streamlit as st

@st.cache_resource
def shared():                         # one dict per process, shared by ALL sessions
    return {"lock": threading.Lock(), "results": {}, "runs": 0}

def background_job(store, job_id):    # no st.* calls in this thread
    time.sleep(3)
    with store["lock"]:
        store["results"][job_id] = "done"

store = shared()
with store["lock"]:
    store["runs"] += 1                # counts every user's reruns
st.session_state.my_runs = st.session_state.get("my_runs", 0) + 1  # yours only

if st.button("Start job"):
    threading.Thread(target=background_job, args=(store, "job-1"), daemon=True).start()

@st.fragment(run_every="1s")          # poll from the script thread
def status():
    st.write("job-1:", store["results"].get("job-1", "running or not started"))
status()
st.caption(f"all runs={store['runs']}, my runs={st.session_state.my_runs}")

Remember

  • Each tab is a session; its script runs in a thread and streams deltas over a WebSocket
  • Widget IDs derive from type, parameters and key; parameter changes reset widgets
  • Caches and imported-module globals are shared process-wide; session state is per user

Watch out for

  • Calling st.* functions from a background thread, which has no script run context.

Check yourself

  1. You store the current user's cart in a global list inside a helper module the app imports. What goes wrong?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.streamlit.io/develop/concepts/architecture/architecture