Back to Explore
GuideVibeFix 编辑部Updated Oct 13, 2026

Stop Polling with setInterval: Realtime Features Playbook for Vibe Projects

AI loves setInterval polling for "realtime" — and it brings exploding bills, baked-in latency, and stale data. This playbook covers realtime done right for vibe projects: the polling/SSE/WebSocket/WebRTC decision table, channel auth, reconnect with message resume, presence, multi-instance pub/sub scaling, a connection-count cost model, and an 8-item launch checklist. With Supabase, PartyKit, and self-built WebSocket integration code.

Editorial photo of realtime collaboration: people connected over a live video call, symbolizing realtime connection

Your AI coding assistant just finished building you a "realtime notifications" feature. You click through it: it works, messages show up three seconds after they're sent. You're happy, you ship it. A week later the bill arrives, your database connection pool is saturated at peak hours, and someone in your user group is asking "why am I seeing numbers from three minutes ago?" That's not bad luck — that's the standard outcome of AI-written realtime code.

This guide is about doing realtime features in vibe projects without getting burned: why AI keeps handing you setInterval polling, how to actually choose between polling / SSE / WebSocket / WebRTC, how to do channel auth, reconnects, presence, and multi-instance scaling — plus the launch checklist. One goal: after your app drops offline in the subway and reconnects, it should fetch the messages it missed all by itself.

One note on where this guide stands: it won't teach you "how the WebSocket protocol works" — that's what docs are for. This is about the judgment AI never gives you: when polling is actually fine, when you must use WebSocket; which layer auth belongs at; where the reconnect traps are; how to avoid a rewrite on the day your second instance goes live. Every section ends with a "solo-team verdict" you can take and use. By the end, you'll know how to review, fix, and ship AI-generated realtime code.

1. Why AI writes bad realtime code: the three sins of polling

First, an observation: ask AI for a "realtime" feature and ten times out of nine you get setInterval(() => fetch('/api/updates'), 3000). It's not being lazy — polling examples dominate its training data, they're stateless, and they run on the first try. It's the model's path of least surprise. But "looks like it runs" is the most expensive illusion in vibe coding. Polling has three sins, and each one collects its tax in production.

Sin one: the bill. Polling translates "realtime" into "requests = users × online time / interval." Do the math: 10,000 daily active users, 10 minutes online each per day, polling every 3 seconds — that's 200 requests per person per day, 2 million requests a day. Every request opens a connection, runs auth, and hits the database. Your connection pool and your metered backend will feel "realtime" before you do. Worse: 90% of those polls return "no updates." You're paying for idle spinning.

Sin two: latency. Polled data is always one interval out of date. Shrink the interval from 5 seconds to 1 and latency drops — while the bill multiplies by 5. That's polling's impossible triangle: latency, cost, and freshness — pick at most one. In collaborative editing or game battles, 1 second of delay means "they already left and you just noticed." The product is dead on arrival.

Sin three: stale data. Polling always shows you the world as of your last request. Two people editing the same record, "typing…" indicators in chat, viewer counts on a livestream — polling sees all of it in the past tense. Users won't tell you "your architecture is wrong." They'll just think "this product feels laggy and fake."

The verdict in one line: polling isn't "the simple option" — it's the option that moves complexity onto your bill and your users' experience. It belongs in exactly two places: prototype validation, and as a fallback degradation path for your real solution. Anywhere else, when AI hands you setInterval for realtime, ask "isn't there something better?"

Some real-world scale to feel it: a vibe-built live quiz with 5,000 concurrent users polled the leaderboard every 2 seconds — 2,500 requests per second, each running an aggregation query. Twenty minutes after launch, the connection pool alarm fired, then the billing alarm. After switching to WebSocket push, the same 5,000 users cost the server one broadcast per new question — under 10 writes per second. Same "realtime," two orders of magnitude apart in cost. That's why the decision has to happen before you write code, not after the bill.

There's also a hidden cost: polling bakes "one beat behind" into your product forever. In an AI chat scenario, the user hits send and polling waits for the next 3-second tick before it even starts streaming the response — it feels like "I clicked send and nothing happened for 3 seconds." That's not a network problem, it's an architecture problem, and no amount of money buys it away.

