v-taiwan #3: From Socket Storm to Realtime Owner — the Frontend Is Also a System Cost
Fortes Huang’s talk at v-taiwan Meetup #3: how to tame the “socket storm” when a user opens several tabs of a real-time trading app. The premise that changes everything:
The frontend isn’t just UI — it’s part of the system cost and the governance of the data flow.
The problem in numbers
Every open tab is a full runtime: a Vue instance, a Pinia store, TanStack Query observers, a Socket.IO client, a candlestick chart, watchers. With three tabs, every market tick gets processed three times over.
The capacity thinking exercise (order-of-magnitude estimation, not a benchmark): if 70% of users open 1 tab, 25% open 2, and 5% open 5, the average is 1.45 tabs/user. With 100,000 online users that’s 145,000 runtimes — and if you cap it at 1 realtime connection per user, you avoid 45,000 streams (~31%). Behind every avoided stream there isn’t just one connection: there’s JSON parsing, computed/watch invalidation, schema validation, chart callbacks, and GC pressure that no longer runs.
Realtime Owner: who gets the tick
The chosen strategy: a single tab (the Leader/Owner) connects to the /quote socket and consumes the realtime tick; the others (Followers) only see history via REST and can claim the role when they need to. Coordination travels over BroadcastChannel — but only control messages:
- What it IS responsible for: leader election (heartbeat, release, stale takeover), requesting/releasing ownership, invalidation signals after a CRUD operation
- What it is NOT responsible for: transporting the market tick — the Follower never receives or renders the tick; only the Leader consumes it
This separation is literally data plane / control plane: the high-frequency channel (Binance → backend → leader tab) never mixes with the low-frequency one (coordination).
An honest caveat from the speaker about the “Policy-Based” version with multiple owners: it’s the most complete on the diagram, but “nice to look at, not to operate” — leader election with edge cases, heartbeats, debugging “why isn’t this tab the leader.” Start simple with maxRealtimeOwners=1 and only add policy if the product actually asks for it. The classic tension between whiteboard architecture and what you actually ship.
CRUD without a refetch storm
When an admin mutates data, callUpdate travels as a signal — it’s not the data, it’s just “this resource changed.” Local invalidation is immediate, but the network refetch carries jitter to avoid QPS spikes: in the example of a 9-screen grid with the same pair, one admin mutation shouldn’t trigger 9 refetches — the leader does 1 (with dedupe + jitter) and distributes the snapshot to the followers.
Why the limit lives in the backend
The part I liked most, because it’s pure backend thinking: BroadcastChannel only coordinates tabs from the same origin in the same browser — it can’t see the user’s other devices. Only the backend has the global view by userId/sessionId, and the limit also depends on the plan (mobile 1, desktop 3-5, professional trader 10): it’s a business rule, not a UI rule. The direct analog in my world is Spring Security’s maximumSessions — the server decides how many concurrent sessions are allowed, not the browser.
Two more parallels with the Java stack that I noted:
- Precision boundary: the backend (Rust) passes prices as
Binance string → BigDecimal (DB) → string (API)— neverf64/numberuntil the frontend decides how to display it. Exactly theBigDecimal-for-money rule in Java. committed_broadcast_failed: distinguishing “never happened” from “happened but nobody found out.” If the commit already occurred and the broadcast fails, the write isn’t retried — only the resync happens. It’s the same problem the outbox pattern solves in systems with Kafka + a transactional DB.
And on client-side validation: in Spring Boot, @Valid + Jackson protect the HTTP boundary for free; on a frontend socket there’s no container — what arrives is literally any. Zod is that boundary: it validates the shape at runtime, fails fast and clearly (if the backend changes a field from string to number, you see it on the first tick, not as a silent NaN three components later), and with z.infer the TS type is generated from the schema — a single source of truth.
Demo repos: frontend (Vue 3 + TS) and backend (Rust + PostgreSQL).