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.