Building production-grade real-time applications requires decoupling stateful socket connections from reactive UI rendering lifecycles, and isolating database change detection from socket distribution via a centralized ingestion worker. This article solves React 18/19 StrictMode double-mount socket leaks, implements cryptographic ticket handshake authentication, and architects a Single Ingestion Worker pattern over MongoDB Replica Set Change Streams with oplog window sizing and automated Full State Sync fallback on stale resume tokens.
Series·Part 4 of 6
Real-Time Web Architecture: From Polling & SSE to WebSockets & Distributed Backplanes
Real-time is not a library you import, but an architectural contract across the network stack: choose the lowest-complexity transport that matches your data symmetry, budget for long-lived socket file descriptors, and decouple stateful client connections from stateless event producers via a distributed backplane. Parts 1–3 established the transport layer — from polling overhead equations and SSE wire framing to RFC 6455 binary frames and OS socket memory math. Now we assemble the complete application engine.
The hardest part of real-time web development is not choosing a transport — it is integrating that transport correctly into a React component tree, an Express authentication system, and a database that must detect its own writes and stream them to the right clients. Most tutorials show a single-file socket.io demo with no authentication, no cleanup, and no consideration for what happens when React 18 mounts the component twice in StrictMode, the JWT expires mid-session, or the MongoDB replica set failover takes 15 seconds and your Change Stream cursor silently dies.
This article solves all of it. We implement a production-grade real-time application layer: a cryptographic ticket handshake for WebSocket authentication, a ref-counted singleton ConnectionManager, React 18/19 StrictMode-resilient socket hooks, and a Single Ingestion Worker over MongoDB Replica Set Change Streams with oplog window sizing, resume token management, and automated Full State Sync fallback.
The first mistake in every real-time tutorial is storing the WebSocket instance in React component state or a useRef inside the component that renders the real-time UI:
TYPESCRIPT
// ❌ Component-scoped socket — creates reconnection stormsfunctionFleetMap() {
const [socket, setSocket] = useState<WebSocket | null>(null)
useEffect(() => {
const ws = newWebSocket('wss://api.fleettracker.io/ws/fleet')
setSocket(ws)
return() => ws.close() // ← Closes on every re-render!
}, [])
// Problems:// 1. Each mount of FleetMap creates a new WebSocket connection// 2. When FleetMap unmounts (route change, conditional render),// the socket closes — even if another component needs it// 3. React 18 StrictMode double-invokes useEffect → 2 connections opened// 4. Navigation away and back → old socket closed, new one opened
}
The structural problem: A WebSocket connection is application-scoped infrastructure, not component-scoped UI state. It should live exactly as long as the user's authenticated session, not as long as a particular component is mounted.
React 18 StrictMode deliberately mounts → unmounts → re-mounts every component in development to surface non-idempotent side effects. A useEffect that opens a WebSocket and closes it in the cleanup runs twice:
This is correct behavior — the singleton handles it gracefully. The ref-counting ensures that if multiple components call acquire() simultaneously, only one connection is opened:
TEXT
Component A mounts → acquire() → refCount = 1 → connect()
Component B mounts → acquire() → refCount = 2 → (no-op, already connected)
Component A unmounts → release() → refCount = 1 → (no-op, B still needs it)
Component B unmounts → release() → refCount = 0 → disconnect()
Expand
React 18 StrictMode double-mounts components causing naive socket hooks to duplicate connections; ref-counted SocketManager ensures exactly one socket is opened
// ❌ Passing a long-lived JWT in the WebSocket URL — multiple failuresconst ws = newWebSocket(`wss://api.example.com/ws?token=${jwtToken}`)
// Problems:// 1. The full URL (including query string) is logged in Nginx access logs,// CloudFront access logs, and application server logs — token is plaintext// 2. Browser history / referrer headers may expose the token// 3. The JWT is valid for its full lifetime (hours) — replay attack window// 4. Cannot be rotated without closing the WebSocket connection
The ticket pattern uses a two-step exchange: the client authenticates via a standard REST endpoint (with a proper Authorization: Bearer header, not logged in access logs) to receive a single-use, short-lived (30 seconds), cryptographically signed ticket. The ticket is then used once to authenticate the WebSocket upgrade request.
TYPESCRIPT
// server/routes/auth.tsimport { randomBytes, createHmac } from'node:crypto'constTICKET_SECRET = process.env.WS_TICKET_SECRET! // 32-byte random secretconst pendingTickets = newMap<string, { userId: string; tenantId: string; expiresAt: number }>()
// Step 1: Client calls this REST endpoint with a valid Bearer token
app.post('/api/auth/ws-ticket', requireAuth, (req, res) => {
const ticketId = randomBytes(32).toString('hex')
const expiresAt = Date.now() + 30_000// 30-second validity window// Sign ticket with HMAC-SHA256const signature = createHmac('sha256', TICKET_SECRET)
.update(`${ticketId}:${expiresAt}:${req.user!.userId}`)
.digest('hex')
const ticket = `${ticketId}.${expiresAt}.${signature}`// Store pending ticket (in Redis for multi-node: SETEX ticket 30 userId)
pendingTickets.set(ticketId, {
userId: req.user!.userId,
tenantId: req.user!.tenantId,
expiresAt,
})
// Auto-expire if never usedsetTimeout(() => pendingTickets.delete(ticketId), 31_000)
res.json({ ticket })
})
// ❌ Change Streams on a standalone MongoDB — throws immediatelyconst client = newMongoClient('mongodb://localhost:27017') // Standaloneawait client.connect()
// This throws: MongoServerError: The $changeStream stage is only// supported on replica sets and sharded clusters.const stream = db.collection('vehicles').watch()
MongoDB Change Streams are implemented on top of the oplog (local.oplog.rs) — a capped collection that records every write operation in order. The oplog only exists on Replica Set members. To develop locally:
BASH
# Start a single-node replica set for local development
mongod --replSet rs0 --port 27017 --dbpath /data/db
# In mongosh: initialize the replica set
rs.initiate({
_id: 'rs0',
members: [{ _id: 0, host: 'localhost:27017' }]
})
// ❌ Opening a Change Stream cursor per connected WebSocket client
wss.on('connection', async (socket, req) => {
const { tenantId } = validateTicket(/* ... */)
// At 1,000 concurrent users: 1,000 open MongoDB cursors// Each cursor tails the oplog and transmits all events to the application// MongoDB Atlas free tier: 500 cursor limit — will throw at 501st userconst stream = db.collection('vehicles').watch([
{ $match: { 'fullDocument.tenantId': tenantId } }
])
stream.on('change', (change) => socket.send(JSON.stringify(change)))
socket.on('close', () => stream.close())
})
This creates one MongoDB cursor (and one oplog tail thread) per user. At 1,000 concurrent users, you have 1,000 cursors all reading the same oplog, all applying the same $match filter — 1,000× the database read pressure for identical data.
Expand
The Single Ingestion Worker pattern fans out one Change Stream cursor to active tenant connections via ConnectionManager
The oplog is a capped collection — when it fills, the oldest entries are overwritten. If your Change Stream worker is offline longer than the oplog covers, the resume token is overwritten and the stream cannot resume:
// Set oplog size (requires replica set reconfiguration)// In mongod.conf:replication:
replSetName: rs0
oplogSizeMB: 10240 # 10GB — target 48+ hours of coverage at peak write throughput
Crucial Requirement
Size your oplog to cover at least twice your maximum planned downtime during maintenance windows. If your Change Stream worker deploys once per week and deployments take up to 30 minutes, your oplog must cover at least 1 hour of writes. At 10,000 writes/second, a 2GB oplog covers approximately 3–4 hours. A 10GB oplog covers 15–20 hours.
// Server: configure write concern for Change Stream compatibilityconst db = client.db('fleettracker', {
writeConcern: { w: 'majority', j: true },
readConcern: { level: 'majority' },
})
// Change Streams with readConcern: majority guarantee:// - Events are emitted ONLY for writes that have been committed to a majority of replica set members// - If the primary fails and a new one is elected, rolled-back writes are NEVER emitted to the stream// - This enforces CP semantics: clients always see a consistent, committed view of the database
Mental Model Check
MongoDB Change Streams with readConcern: majority and writeConcern: majority deliver CP consistency for your real-time event stream: every event the stream emits represents a write that will survive any single node failure. The trade-off is latency — the write must propagate to a majority of replica set members before the Change Stream emits it, adding 1–5ms of replication lag compared to writeConcern: { w: 1 }.
For operations where the client initiates a change (updating vehicle destination, sending a command), optimistic UI renders the mutation immediately while waiting for server confirmation:
Storing WebSocket instances in React component state causes teardown leaks; a centralized singleton or stable context provider with ref-counted subscriptions is required for StrictMode resilience.
Use a module-level SocketManager singleton with acquire() / release() ref counting; React 18 StrictMode double-mount will correctly open one socket, not two.
Passing long-lived JWTs in WebSocket query parameters exposes tokens in server access logs; use the short-lived, single-use cryptographic ticket exchange pattern for handshake authentication.
REST endpoint issues a 30-second HMAC-signed ticket; the ticket is consumed once during WebSocket upgrade and deleted from the pending map.
MongoDB Change Streams strictly require a Replica Set; they fail immediately on standalone instances and must be driven by a single centralized ingestion worker to prevent oplog cursor exhaustion.
Run rs.initiate() even in local development; open exactly one Change Stream cursor per application cluster, not one per connected user.
Database Change Stream resume tokens guarantee at-least-once delivery; if the oplog rotation window expires, the system must trigger an automated full-state resynchronization fallback.
Size oplogSizeMB to cover at least 2× your maximum deployment downtime; catch ChangeStreamFatalError and trigger triggerFullStateSync() on resume token expiry.
Optimistic UI with server acknowledgment callbacks allows React interfaces to feel instantaneous while retaining the ability to rollback if the database rejects the mutation.
Apply optimistic state immediately on user action; roll back to the pre-mutation snapshot on server error; apply authoritative server data on success.
With a single Node.js process, the ConnectionManager is an in-memory singleton that cannot reach connections on other server instances. Part 5 scales the architecture horizontally: configuring Nginx to correctly proxy WebSocket upgrade headers, demystifying the sticky session myth (raw WebSockets don't need them; Socket.IO polling does), and architecting a Redis distributed backplane — evaluating Redis Pub/Sub (AP, at-most-once) against Redis Streams (CP, at-least-once) for cross-node event fanout.
Part 5 → Horizontal Scaling & Distributed Backplanes: Nginx WebSocket Proxying, Sticky Sessions & Redis Pub/Sub vs Streams — realtime-horizontal-scaling-redis-pubsub-streams (coming in Part 5 of this series)
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.