How to Manage Streamlit Session State Across Multiple User Tabs
Streamlit isolates st.session_state per browser tab, so to share state across tabs for the same user you externalize a lightweight spatial payload to a shared store (Redis, PostgreSQL, or a thread-safe KV store) keyed to a stable user identifier, then sync it with a timestamp-gated, last-write-wins loop that avoids race conditions.
Jump to heading Why this matters
Each browser tab establishes an independent WebSocket connection with its own session ID, and Streamlit scopes every variable to that connection — which is exactly the session state isolation that keeps concurrent reruns from clobbering each other. For GIS analysts that isolation becomes friction the moment they open one tab to compare flood zones while editing layer styling in another: the two map views drift apart. Treating cross-tab synchronization as an explicit layer of your Core Dashboard Architecture & State Management — rather than expecting it from the framework — is what keeps viewport, zoom, active layers, and the coordinate reference system aligned across every window a user has open. Because the heavy geometry behind those parameters belongs in a cache, not in state, the sync payload itself stays tiny.
Jump to heading Prerequisites
- Python 3.9+ and Streamlit 1.30+ (the
on_changecallback andst.rerun()APIs used below). - redis-py 5.x and a reachable Redis instance (local Docker container is fine for development).
- A working understanding of why each tab is a separate session — see Session State Patterns for the reactive-execution and per-session model this page builds on.
Jump to heading Why Streamlit isolates tabs by design
Streamlit’s execution model treats every browser tab as a distinct user session. This isolation prevents WebSocket message collisions, avoids accidental state overwrites during concurrent reruns, and aligns with the framework’s reactive, top-down execution flow. You can review the official session state architecture to understand how Streamlit serializes and scopes variables per connection.
The consequence for spatial tooling is concrete: Streamlit intentionally delegates multi-session coordination to external systems, so your dashboard must implement a publish-subscribe or last-write-wins strategy to keep spatial contexts (bounding boxes, active layers, coordinate reference systems) aligned. The four steps below are the deterministic version of that strategy.
Jump to heading Step-by-step solution
A scalable pattern follows four deterministic steps, each mapped to a concrete piece of the implementation:
- Identify the user deterministically. Extract a stable identifier from an authentication token, session cookie, or enterprise SSO header. Never rely on IP addresses or
st.runtime.scriptrunner.get_script_run_ctx().session_id, as these change per tab and break persistence. - Externalize spatial state. Serialize only the necessary dashboard parameters (for example
{"center": [40.7128, -74.0060], "zoom": 10, "active_layers": ["parcels", "flood_zones"], "crs": "EPSG:4326"}) and store them in a shared backend. Keep payloads under 1MB to avoid serialization latency and network bottlenecks. - Sync on mount and on change. Load the external state into
st.session_stateduring initialization. Push local changes back to the store using widgeton_changecallbacks or explicit save triggers. - Resolve conflicts. Implement a monotonic timestamp or version counter to prevent stale overwrites when multiple tabs modify state simultaneously. A simple last-write-wins approach suffices for most analytical dashboards, but optimistic locking is recommended for write-heavy workflows.
Jump to heading Working code: Redis-backed state sync
The following example implements the four steps as a single, copy-pasteable script. It handles initialization, event-driven writes, and timestamp-gated reads to prevent infinite reruns.
import streamlit as st
import redis
import json
import time
# Configuration
REDIS_URL = "redis://localhost:6379/0"
USER_ID = "user_123" # Derive from auth token or session cookie in production
STATE_KEY = f"dashboard_state:{USER_ID}"
# Initialize Redis client
r = redis.from_url(REDIS_URL, decode_responses=True)
def load_remote_state() -> dict:
"""Fetch and deserialize state from Redis."""
data = r.get(STATE_KEY)
return json.loads(data) if data else {}
def save_remote_state(state: dict):
"""Serialize state, attach timestamp, and push to Redis with TTL."""
state["_updated_at"] = time.time()
# TTL of 3600 seconds auto-expires abandoned sessions
r.set(STATE_KEY, json.dumps(state), ex=3600)
def make_push_callback(key: str):
"""Return a callback that pushes a single key to Redis on widget change."""
def _push():
remote = load_remote_state()
remote[key] = st.session_state[key]
save_remote_state(remote)
return _push
# --- Initialization ---
if "initialized" not in st.session_state:
st.session_state.initialized = True
remote = load_remote_state()
st.session_state.zoom = remote.get("zoom", 10)
st.session_state.map_center = remote.get("map_center", [40.7128, -74.0060])
st.session_state._last_sync_ts = 0.0
# --- Cross-Tab Sync Check ---
# Pull remote updates only if they are newer than our last sync
remote = load_remote_state()
remote_ts = remote.get("_updated_at", 0.0)
if remote_ts > st.session_state._last_sync_ts:
st.session_state.zoom = remote.get("zoom", st.session_state.zoom)
st.session_state.map_center = remote.get("map_center", st.session_state.map_center)
st.session_state._last_sync_ts = remote_ts
# Trigger a single rerun to reflect pulled state in UI
st.rerun()
# --- UI & Widgets ---
st.title("Multi-Tab Spatial Dashboard")
st.caption(f"Synced state for user: `{USER_ID}`")
st.number_input(
"Zoom Level",
min_value=1,
max_value=20,
key="zoom",
on_change=make_push_callback("zoom")
)
st.info("Map center updates will sync across tabs on next interaction.")
How the sync logic holds together:
- Event-driven writes. The
on_changecallback fires immediately when a user modifies a widget, pushing the updated value to Redis with a fresh timestamp. - Timestamp-gated reads. Every script execution checks the Redis timestamp. If a newer version exists, it overwrites local
st.session_statevariables and triggers a singlest.rerun()to refresh the UI. - Infinite-loop prevention. The
_last_sync_tsguard ensures the app only reruns when external state actually changes, avoiding the common polling trap. - Session TTL. The
ex=3600parameter auto-expires the Redis key after one hour of inactivity, preventing unbounded key accumulation.
Jump to heading Choosing event-driven vs. polling sync
Streamlit reruns the entire script on every user interaction, which makes event-driven sync highly efficient for dashboards with frequent widget usage. However, if users leave a tab idle while another tab updates the shared state, the idle tab will not reflect changes until the next interaction.
To achieve true real-time synchronization without user action, you can implement a lightweight polling loop using st.empty() and time.sleep(), though this increases server load and complicates deployment on serverless platforms. Alternatively, leverage Redis Pub/Sub or Server-Sent Events with a background worker thread — an approach that pairs naturally with async data loading patterns. For most internal tools and GIS workbenches, the timestamp-gated event pattern above strikes the optimal balance between responsiveness and infrastructure overhead.
Jump to heading Alternative: a browser localStorage bridge
For lightweight applications where deploying a Redis instance is impractical, you can synchronize tabs using the browser’s native storage. This requires a custom Streamlit component that injects a small JavaScript listener for the storage event. When one tab updates localStorage, all other tabs for the same origin receive a synchronous event, allowing you to call window.parent.postMessage() to trigger a Streamlit rerun.
While this approach eliminates backend dependencies, it is limited to single-origin deployments and cannot scale across different domains or enterprise proxy setups. For detailed implementation guidance, refer to the MDN Web Storage API documentation.
Jump to heading Verification
Confirm the sync works by asserting that the value written from one session is readable by another through the same shared key. Run this in a separate Python shell while the dashboard is open:
import redis, json
r = redis.from_url("redis://localhost:6379/0", decode_responses=True)
state = json.loads(r.get("dashboard_state:user_123"))
# The key written by Tab A's on_change callback must be visible here
assert state["zoom"] == 13, f"expected the last write, got {state['zoom']}"
assert "_updated_at" in state, "every write must carry a monotonic timestamp"
print("OK — cross-tab state converged:", state)
In the UI itself, the proof is behavioral: change the zoom in Tab A, switch to Tab B and interact with any widget, and Tab B’s zoom snaps to Tab A’s value after exactly one rerun.
Jump to heading Edge cases & gotchas
- Tab isolation is not optional. You cannot read another tab’s
st.session_statedirectly under any circumstance — the per-WebSocket session ID guarantees isolation. If your sync “works” without a shared store, you are accidentally relying on a single tab. - Stale overwrites from slow tabs. Without the
_updated_attimestamp gate, a tab that loads slowly can push its old configuration over a newer one, causing jarring viewport jumps. Always attach and compare the monotonic timestamp before writing. - TTL expiry mid-session. The
ex=3600TTL drops the key after an hour of inactivity; a user returning to an idle tab then sees defaults, not their last view. Refresh the TTL on every write (the example does this implicitly by re-set-ing) or lengthen it for long analytical sessions. - Heavy payloads kill responsiveness. Never sync raw GeoDataFrames, GeoJSON collections, or raster tiles — Streamlit reruns on every interaction and each write would serialize the whole payload. Sync only metadata and fetch geometry on demand through query result caching.
Jump to heading FAQ
Why doesn't st.session_state share across browser tabs automatically?
Each tab opens its own WebSocket connection with a unique session ID, and Streamlit scopes st.session_state to that connection. Two tabs for the same user therefore hold completely separate state objects, and neither can read the other’s memory. Cross-tab sync has to be implemented through an external shared store — Redis, PostgreSQL, or a thread-safe KV store — keyed by a stable, authenticated user identifier rather than the per-tab session ID.
Will idle tabs update in real time without any user interaction?
Not with the event-driven pattern alone. Streamlit only re-runs a tab’s script when that tab receives an interaction, so an idle tab reflects another tab’s change on its next rerun. For genuine push updates you can add a polling loop with st.empty() and time.sleep(), or wire Redis Pub/Sub or Server-Sent Events through a background worker thread — at the cost of higher server load and trickier serverless deployment.
Can I sync a GeoDataFrame across tabs through session state?
No. Never serialize raw GeoDataFrames, GeoJSON feature collections, or raster tiles into cross-tab state — they bloat every rerun and every write. Sync only lightweight metadata (center, zoom, layer IDs, filter predicates, and the CRS code such as EPSG:4326) and reconstruct the heavy geometry on demand from a cache or spatial database. See Session State Patterns for the schema-design rules that keep state small.
Back to Session State Patterns
Related
- Session State Patterns — schema design, initialization guards, and Redis persistence that this cross-tab pattern extends
- Core Dashboard Architecture & State Management — the broader architectural layers that cross-tab sync sits within
- Query Result Caching — keep heavy geometry out of synced state and fetch it on demand instead
- Async Data Loading Patterns — background workers and SSE for true push-based real-time sync