Finally, here's an "AI realtime code review checklist" — when AI hands you realtime code, check these 5 things first. Each one is a past incident:

  1. Is there auth? Search the handshake code for token verification. None means it's wide open.
  2. What happens on disconnect? Search for onclose / reconnect. Nothing means "occasionally online."
  3. Do messages have IDs? Without monotonically increasing IDs, resume and dedup are impossible.
  4. Where's the heartbeat? No ping/pong and in three months you'll be running a zombie-connection farm.
  5. Was it load-tested? Ask the AI "how much memory at 2,000 concurrent connections" — it can't answer, because it never ran it.

All five green, the code ships. One red, it gets rewritten. Paste this checklist into your code review template and make the AI check itself against it before you even look.

2. The four-way decision: polling / SSE / WebSocket / WebRTC

There are only four realtime options, and picking wrong means rewriting. The conclusion first: for 90% of vibe projects, the answer is SSE or WebSocket — pick one; polling is only a fallback; don't touch WebRTC unless you're doing audio/video.

OptionTypical latencyDirectionCostComplexityThe deciding line (one sentence)
PollingSeconds (= interval)Client pullsMedium (idle requests burn money)Lowest5-second-old data kills nobody, reads dominate writes, a few hundred concurrent users max
SSE100–500msServer → client, one-wayLow (HTTP long-lived connection)LowYou only need server-to-client push, no client uplink
WebSocket50–200msFull duplexMedium (connections live in memory)MediumTwo-way low-latency interaction: collaboration, chat, games, AI conversations
WebRTC<100ms (P2P)Peer to peerHigh (signaling + TURN + debugging)HighestAudio/video calls or P2P large files, in small rooms

Polling's disqualifiers (one is enough to rule it out): frequent writes (every poll might find something new), thousands of concurrent users (the multiplier effect melts your DB first), latency sensitivity (over 1 second is unacceptable). Hit any one of the three and polling is slow suicide.

SSE is the most underrated option of 2026. AI streaming output — an LLM emitting token by token — has one standard answer: SSE (EventSource). Simple, the browser natively supports reconnects (Last-Event-ID), and it multiplexes nicely over HTTP/2. Two gotchas: Nginx / CDNs buffer response bodies by default — turn off proxy_buffering and set X-Accel-Buffering: no; and it's one-way, client uplink still goes over regular HTTP requests.

The three-line Nginx config for SSE — miss one and it misbehaves:

proxy_buffering off;
proxy_cache off;
# X-Accel-Buffering: no;  # response header, returned by your app

Symptom check: works locally, but in production SSE "buffers up a batch then flushes" — 99% of the time some proxy layer is buffering. Cloudflare users, note: its CDN is SSE-friendly, but don't expect it to do your auth.

WebSocket is the only general answer for two-way low latency, and it costs you: connections live in memory, you manage heartbeats yourself, and multiple instances need pub/sub (section 6). One rule of discipline: don't run over WebSocket what HTTP can do — file uploads and heavy queries go over HTTP; WebSocket carries only genuine realtime messages. I've seen too many people treat WS as a universal pipe, ending up with 90% of the traffic being stuff REST could have handled.

Don't touch WebRTC. Unless your product is audio/video calls. P2P sounds beautiful, but you still need a signaling server, NAT traversal, and TURN relays — every single one. Past 4 people in a room, P2P mesh connections grow as O(n²) and you need an SFU — that's a different profession. 99% of vibe projects don't need WebRTC, and the 1% that do shouldn't build it themselves; use an established audio/video PaaS.

How do you feed this decision table to AI? Don't ask "which do you think is better." Give constraints: "5,000 DAU, two-way realtime customer-service chat, sub-500ms latency required, team of one, no ops — pick per the decision table and give me integration code." The more specific the constraints, the less likely AI falls back to its polling template. Remember: the decision is your judgment, not the AI's.

Polling vs SSE vs WebSocket vs WebRTC comparison chart Figure: the four options compared. Latency / cost / complexity scored 1 (best) to 5 (worst). Polling maxes out latency because latency always equals the poll interval; WebRTC maxes out complexity because signaling, NAT traversal, and TURN relays are all mandatory. Decide by the deciding line: SSE when you only need server push, WebSocket for two-way low latency.

3. Channel auth: don't let strangers subscribe to someone else's room

