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_change callback and st.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.

Last-Write-Wins Timeline Across Two TabsTwo horizontal timelines flow left to right, one per tab. Tab A writes zoom 10 at timestamp t1 and zoom 12 at t2; Tab B writes zoom 13 at t4, the latest timestamp. Each write updates the shared Redis key's _updated_at field. On Tab A's next rerun the remote timestamp t4 exceeds its last sync ts, so Tab A pulls zoom 13 and both tabs converge to the final write.TAB At1write zoom=10t2write zoom=12t5 rerunpull zoom=13SHARED REDIS KEY — dashboard_state:user_123_updated_at advances monotonically · latest write owns the value · last-write-winsTAB Bt4 (latest)write zoom=13remote ts > last_synct4 is newest → key holds zoom=13

Jump to heading Step-by-step solution

A scalable pattern follows four deterministic steps, each mapped to a concrete piece of the implementation:

  1. 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.
  2. 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.
  3. Sync on mount and on change. Load the external state into st.session_state during initialization. Push local changes back to the store using widget on_change callbacks or explicit save triggers.
  4. 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.
Cross-Tab Session State Synchronization Through a Shared StoreTwo browser tabs for the same user each hold an isolated st.session_state behind a separate WebSocket session ID. Neither tab can read the other's memory. A shared Redis key, dashboard_state:user_123, sits between them: each tab writes its viewport, zoom and active layers with a monotonic timestamp on widget change, and pulls newer state on rerun when the remote timestamp exceeds its last sync. Last-write-wins resolves concurrent edits.BROWSER TAB Asession_id: 7f3a… (WebSocket)st.session_statezoom = 10center = [40.71, -74.01]BROWSER TAB Bsession_id: c19d… (WebSocket)st.session_statezoom = 13center = [40.75, -73.99]MEMORY ISOLATED — NO DIRECT SHARINGSHARED STORE (REDIS)key: dashboard_state:user_123{ zoom, center, _updated_at: 1719… }last-write-wins · TTL 3600swrite on_change(+ timestamp)write on_change(+ timestamp)write on widget changeread on rerun if remote ts is newer

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.

python
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_change callback 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_state variables and triggers a single st.rerun() to refresh the UI.
  • Infinite-loop prevention. The _last_sync_ts guard ensures the app only reruns when external state actually changes, avoiding the common polling trap.
  • Session TTL. The ex=3600 parameter 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:

python
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_state directly 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_at timestamp 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=3600 TTL 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