Real-time & Persistent State: WebSockets, SSE, BroadcastChannel & IndexedDB
Pull-based server state (React Query) and push-based server state (WebSockets, SSE) are architecturally distinct domains. This article covers the third state domain: real-time event streams, the merge-vs-invalidate decision, cross-tab synchronization with BroadcastChannel, and local persistence with IndexedDB and Zustand persist middleware.
Frontend State Architecture
Real-time & Persistent State: WebSockets, SSE, BroadcastChannel & IndexedDB
The series has so far covered two state domains: UI state (Parts 1–3, 6) and pull-based server state (Part 4). Both assume a request-response model: the UI asks, the server answers.
But a large and growing class of modern applications lives outside that model: chat, collaborative editing, live dashboards, trading feeds, notifications, online presence indicators. For these, the server pushes updates without being asked. This is push-based server state — the third domain, with its own architecture, its own failure modes, and its own set of tools.
This article also covers local persistence — the browser's storage layer — and cross-tab synchronization, which becomes critical when push-based state changes in one tab and must propagate to others.
1. Pull vs. Push: The Architectural Divide
| Pull-Based | Push-Based | |
|---|---|---|
| Initiation | Client fetches on demand | Server sends when data changes |
| Caching model | Stale-while-revalidate, invalidation | Event stream, append or replace |
| React Query fit | ✅ Natural — polling, caching, refetching | ⚠️ Awkward — requires manual integration |
| Protocol | HTTP request-response | WebSocket, SSE, WebRTC |
| State merge logic | Invalidate and refetch | Merge incoming event into local state |
| Primary challenge | Staleness, waterfall | Connection lifecycle, reconnect, ordering |

Think of pull-based state as a snapshot that ages (solved by React Query's stale-while-revalidate). Think of push-based state as a stream of events that must be applied to a local state model (solved by WebSocket/SSE + a merge strategy). The two require different architectural thinking.
2. WebSocket Connection as a State Machine
A WebSocket connection has a native lifecycle with four states:
Model this as a state machine — not as a bag of booleans:

3. Server-Sent Events (SSE): Simpler Unidirectional Push
SSE is often the correct choice over WebSockets when data flows only from server to client. Its advantages:
- HTTP/2 multiplexed — no separate TCP connection needed
- Automatic reconnect built into the browser's
EventSourceAPI - Passes through proxies and CDNs without special configuration (no WebSocket upgrade)
- Text-based, human-readable — easier to debug
SSE vs. WebSocket decision rule: If data flows only server → client (notifications, live prices, progress updates), use SSE — simpler, more reliable through infrastructure. If the client needs to send data continuously too (chat, collaborative editing, gaming), use WebSockets.
4. The Merge vs. Invalidate Decision
When an incoming server event arrives, you face a fundamental architectural choice:
Merge: Apply the event to the local state immediately. Invalidate: Mark the cached data as stale and refetch from the server.
Merge is correct when:
- The incoming event is the state change (a new chat message, a new log entry)
- You can apply the event locally without round-tripping to the server
- Out-of-order delivery would be tolerable or handled client-side
Invalidate is correct when:
- The event is a signal that something changed, not the data itself
- Multiple concurrent users might have changed the same field (inventory, votes)
- Local merge logic would be complex or error-prone
- Consistency with server state is more important than immediate update speed
5. BroadcastChannel: Cross-Tab State Sync
When a user has your application open in multiple tabs, push-based state changes in one tab are invisible to the others. BroadcastChannel bridges this:
BroadcastChannel works across tabs of the same origin (same protocol, domain, and port). It does not work across different origins or across devices.
Alternative for broader support: The storage event fires in all tabs when localStorage is modified by another tab:
The storage event does NOT fire in the tab that made the change — only in other tabs. This is intentional: the modifying tab already has the latest value in memory.

6. Local Persistence as a State Layer
The browser provides four storage mechanisms for persisting state across page loads:
| Store | Capacity | Scope | Sync/Async | XSS accessible | Use case |
|---|---|---|---|---|---|
localStorage |
~5–10 MB | Origin, all tabs | Synchronous | ✅ Yes (dangerous) | Non-sensitive UI preferences |
sessionStorage |
~5 MB | Origin, this tab only | Synchronous | ✅ Yes | Tab-scoped temporary state |
IndexedDB |
Hundreds of MB | Origin | Asynchronous | ✅ Yes | Offline data, drafts, large structured data |
Cookie (httpOnly) |
~4 KB | Configurable | Via HTTP header | ❌ No | Auth tokens, session IDs |
Never store auth tokens, API keys, or secrets in localStorage or sessionStorage. Any injected script — via XSS — has full read access to these stores via localStorage.getItem(). Auth tokens belong in httpOnly cookies, which are inaccessible to JavaScript entirely.
6.1 Zustand Persist Middleware
For non-sensitive UI state (theme preference, sidebar open/closed, draft content), Zustand's persist middleware synchronizes store state to localStorage automatically:
Always include a version field in your persist config. When you change the shape of persisted state (rename a field, change a type), increment the version and write a migrate function. Without this, old persisted data with a different shape will silently corrupt the store on load.
7. Offline-First: Queue, Sync, Replay
For applications that need to work without a network connection:
8. References
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.