The security boundary of a realtime system isn't "can you connect" — it's "which channels can you subscribe to." Connections are cheap; channels are valuable. AI-generated code's favorite anti-pattern: the client sends userId and roomId, the server nods and subscribes them — and channel names are sequential room-1, room-2, so enumerating them lets anyone eavesdrop on every room.

The correct flow has three stages: server signs → handshake verifies → subscribe re-verifies.

  1. The server signs a short-lived token. HMAC-sign userId.channel name.expiry; the secret never leaves the server. 5–10 minutes of validity is enough to complete the handshake — long-lived connections don't need long-lived tokens.
  2. Verify at handshake. Take the token from the query string or Sec-WebSocket-Protocol during the WebSocket handshake; failed verification gets an immediate close(4401) — no subscription attempt allowed.
  3. Re-verify channel permission on subscribe. The channel declared in the token must match the channel actually subscribed; users may only subscribe to rooms they're members of (one membership-table lookup). Use unguessable channel names like room_<uuid>, never auto-increment IDs.

Supabase Realtime notes: channel authorization goes two ways — postgres_changes relies on RLS policies (no RLS means naked, remember enable row level security on the table), while broadcast / presence use private channels plus server-side signing. The most common real-world mistake: RLS is on but the policy doesn't cover the role realtime uses. Everything works in dev (because you're on service_role), production on the anon key returns empty — check the policy before questioning your life choices.

Supabase RLS policy example (postgres_changes won't deliver rows without it):

alter table messages enable row level security;
create policy "room members can read"
  on messages for select
  using (
    room_id in (select room_id from memberships where user_id = auth.uid())
  );

Remember: the policy is for the role realtime actually uses (authenticated / anon) — don't just test with service_role and ship.

PartyKit notes: one Party is one room. Verify the token in onConnect (same HMAC logic, secret in party.env); failed verification gets connection.close() on the spot. Broadcast within the room with party.broadcast(), point-to-point with connection.send(). Its auth is entirely yours to write — don't expect the framework to cover you.

Self-built WebSocket notes: see verifyToken in the section 4 code — handshake signature verification plus timingSafeEqual against timing attacks, 10 lines total, and AI will never volunteer it. While you're at it, make token refresh "re-sign on every reconnect" — don't let clients wander around with a 24-hour token.

Channel design is its own discipline: the finer the channel granularity, the simpler the auth. Open channels per room (room_<uuid>), not per feature (global_notifications) — a global channel means every message needs a "may this subscriber see it?" filter, turning O(1) into O(n). Cross-room system notices? That's a separate one-way push system — SSE or a push service, not stuffed into room channels.

Server-side token signing (Node, 10 lines):

function signChannelToken(userId, channel) {
  const exp = Math.floor(Date.now() / 1000) + 600 // valid 10 minutes
  const sig = crypto.createHmac('sha256', process.env.CHANNEL_SECRET)
    .update(`${userId}.${channel}.${exp}`).digest('hex')
  return `${userId}.${exp}.${sig}`
}
// After login, sign one per room the user may enter; the client fetches them when connecting

4. Reconnects and state recovery: heartbeat, backoff, resume

Set the bar first: "realtime" without a reconnect strategy is just "occasionally online." Subways, elevators, WiFi handoffs — disconnection is the norm, not the exception. Users accept "it dropped for a moment." They don't accept "it dropped and messages went missing." The reconnect trio is non-negotiable:

1. Heartbeat. The client pings every 25–30 seconds, the server pongs; two missed pongs in a row and the server declares the connection dead and reaps it. A connection pool without heartbeats becomes a zombie-connection pool in three months — still holding memory, still billing you for connections. The WebSocket protocol has ping/pong frames built in; don't invent your own heartbeat message format.

Why 25–30 seconds and not 60? Because NAT gateways and cloud LBs usually idle-timeout around 60 seconds, shorter on mobile networks. Pinging every 30 seconds leaves margin. When the app goes to background, downgrade deliberately: background WebSocket connections get killed by the OS quickly — don't let them spin idly and drain battery. On backgrounding, record last_msg_id and disconnect; on foregrounding, reconnect and resume. That's the correct mobile posture.

2. Exponential backoff + jitter. Reconnect intervals of 1s → 2s → 4s → 8s…, capped at 30 seconds, each multiplied by a random 0.5–1 jitter. Why the jitter? The moment your server restarts, thousands of clients reconnecting at once is a self-inflicted DDoS (thundering herd). AI-written reconnects are invariably "retry every 3 seconds fixed" — fine on day one, explodes on your first server deploy.

3. Resume from breakpoint. Every message carries a monotonically increasing ID; the client persists last_msg_id (localStorage is enough); on reconnect it sends since=last_msg_id and the server returns everything after it. The server must cooperate: keep a window — Redis Stream holding the last 500 messages, or a time-based DB query. Beyond the window, fall back to one full fetch on reconnect.

// Client reconnect manager: exponential backoff + jitter + resume
function connectRealtime({ channel, getToken, onMessage }) {
  let ws, retry = 0, lastId = localStorage.getItem('last_msg_id') || 0
  const MAX_DELAY = 30000
  async function dial() {
    const token = await getToken(channel) // short-lived signature, refreshed each reconnect
    ws = new WebSocket(
      `wss://api.example.com/ws?channel=${channel}&token=${token}&since=${lastId}`
    )
    ws.onopen = () => { retry = 0 }
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      if (msg.id > lastId) {
        lastId = msg.id
        localStorage.setItem('last_msg_id', lastId)
      }
      onMessage(msg)
    }
    ws.onclose = () => {
      const delay = Math.min(1000 * 2 ** retry++, MAX_DELAY) * (0.5 + Math.random() / 2)
      setTimeout(dial, delay)
    }
  }
  dial()
}

Server-side since query (Redis Stream version, pairing with the xadd persistence in the section 6 code):

// On reconnect: backfill messages after the breakpoint
const missed = await redis.xrange(`stream:${channel}`, `(${lastId}`, '+')
for (const [, fields] of missed) ws.send(fields.msg)

Supabase users, note: its SDK reconnects by itself, but changes that happened during the outage are not backfilled for postgres_changes — reconnected does not mean caught up. Business-critical flows (order status, payment results) must maintain their own cursor: after reconnect, do one full pull of updated_at > disconnect time for reconciliation. Treat realtime as "a notification to go fetch," not "the data itself," and you dodge 80% of the traps.

Backfill introduces a new problem: duplicates. The server may have pushed a message right as the connection died; the client never got it; the backfill delivers it again. Dedup client-side by msg.id (a Set caching the last 1,000 IDs) while the server guarantees monotonically increasing IDs. Order plus dedup — once both are done, you have the feeling of exactly-once. Note the emphasis: distributed exactly-once doesn't exist, don't go down that hole.

Reconnect and message resume sequence diagram Figure: reconnect + message-resume sequence. Steps ①–⑥: normal connect and traffic; ⑦: heartbeat timeout triggers exponential-backoff reconnect (1s→2s→4s… capped at 30s, with jitter against herds); ⑧: reconnect carries last_msg_id; ⑨: the server backfills post-breakpoint messages from the Redis Stream; ⑩: redelivery; ⑪: back to normal. Remember: reconnected does not mean caught up — the backfill is the point.

5. Presence in practice: who's online, typing indicators, cursor collaboration

Presence (online state) is the "looks simple, all traps" member of the realtime family: who's online, "typing…" indicators, those colored cursors in collaborative docs — all of it is fundamentally "state broadcast with expiry." Three pieces to implement:

  1. Heartbeat lease renewal. The client reports "I'm still here" every 10 seconds; the server gives that state a 30-second TTL. TTL expiry means offline — no "graceful disconnect" needed, since mobile OSes never say goodbye when killing your app.
  2. Broadcast diffs only. Don't broadcast what hasn't changed. With 100 people online, everyone broadcasting full state every 10 seconds is 10 QPS of garbage traffic. Presence messages carry only what changed.
  3. The throttle iron rule. "Typing" throttled to 2 seconds (only counts as done typing after the user pauses 2s), cursor positions throttled to 100ms. The anti-pattern: one presence message per keystroke — your channel becomes typewriter noise, and the bill and the latency explode together.

One more UX detail on "typing…": after receiving a typing state, clear it automatically if no new message arrives within 5 seconds — otherwise the other person takes a phone call mid-sentence and your "typing…" hangs there all day. Every presence state needs expiry semantics; that's the biggest difference from regular messages.

Supabase Presence in four core lines:

const channel = supabase.channel('room:' + roomId, {
  config: { presence: { key: userId } }
})
channel
  .on('presence', { event: 'sync' }, () => {
    const online = channel.presenceState() // { userId: [{status, cursor}] }
    renderAvatars(online)
  })
  .subscribe(async (s) => {
    if (s === 'SUBSCRIBED') await channel.track({ status: 'online' })
  })
// Typing indicator: throttle 2s, then channel.track({ status: 'typing' })

Self-built presence (Redis version, 20 lines):

// Report: presence:{room} is a hash, field=userId, TTL 30s
await redis.hset(`presence:${room}`, userId,
  JSON.stringify({ status: 'typing', cursor: { x, y } }))
await redis.expire(`presence:${room}`, 30)
// Read: hgetall gives the whole room's online list; TTL expiry auto-removes, no cleanup job
// Diff broadcast: after reporting, publish(`presence:${room}`, userId) so instances push diffs

One verdict: never persist presence data. It's ephemeral state; persisting it buys you consistency headaches — "the database says he's online but his phone died an hour ago" always traces back to treating ephemeral state as durable fact. Redis TTL is presence's natural home.

One step beyond cursor sync is collaborative editing (OT / CRDT) — a different depth of topic, so here's the one-line boundary: presence syncs "where the cursor is," never "what the document says" — content sync belongs to CRDT libraries like Yjs / Automerge, don't invent your own merge algorithm. Room size needs a verdict too: presence broadcast is O(n²) (n people each broadcasting to n−1). Under 50 people, go wild; in a 500-person livestream, skip full presence for "who's online" — show "online count + sampled avatars" instead.

6. From one machine to many: pub/sub scaling and the sticky-session tradeoff

On a single machine, a realtime system is almost embarrassingly simple: one Map<channel, Set<connection>>, iterate and push on message, thousands of concurrent connections without breaking a sweat. The watershed is the moment your second instance goes live: user A is connected to instance 1, user B to instance 2, and A's messages never reach B — because the broadcast only happened in instance 1's memory.

The fix is pub/sub (see the architecture figure below): each instance only manages its own local connections; cross-instance messages travel a Redis (or NATS) broadcast bus. Publishing becomes four steps: client publishes → local instance writes to pub/sub → every instance receives → each instance pushes to its own local subscribers. Instances become stateless; losing one only affects its connections (clients reconnect to another instance).

import { WebSocketServer } from 'ws'
import Redis from 'ioredis'
import crypto from 'node:crypto'

const SECRET = process.env.CHANNEL_SECRET
const pub = new Redis(process.env.REDIS_URL)
const sub = new Redis(process.env.REDIS_URL)
const wss = new WebSocketServer({ port: 8080 })
const rooms = new Map() // channel -> LOCAL connection set (local only!)

// Handshake auth: token = HMAC(userId.channel.expiry)
function verifyToken(token, channel) {
  const [userId, exp, sig] = token.split('.')
  if (Date.now() > Number(exp) * 1000) return null
  const expect = crypto.createHmac('sha256', SECRET)
    .update(`${userId}.${channel}.${exp}`).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
  return userId
}

wss.on('connection', (ws, req) => {
  const q = new URL(req.url, 'http://x').searchParams
  const channel = q.get('channel')
  const userId = verifyToken(q.get('token'), channel)
  if (!userId) return ws.close(4401, 'unauthorized')
  ws.channel = channel
  if (!rooms.has(channel)) { rooms.set(channel, new Set()); sub.subscribe(channel) }
  rooms.get(channel).add(ws)
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })
  ws.on('message', (raw) => {
    const msg = JSON.stringify({ id: Date.now(), from: userId, body: String(raw) })
    pub.publish(channel, msg)                                          // cross-instance broadcast
    pub.xadd(`stream:${channel}`, 'MAXLEN', '~', 500, '*', 'msg', msg) // resume log
  })
  ws.on('close', () => rooms.get(channel)?.delete(ws))
})

// Heartbeat: sweep every 30s, reap zombie connections
setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate()
    ws.isAlive = false; ws.ping()
  })
}, 30000)

// pub/sub arrival -> push only to local subscribers
sub.on('message', (channel, msg) => {
  rooms.get(channel)?.forEach((ws) => {
    if (ws.readyState === 1) ws.send(msg)
  })
})

The sticky-session tradeoff: having the load balancer always route the same client to the same instance makes reconnect recovery simpler (the last_msg_id cursor is still in memory). The price: painful connection migration on scale events, and when an instance dies, all its connections die with it. The solo-team verdict: go stateless instances + Redis, don't touch sticky — unless you have dedicated ops. Stateless plus external pub/sub is the cloud-native default; sticky is a legacy of the previous era.

This is exactly where managed services earn their keep: Supabase Realtime, PartyKit, and Pusher bundle "multi-instance broadcast, global edge, connection management" and sell it to you — you pay per connection and per message, and you save half an ops hire. Under 10k DAU, use managed; once connections grow, run the section 7 math on self-hosting.

Redis or NATS? For a one-person team, Redis without thinking: you already have it (cache, sessions, presence all live there); pub/sub is free. NATS JetStream is stronger when "messages must be durable and replayable," but that's a hundred-thousand-DAU problem. And note: the publish side needs auth too — verify the sender is actually in the channel before pub.publish, or anyone with a WebSocket connection can flood arbitrary channels and your pub/sub becomes an amplifier.

One more beginner question: isn't pub/sub itself a single point of failure? Yes. If Redis dies, all cross-instance broadcast stops. So Redis needs high availability (primary-replica plus sentinel, or a managed cloud version) — its SLA is your realtime SLA. Don't cheap out on Redis: when it dies, your "realtime" degrades into "everyone only sees messages from their own instance," and that half-broken state is harder to debug than a clean outage.

Realtime multi-instance architecture diagram Figure: realtime multi-instance architecture. Clients connect to app instances through an auth gateway; each instance keeps only its local connection table, cross-instance messages go through the Redis Pub/Sub bus, and breakpoint messages land in a Redis Stream for reconnect resume. On a single machine you can drop the right half — but from day one, keep "publish to the bus" and "push to local connections" as two separate functions. The day your second instance goes live, you'll thank yourself.

7. The launch checklist: a connection-count cost model + 8 acceptance tests

Before shipping realtime, do the accounting. First a counterintuitive conclusion: the most expensive part of realtime is usually not the servers — it's "connection count" as a billing dimension. Traditional HTTP bills per request; idle time is free. Realtime connections are held 24/7 — 1,000 idle connections at 3am cost the same on a managed bill as 1,000 actively chatting ones. Design time is when you ask: which connections genuinely need realtime? Can idle users downgrade to SSE or just disconnect?

The connection-count cost model comes in two flavors:

Managed (Supabase Realtime / Pusher / Ably style metered billing):

monthly cost ≈ peak concurrent connections × per-connection price + monthly messages × per-message price

Note peak, not average — the hour of your marketing push or livestream premiere sets your billing tier. Load-test the peak first, then pick the tier — not the other way around.

Self-hosted:

connections per instance ≈ available memory / memory per connection (measure it yourself; rule of thumb 10k–50k per instance)
monthly cost = instances needed × instance price + Redis cost

Measure memory-per-connection yourself: an idle connection and one with subscriptions plus presence can differ by an order of magnitude. Don't use numbers from the internet — those are someone else's workload.

One verdict: under 10k DAU, prefer managed or single-instance self-hosting; don't build multi-instance early. 80% of realtime complexity lives in "multi-instance + auth + reconnects"; on a single instance you just need to get it right, and the other 20% is load testing. Spinning up Redis clusters and global edge early is the classic "résumé-driven architecture" of vibe projects.

8 launch acceptance tests (no ship until all pass):

  1. Adverse-network test. Chrome DevTools throttling + toggling airplane mode 10 times: zero lost messages, ordering intact.
  2. Reconnect. Kill the connection; it must recover within 30 seconds, with last_msg_id resume — no loss, no duplicates (or dedup in place).
  3. Connection storm. Restart the server; N clients reconnect at once — backoff jitter holds, gateway CPU stays flat.
  4. Zombie cleanup. kill -9 the client process; the server must reap the connection within 60 seconds and the connection-count metric must fall back.
  5. Cross-room subscription. User A's token subscribing to B's room must be rejected; enumerating room-1 through room-100 must fail entirely.
  6. Peak load test. Push to 2× expected peak connections; p99 push latency meets target, memory growth converges (no leaks).
  7. Monitoring & alerts. Connection count, message p99 latency, and heartbeat-timeout rate go into alerting, thresholds set from load-test data.
  8. Kill switch. A one-click fallback to SSE / polling, rehearsed once — on the day things break, you won't have time to write a fallback.

Load-testing a realtime system takes 20 lines with k6's ws module:

import ws from 'k6/ws'
export default function () {
  ws.connect('wss://api.example.com/ws?channel=test&token=__TOKEN__', {}, (socket) => {
    socket.on('message', () => {})
    socket.setInterval(() => socket.ping(), 25000)
  })
}
// k6 run --vus 2000 --duration 5m script.js: watch memory and p99 latency at 2000 concurrent

Starting alert thresholds: connection-count alert at 70% of your load-test ceiling; message p99 latency alert at 500ms; heartbeat-timeout-rate alert at 1%. Set all three from load-test results, revisit after two weeks — alert thresholds without load-test data are just guesses.

How do you build the kill switch? A feature flag (realtime.enabled): when client connection failures cross a threshold — or someone flips the switch manually — fall back to SSE / polling. The critical detail: instrument the degraded state, or you'll never know "half our users were on polling last night." A fallback's value isn't "it works," it's "it takes effect in 10 seconds when things break."

Rapid-fire: five soul-searching questions about realtime

1. "I'm on Supabase / Firebase — do I still need all this?"
Managed services handle multi-instance broadcast and connection management for you, but auth, reconnect backfill, and presence throttling are still yours. Managed isn't a get-out-of-jail card — it trades 80% of ops for 20% of business logic, and that remaining 20% is what decides the experience.

2. "In the HTTP/2 and HTTP/3 era, is SSE obsolete?"
No. SSE multiplexes even better over HTTP/2. WebTransport is the future, but in 2026 its proxy traversal and browser coverage aren't solid enough — vibe projects shouldn't be guinea pigs. Switch when it becomes the no-brainer default.

3. "How do I guarantee message ordering?"
Within one channel: server-side monotonically increasing IDs, client sorts and dedups by ID. Across channels: no guarantees — if your business needs global ordering, your channel split is wrong; go back to section 3.

4. "What about mobile apps in the background?"
Background connections are doomed; don't fight it. The correct posture: disconnect deliberately on backgrounding with last_msg_id recorded, reconnect and backfill on foregrounding; important messages fall back to push notifications (APNs / FCM). Realtime connections exist only in the foreground — that's a design constraint, not a bug.

5. "For AI chat streaming, WebSocket or SSE?"
SSE. One-way, native browser reconnects, and structurally identical to an LLM's token stream. Unless your chat needs realtime client uplink (like voice interruption), skip WebSocket — don't use a sledgehammer to crack a nut, especially one you have to sharpen yourself.

Back to that bill from the opening. If you follow this guide — decide by the deciding lines instead of whatever AI handed you, enforce auth at the channel rather than the connection, reconnect with a breakpoint ID, broadcast only presence diffs, and have pub/sub ready for multi-instance day — then your realtime feature will fetch its missed messages by itself after dropping offline in the subway. That's what "realtime" should mean: not "fast," but "it comes back."

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Studio microphone close-up representing voice feature integration: capture, transcription, synthesis and realtime dialogue for vibe projects
Guide
Give Your Product a Voice: Voice Feature Integration for Vibe Projects

Voice demos are easy; shippable voice features are pipeline engineering. This guide covers picking the right of three shapes (STT/TTS/realtime), the 400ms latency budget split across capture→STT→LLM→TTS→playback, Whisper vs streaming STT, the TTS quality/latency/cost triangle, interruption and turn state machines, five browser pitfalls, cost modeling with anti-abuse, and a 10-item launch checklist.

Tool TipsBackend EngineeringProject Building
A padlock on a laptop keyboard, symbolizing web security protection
Guide
Don't Ship Your Vibe Project Naked: A Web Security Baseline Playbook (CSP, CSRF, XSS, SQL Injection)

AI writes code fast but never writes secure code by default: running locally is not the same as being safe in production. This hands-on playbook fills the web security baseline for your vibe project — CSP headers, CSRF tokens, XSS defense, SQL parameterization, the cookie triple, dependency supply-chain gates, plus a copy-ready 10-item pre-launch acceptance checklist.

Backend EngineeringIndie DevelopmentFrontend Engineering