JSON Realtime APIs: SSE vs WebSocket vs Long Polling — Format & Protocol Guide

Last updated:

Choosing the right protocol for a realtime JSON API is the first architectural decision, and it affects message format, client code, infrastructure, and scaling strategy. Server-Sent Events (SSE) uses text/event-stream over plain HTTP — one-way, auto-reconnecting, works through every proxy. WebSocket upgrades HTTP to a full-duplex persistent channel — bidirectional, lowest latency, requires custom message framing. Long Polling holds an HTTP request open until the server has data — maximum compatibility, highest per-event overhead. This guide covers the JSON wire format for each protocol, event schema design for forward compatibility, SSE implementation in Next.js App Router with streaming, JSON delta updates using JSON Patch for bandwidth efficiency, and scaling across multiple servers with Redis Pub/Sub and Redis Streams.

Protocol Comparison for Realtime JSON

SSE, WebSocket, and Long Polling serve different use cases and have different JSON formatting constraints. The choice is not about performance alone — it is about data flow direction, infrastructure constraints, and how much connection-lifecycle complexity you can accept.

// ── Decision Matrix ───────────────────────────────────────────────────
//
// Criterion              SSE              WebSocket        Long Polling
// ──────────────────────────────────────────────────────────────────────
// Data direction         Server → Client  Bidirectional    Server → Client
// Protocol               HTTP/1.1, HTTP/2 WS (after upgrade) HTTP
// JSON format            text/event-stream JSON frames      JSON response body
// Auto-reconnect         YES (browser)    NO (manual)      NO (manual)
// Proxy/CDN support      Excellent        Good (needs cfg)  Excellent
// HTTP/1.1 conn limit    6 per origin     No limit          6 per origin
// HTTP/2 conn limit      Unlimited        N/A               N/A
// Latency                Low (~50ms)      Very low (~10ms)  Medium (~200ms)
// Server complexity      Low              Medium            Medium
// Client complexity      Low (EventSource) Medium (WebSocket) High (poll loop)
//
// ── When to use each ──────────────────────────────────────────────────
//
// SSE: notification feeds, live dashboards, AI token streaming,
//      server logs, price tickers where client only reads
//
// WebSocket: chat, collaborative editing, multiplayer games,
//            trading terminals where client sends and receives JSON
//
// Long Polling: environments blocking WebSocket/SSE, legacy systems,
//               very low frequency updates (< 1 event/minute)

// ── SSE wire format (text/event-stream) ──────────────────────────────
// Each event = field lines + blank terminator line
// JSON must be minified (no raw newlines in data: value)
//
// id: 42
// retry: 3000
// event: price_update
// data: {"symbol":"AAPL","price":189.34,"ts":1708956000000}
//
// (blank line ends the event)

// ── WebSocket JSON frame ──────────────────────────────────────────────
// No built-in framing — design your own envelope:
const wsMessage = {
  type: 'price_update',        // message kind
  payload: {                   // message-specific data
    symbol: 'AAPL',
    price: 189.34,
    ts: 1708956000000,
  },
  id: 'evt-001',               // correlation id (null for push events)
  timestamp: Date.now(),       // sender timestamp for ordering
}
// wire: JSON.stringify(wsMessage) — sent as text frame

// ── Long Polling JSON response ────────────────────────────────────────
// Client: GET /api/poll?lastEventId=41&timeout=30
// Server: holds request, responds when event arrives or timeout expires
const pollResponse = {
  events: [
    { id: 42, type: 'price_update', data: { symbol: 'AAPL', price: 189.34 } },
  ],
  nextPollAfterMs: 0,          // 0 = poll immediately, >0 = backoff hint
}
// Client reads response, updates lastEventId, immediately re-polls

The JSON format differs by protocol because of the transport constraints. SSE encodes JSON as a single-line text field. WebSocket sends raw JSON bytes as a text or binary frame with no protocol overhead. Long Polling wraps one or more JSON events in a response envelope with pagination state. For most new applications serving browser clients with streaming data, SSE on HTTP/2 is the right default — it eliminates the connection limit, works through all proxies, and requires no custom reconnect logic.

Server-Sent Events JSON Format

The SSE wire format is defined by the WHATWG SSE specification and consists of UTF-8 text lines. Four field names are defined: data, event, id, and retry. Everything else is a comment (lines starting with :). Understanding the format precisely is critical for building reliable SSE streams — a missing blank line silently drops events.

// ── SSE field reference ───────────────────────────────────────────────
//
// data: <string>   — the payload; repeat field for multi-line data
// event: <string>  — custom event type (omit for default 'message' event)
// id: <string>     — event identifier for Last-Event-ID reconnection
// retry: <number>  — reconnect delay in milliseconds (e.g. retry: 3000)
// : <comment>      — ignored by browser (used for keepalive pings)
//
// Each event MUST end with a blank line (

)

// ── Single JSON event (minified) ─────────────────────────────────────
function formatSseEvent(opts: {
  data: unknown
  event?: string
  id?: string | number
  retry?: number
}): string {
  const lines: string[] = []
  if (opts.id !== undefined)    lines.push(`id: ${opts.id}`)
  if (opts.retry !== undefined) lines.push(`retry: ${opts.retry}`)
  if (opts.event)               lines.push(`event: ${opts.event}`)
  // JSON.stringify produces a single-line string — safe to use in data:
  lines.push(`data: ${JSON.stringify(opts.data)}`)
  return lines.join('\n') + '\n\n'  // blank line terminates event
}

// Usage:
formatSseEvent({
  id: '42',
  retry: 3000,
  event: 'price_update',
  data: { symbol: 'AAPL', price: 189.34, ts: 1708956000000 },
})
// Produces:
// id: 42
retry: 3000
event: price_update
data: {"symbol":"AAPL","price":189.34,"ts":1708956000000}



// ── Multi-line JSON via repeated data: fields ─────────────────────────
// Browser joins multiple data: lines with 
 (newline character)
// Result: "line1
line2" — valid JSON only if minified before splitting
//
// For large JSON, always minify first (JSON.stringify with no indent),
// then send as a single data: line. Multi-line data: is for text,
// not for pretty-printed JSON.

// ── Keepalive comment (prevents proxy timeout) ────────────────────────
// Proxies/load balancers close idle connections after ~60 seconds.
// Send a comment every 15–30 seconds to keep the connection alive:
const KEEPALIVE = ': keepalive\n\n'

// ── Client EventSource API ────────────────────────────────────────────
const es = new EventSource('/api/stream')

// Default 'message' event (no event: field on server)
es.onmessage = (e) => {
  const data = JSON.parse(e.data)
  console.log(data)
}

// Named event type (matches event: price_update on server)
es.addEventListener('price_update', (e) => {
  const { symbol, price, ts } = JSON.parse(e.data)
  updateChart(symbol, price, ts)
})

// Error / reconnection
es.onerror = (e) => {
  // Browser auto-reconnects using Last-Event-ID header
  // Server receives: GET /api/stream  Last-Event-ID: 42
  console.warn('SSE error, browser will reconnect', e)
}

// Reconnection uses Last-Event-ID: the browser sends the last
// received id value in the request header on reconnect.
// Server must replay any events with id > Last-Event-ID.

// ── Server: track Last-Event-ID for replay ────────────────────────────
export async function GET(request: Request) {
  const lastId = request.headers.get('last-event-id')
  const startFromId = lastId ? parseInt(lastId, 10) + 1 : 0

  // Replay buffered events since lastId before streaming new ones
  const missed = eventBuffer.filter(e => e.id >= startFromId)
  // ... stream missed events first, then new events
}

The id field is the most important field for production SSE — without it, clients that reconnect after a network blip will miss every event sent during the disconnection. The browser sends Last-Event-ID on every reconnect attempt, giving the server everything it needs to replay missed events. Keep a circular buffer of recent events on the server (e.g., last 100 events or last 60 seconds) for replay. For related content on streaming JSON data, see the JSON WebSockets guide.

WebSocket JSON Message Protocol

WebSocket provides a raw bidirectional channel with no built-in message semantics. You design the JSON protocol on top: message envelopes, type routing, request-response correlation, and heartbeat. Getting the envelope design right avoids protocol redesigns as your API grows.

// ── Message envelope schema ───────────────────────────────────────────
interface WsMessage<T = unknown> {
  type: string        // namespaced kind: 'market:subscribe', 'order:created'
  payload: T          // message-specific data (typed per message kind)
  id: string | null   // UUID for request-response; null for push events
  timestamp: number   // sender Unix ms — for latency measurement & ordering
}

// ── Request from client ───────────────────────────────────────────────
const subscribeRequest: WsMessage = {
  type: 'market:subscribe',
  payload: { symbol: 'AAPL', interval: '1s' },
  id: crypto.randomUUID(),   // client generates a unique id per request
  timestamp: Date.now(),
}
ws.send(JSON.stringify(subscribeRequest))

// ── Correlated response from server ───────────────────────────────────
// Server echoes the same id back — client matches response to request
const subscribeResponse: WsMessage = {
  type: 'market:subscribed',
  payload: { symbol: 'AAPL', status: 'ok', nextEvent: 'price_update' },
  id: subscribeRequest.id,   // same id as the request
  timestamp: Date.now(),
}

// ── Push event from server (no request correlation) ───────────────────
const priceEvent: WsMessage = {
  type: 'market:price_update',
  payload: { symbol: 'AAPL', price: 189.34, volume: 15234 },
  id: null,                  // null = unsolicited push, no correlation
  timestamp: Date.now(),
}

// ── Client: pending request map for response correlation ─────────────
type Resolver = (response: WsMessage) => void
const pending = new Map<string, Resolver>()

function sendRequest<T>(ws: WebSocket, msg: WsMessage): Promise<WsMessage<T>> {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      pending.delete(msg.id!)
      reject(new Error(`Request ${msg.id} timed out`))
    }, 5000)

    pending.set(msg.id!, (response) => {
      clearTimeout(timeout)
      resolve(response as WsMessage<T>)
    })
    ws.send(JSON.stringify(msg))
  })
}

ws.onmessage = (e) => {
  const msg: WsMessage = JSON.parse(e.data)

  if (msg.id && pending.has(msg.id)) {
    // Correlated response — resolve the pending promise
    pending.get(msg.id)!(msg)
    pending.delete(msg.id)
  } else {
    // Push event — route by type
    eventEmitter.emit(msg.type, msg.payload)
  }
}

// ── Heartbeat: ping/pong to detect dead connections ───────────────────
// WebSocket spec has native ping/pong control frames (handled by the
// browser and server automatically), but many frameworks also implement
// an application-level ping via JSON for cross-environment compatibility.

let heartbeatTimer: ReturnType<typeof setInterval>
let pongReceived = true

function startHeartbeat(ws: WebSocket) {
  heartbeatTimer = setInterval(() => {
    if (!pongReceived) {
      ws.close(1001, 'heartbeat timeout')
      return
    }
    pongReceived = false
    ws.send(JSON.stringify({ type: 'ping', payload: null, id: null, timestamp: Date.now() }))
  }, 25_000)  // send ping every 25 seconds
}

ws.addEventListener('message', (e) => {
  const msg: WsMessage = JSON.parse(e.data)
  if (msg.type === 'pong') { pongReceived = true; return }
  // ... handle other messages
})

// ── Large JSON payloads: chunked messages ─────────────────────────────
// WebSocket data frames have no size limit but network layers may
// fragment large frames. For JSON payloads > 1MB, split into chunks:
interface ChunkedMessage {
  type: 'chunk'
  chunkId: string    // shared across all chunks of one payload
  index: number      // 0-based chunk index
  total: number      // total number of chunks
  data: string       // base64 or string segment of the full payload
}
// Receiver reassembles chunks by chunkId, orders by index, joins data.

The correlation-by-id pattern converts WebSocket's fire-and-forget message passing into a promise-based request-response API without sacrificing the low-latency connection. The pending request map with timeouts prevents memory leaks from unanswered requests. Namespace the type field from day one — adding namespaces later requires a coordinated client/server update. For WebSocket-specific JSON patterns in the browser, see the JSON WebSocket guide.

JSON Event Schema for Realtime Streams

A well-designed event schema enables forward compatibility — new event types and fields can be added without breaking existing clients. The key principles: always include a version field, use a discriminated union on the type field, and never remove or rename fields in a published event type.

// ── Core event types for any realtime stream ─────────────────────────
type CoreEventType =
  | 'connected'    // sent immediately on connection; includes session info
  | 'data'         // carries domain-specific payload
  | 'error'        // server-side error the client should handle
  | 'close'        // server is intentionally closing the stream

// ── Versioned event envelope ──────────────────────────────────────────
interface StreamEvent<T = unknown> {
  v: 1                  // schema version — increment when shape changes
  type: CoreEventType | string  // extensible with domain types
  payload: T
  seq: number           // monotonically increasing sequence number per session
  ts: number            // server Unix ms
  sessionId: string     // identifies the connection for debugging
}

// ── 'connected' event — sent on every (re)connection ──────────────────
const connectedEvent: StreamEvent = {
  v: 1,
  type: 'connected',
  payload: {
    sessionId: 'sess_xK9mP2',
    serverTime: Date.now(),
    resumeFromSeq: null,   // null = fresh connection; number = resumed
  },
  seq: 0,
  ts: Date.now(),
  sessionId: 'sess_xK9mP2',
}

// ── 'data' event — domain payload wrapped in envelope ─────────────────
const priceEvent: StreamEvent<{ symbol: string; price: number }> = {
  v: 1,
  type: 'market:price_update',
  payload: { symbol: 'AAPL', price: 189.34 },
  seq: 47,
  ts: Date.now(),
  sessionId: 'sess_xK9mP2',
}

// ── 'error' event — recoverable vs fatal errors ───────────────────────
const errorEvent: StreamEvent = {
  v: 1,
  type: 'error',
  payload: {
    code: 'RATE_LIMITED',         // machine-readable error code
    message: 'Too many events',   // human-readable description
    retryAfterMs: 5000,           // client should wait before action
    fatal: false,                 // false = recoverable; true = close stream
  },
  seq: 48,
  ts: Date.now(),
  sessionId: 'sess_xK9mP2',
}

// ── Backwards compatibility rules ─────────────────────────────────────
//
// SAFE (non-breaking):
//   - Add new optional fields to an existing event type
//   - Add new event types (clients ignore unknown types)
//   - Add new values to an enum field (clients handle 'unknown')
//
// BREAKING (requires version bump):
//   - Remove or rename an existing field
//   - Change a field type (string -> number)
//   - Change the semantic meaning of an existing field
//
// Client defensive pattern: always handle unknown type values
ws.onmessage = (e) => {
  const event: StreamEvent = JSON.parse(e.data)

  switch (event.type) {
    case 'connected':   handleConnected(event.payload); break
    case 'data':        handleData(event.payload); break
    case 'error':       handleError(event.payload); break
    case 'close':       handleClose(event.payload); break
    default:
      // New event type from a newer server version — ignore gracefully
      console.debug('Unknown event type:', event.type)
  }
}

// ── Sequence number gap detection ─────────────────────────────────────
let lastSeq = -1

function processEvent(event: StreamEvent) {
  if (lastSeq >= 0 && event.seq !== lastSeq + 1) {
    // Gap detected — request a full snapshot
    console.warn(`Seq gap: expected ${lastSeq + 1}, got ${event.seq}`)
    requestSnapshot()
    return
  }
  lastSeq = event.seq
  // ... process normally
}

The sequence number is the most important field for reliable clients. It lets the client detect missed events, request a fresh snapshot, and apply delta updates in the correct order. Without sequence numbers, a client that misses one delta update gets silently out of sync. The connected event should always include the sequence number the server is starting from so the client knows if it is resuming a session or starting fresh. For general JSON schema patterns including versioning strategies, see the linked guide.

Implementing SSE in Next.js App Router

Next.js App Router Route Handlers return a Response object directly, making it straightforward to return a streaming ReadableStream body with Content-Type: text/event-stream. Both Edge Runtime and Node.js Runtime support streaming.

// app/api/stream/route.ts
// Edge Runtime: lowest latency, global deployment, no cold starts
export const runtime = 'edge'

const encoder = new TextEncoder()

function sse(opts: {
  data: unknown
  event?: string
  id?: string | number
  comment?: string
}): Uint8Array {
  const lines: string[] = []
  if (opts.comment)           lines.push(`: ${opts.comment}`)
  if (opts.id !== undefined)  lines.push(`id: ${opts.id}`)
  if (opts.event)             lines.push(`event: ${opts.event}`)
  lines.push(`data: ${JSON.stringify(opts.data)}`)
  return encoder.encode(lines.join('\n') + '\n\n')
}

export async function GET(request: Request) {
  const lastEventId = request.headers.get('last-event-id')
  const startSeq = lastEventId ? parseInt(lastEventId, 10) + 1 : 0

  const stream = new ReadableStream({
    async start(controller) {
      // 1. Send 'connected' event immediately
      controller.enqueue(
        sse({ event: 'connected', id: startSeq - 1,
              data: { sessionId: crypto.randomUUID(), ts: Date.now() } })
      )

      // 2. Replay any buffered events the client missed
      const missed = getBufferedEvents(startSeq)
      for (const event of missed) {
        controller.enqueue(sse({ event: event.type, id: event.seq, data: event.payload }))
      }

      // 3. Subscribe to new events
      let seq = missed.length > 0 ? missed[missed.length - 1].seq + 1 : startSeq
      const unsubscribe = subscribeToEvents((event) => {
        if (request.signal.aborted) return
        controller.enqueue(sse({ event: event.type, id: seq++, data: event.payload }))
      })

      // 4. Keepalive ping every 20 seconds to prevent proxy timeout
      const keepalive = setInterval(() => {
        if (request.signal.aborted) { clearInterval(keepalive); return }
        controller.enqueue(encoder.encode(': keepalive\n\n'))
      }, 20_000)

      // 5. Clean up when client disconnects
      request.signal.addEventListener('abort', () => {
        clearInterval(keepalive)
        unsubscribe()
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
      // Allow EventSource from any same-origin fetch:
      'X-Accel-Buffering': 'no',  // disable Nginx response buffering
    },
  })
}

// ── Client: EventSource with reconnection tracking ────────────────────
// app/components/RealtimePrice.tsx
'use client'
import { useEffect, useState } from 'react'

export function RealtimePrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null)
  const [connected, setConnected] = useState(false)

  useEffect(() => {
    const es = new EventSource(`/api/stream?symbol=${symbol}`)

    es.addEventListener('connected', () => setConnected(true))

    es.addEventListener('price_update', (e) => {
      const { price } = JSON.parse(e.data)
      setPrice(price)
    })

    es.onerror = () => {
      setConnected(false)
      // Browser auto-reconnects with Last-Event-ID header
    }

    return () => es.close()
  }, [symbol])

  return (
    <div>
      <span>{connected ? '● Live' : '○ Connecting...'}</span>
      <span>{price !== null ? `$${price.toFixed(2)}` : '—'}</span>
    </div>
  )
}

// ── Vercel-specific: streaming on Vercel Edge Functions ───────────────
// Vercel supports streaming ReadableStream responses natively.
// Set maxDuration in vercel.json for long-lived connections:
// { "functions": { "app/api/stream/route.ts": { "maxDuration": 300 } } }
// Edge functions can run up to 30s (Hobby) or 300s (Pro/Enterprise).

The X-Accel-Buffering: no header is critical when deploying behind Nginx — without it, Nginx buffers the response and clients do not receive events until the buffer fills. Vercel handles this automatically for Edge Runtime functions. For Node.js Runtime, set Transfer-Encoding: chunked explicitly if your framework does not set it. For JSON caching strategies to use alongside streaming APIs for non-realtime data, see the linked guide.

JSON Delta Updates for Realtime Efficiency

Sending the full JSON object on every update is wasteful when only a small fraction of fields change. Delta updates — sending only changed fields — reduce realtime bandwidth by 80–90% for large objects. JSON Patch (RFC 6902) is the standard format for structured deltas; plain diff objects work for simpler cases.

// ── Approach 1: Plain diff object (simplest) ──────────────────────────
// Server detects changed fields and sends only those
interface DeltaEvent {
  type: 'delta'
  seq: number
  patch: Record<string, unknown>  // only changed top-level fields
}

// Previous state: { status: 'active', price: 100, volume: 5000, name: 'AAPL' }
// New state:      { status: 'active', price: 105, volume: 5200, name: 'AAPL' }
// Delta:          { price: 105, volume: 5200 }

const deltaEvent: DeltaEvent = {
  type: 'delta',
  seq: 48,
  patch: { price: 105, volume: 5200 },
}
// vs full event: { status: 'active', price: 105, volume: 5200, name: 'AAPL', ...30 more fields }
// Savings: ~90% if only 2 of 32 fields changed

// Client: merge delta into local state
let localState = initialSnapshot
es.addEventListener('delta', (e) => {
  const { seq, patch } = JSON.parse(e.data)
  if (seq !== localState.seq + 1) { requestSnapshot(); return }
  localState = { ...localState, ...patch, seq }
  render(localState)
})

// ── Approach 2: JSON Patch (RFC 6902) — standard format ───────────────
// JSON Patch is an array of operation objects targeting nested paths.
// Operations: add, remove, replace, move, copy, test
// Path is a JSON Pointer (RFC 6901): /key or /outer/inner or /array/0

import * as jsonpatch from 'fast-json-patch'  // npm install fast-json-patch

// Server: generate patch by diffing previous vs new state
const previous = { user: { status: 'away', score: 80 }, tags: ['a', 'b'] }
const next     = { user: { status: 'online', score: 80 }, tags: ['a', 'b', 'c'] }

const patch = jsonpatch.compare(previous, next)
// [
//   { op: 'replace', path: '/user/status', value: 'online' },
//   { op: 'add', path: '/tags/2', value: 'c' },
// ]

const patchEvent = {
  type: 'json_patch',
  seq: 49,
  patch,   // RFC 6902 patch array
}

// Client: apply patch to local state
import * as jsonpatch from 'fast-json-patch'

es.addEventListener('json_patch', (e) => {
  const { seq, patch } = JSON.parse(e.data)
  if (seq !== localState.seq + 1) { requestSnapshot(); return }
  const result = jsonpatch.applyPatch(localState, patch, true, false)
  localState = { ...result.newDocument, seq }
  render(localState)
})

// ── Sequence numbers for delta ordering ───────────────────────────────
// Deltas MUST be applied in sequence order.
// If seq 50 arrives before seq 49, buffer seq 50 and wait:

const pendingDeltas = new Map<number, DeltaEvent>()

function applyOrBuffer(event: DeltaEvent) {
  const expected = localState.seq + 1

  if (event.seq === expected) {
    apply(event)
    // Apply any buffered deltas that are now in sequence
    while (pendingDeltas.has(localState.seq + 1)) {
      apply(pendingDeltas.get(localState.seq + 1)!)
      pendingDeltas.delete(localState.seq)
    }
  } else if (event.seq > expected) {
    pendingDeltas.set(event.seq, event)
    if (pendingDeltas.size > 50) {
      // Too many buffered deltas — gap is too large, request snapshot
      requestSnapshot()
    }
  }
  // seq < expected: duplicate — ignore
}

JSON Patch is more verbose than a plain diff object but supports nested paths, array mutations (add to a specific index), and the test operation for optimistic concurrency control — the server can include a test operation that verifies the client's current state before applying subsequent operations, preventing divergence. Use JSON Patch when your state includes arrays or deeply nested structures. Use plain diff objects for flat top-level state. Always include sequence numbers — without them, delta updates applied out of order will corrupt the client state. For related JSON realtime sync patterns including conflict resolution, see the linked guide.

Scaling Realtime JSON APIs

A single server process can handle tens of thousands of SSE or WebSocket connections, but horizontal scaling across multiple server instances requires a message broker so that a client connected to server A receives events published by server B. Redis Pub/Sub is the standard solution; Redis Streams extends it with replay guarantees.

// ── Redis Pub/Sub fan-out pattern ─────────────────────────────────────
// All server instances subscribe to Redis channels.
// Any server can publish; all subscribers receive and fan out locally.

import { createClient } from 'redis'  // npm install redis

// Publisher (can run on any server instance)
const publisher = createClient({ url: process.env.REDIS_URL })
await publisher.connect()

async function publishEvent(channel: string, event: unknown) {
  await publisher.publish(channel, JSON.stringify(event))
}

// Example: publish a price update to the AAPL channel
await publishEvent('market:AAPL', {
  type: 'price_update',
  payload: { symbol: 'AAPL', price: 189.34 },
  ts: Date.now(),
})

// Subscriber (runs on each server instance, one per instance)
const subscriber = createClient({ url: process.env.REDIS_URL })
await subscriber.connect()

// Local registry: channel -> Set of SSE/WebSocket send functions
const localClients = new Map<string, Set<(event: string) => void>>()

await subscriber.pSubscribe('market:*', (message, channel) => {
  // Fan out to all local clients subscribed to this channel
  const clients = localClients.get(channel)
  if (!clients) return
  for (const send of clients) {
    send(message)  // message is already JSON.stringify'd
  }
})

// SSE Route Handler: register/unregister local client
export async function GET(request: Request) {
  const symbol = new URL(request.url).searchParams.get('symbol') ?? 'AAPL'
  const channel = `market:${symbol}`

  const stream = new ReadableStream({
    start(controller) {
      const enc = new TextEncoder()
      const send = (message: string) => {
        // message is already JSON — wrap in SSE format
        controller.enqueue(enc.encode(`data: ${message}\n\n`))
      }

      // Register with local fan-out
      if (!localClients.has(channel)) localClients.set(channel, new Set())
      localClients.get(channel)!.add(send)

      request.signal.addEventListener('abort', () => {
        localClients.get(channel)?.delete(send)
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  })
}

// ── Redis Pub/Sub limitation: at-most-once delivery ───────────────────
// If a subscriber is slow or temporarily disconnected, messages are dropped.
// Redis does NOT buffer messages for offline subscribers.

// ── Redis Streams: at-least-once with replay ──────────────────────────
// XADD appends to a stream. XREAD reads from a last-seen ID.
// On reconnect, client provides last-seen ID to replay missed events.

// Producer: append event to stream
await publisher.xAdd('market:AAPL', '*', {
  type: 'price_update',
  payload: JSON.stringify({ symbol: 'AAPL', price: 189.34 }),
  ts: String(Date.now()),
})
// '*' = auto-generate stream entry ID (millisecond timestamp + sequence)

// Consumer: read new events since last seen ID
const lastId = '0'  // '0' = from beginning; use last received ID for resume
const entries = await subscriber.xRead(
  [{ key: 'market:AAPL', id: lastId }],
  { COUNT: 100, BLOCK: 5000 }  // block up to 5s waiting for new entries
)
// entries: [{ name: 'market:AAPL', messages: [{ id: '...', message: {...} }] }]

// ── Ably / Pusher: managed realtime JSON infrastructure ───────────────
// Ably and Pusher handle connection management, fan-out, and history.
// Publish a JSON payload to a channel via their REST API:
//
// Ably REST publish:
// POST https://rest.ably.io/channels/market:AAPL/messages
// { "name": "price_update", "data": { "symbol": "AAPL", "price": 189.34 } }
//
// Pusher trigger:
// pusher.trigger('market-AAPL', 'price_update', { symbol: 'AAPL', price: 189.34 })
//
// Both services enforce JSON payload size limits:
// Ably: 64KB per message (configurable to 256KB on enterprise)
// Pusher: 10KB per event payload

Redis Pub/Sub is appropriate when you can tolerate occasional missed messages — for example, a live price ticker where the next update arrives within a second anyway. Use Redis Streams when missed messages matter: collaborative editing, chat messages, order status updates. With Redis Streams, the stream entry ID is the sequence number — clients store the last received ID and replay from there on reconnect. Connection limit planning: each SSE connection holds an HTTP response open; each WebSocket holds a TCP socket. A Node.js process can handle 50,000–100,000 concurrent connections before hitting OS file descriptor limits (ulimit -n). Scale horizontally before hitting that ceiling, not after.

Key Terms

Server-Sent Events (SSE)
A browser API and wire protocol (defined in the WHATWG HTML standard) for receiving a unidirectional stream of events from a server over HTTP. The server sets Content-Type: text/event-stream and sends newline-delimited text fields. Each event contains one or more data: lines (joined with newlines if repeated), an optional event: type name, an optional id: for reconnection tracking, and an optional retry: value in milliseconds. Events are terminated by a blank line. The browser exposes SSE via the EventSource API, which handles reconnection automatically using the Last-Event-ID request header. In HTTP/1.1, browsers limit SSE connections to 6 per origin. In HTTP/2, this limit does not apply. SSE is one-way — the client cannot send data over the SSE connection itself.
WebSocket
A bidirectional, full-duplex communication protocol (RFC 6455) that upgrades an HTTP connection to a persistent TCP channel. The handshake starts as an HTTP request with Upgrade: websocket and Connection: Upgrade headers; on success, the connection switches to the WebSocket framing protocol. Both client and server can send frames at any time. Frames have a 2–10 byte header with an opcode (text, binary, ping, pong, close) and a payload length. Text frames carry UTF-8 strings; binary frames carry raw bytes. Control frames (ping, pong, close) are limited to 125 bytes of payload. Data frames have no protocol size limit. WebSocket requires custom reconnect logic, heartbeat (ping/pong), and message framing. It is supported in all modern browsers via the WebSocket API.
Long Polling
A technique for pushing data to clients over plain HTTP by holding the server response open until an event is available or a timeout expires. The client sends an HTTP request; if no event is available, the server waits (holds the request) until an event occurs, then responds with the event data as a JSON body. Immediately after receiving the response, the client sends a new request. This creates a near-continuous connection through standard HTTP request-response cycles. Long Polling works in all HTTP environments including those that block WebSocket and SSE, but has higher per-event latency (one round-trip per event) and higher server overhead (one thread or async context per waiting client) than SSE or WebSocket. It is appropriate as a fallback for environments where SSE and WebSocket are unavailable.
Event Envelope
A JSON object wrapping a realtime event with standardized metadata fields alongside the domain-specific payload. A typical envelope includes: type (a string identifying the event kind, used for routing on the client), payload (the event-specific data object), id or seq (a sequence number for ordering and gap detection), and ts (server timestamp in Unix milliseconds). The envelope separates transport concerns (sequencing, routing, versioning) from business data (the payload). Using a consistent envelope across all event types allows generic client infrastructure — connection management, gap detection, delta application — to work regardless of the specific event payload. The type field is typically a namespaced string (e.g., market:price_update) to prevent collisions as the event schema grows.
JSON Delta Update
A realtime message that carries only the fields that changed since the last update, rather than the full JSON object. Delta updates reduce bandwidth by 80–90% for large objects where only a few fields change per event. Two formats are common: a plain diff object (a flat JSON object containing only the changed top-level keys and their new values, merged into local state with Object.assign or spread) and JSON Patch (RFC 6902, an array of operation objects — add, remove, replace, move, copy, test — targeting specific paths via JSON Pointer). JSON Patch supports nested fields and array mutations that plain diff objects cannot express. Clients must apply deltas in sequence-number order and request a full snapshot if a gap is detected.
Last-Event-ID
An HTTP request header sent automatically by the browser's EventSource API on every reconnection attempt after a connection drop. Its value is the id field of the last SSE event successfully received by the client. The server reads this header and replays any events with a higher id from its event buffer, ensuring the client does not miss events that were published during the disconnection window. Servers must maintain a circular buffer of recent events (e.g., last 100 events or last 60 seconds of events) to support replay. If no id: fields are sent by the server, the browser does not send Last-Event-ID on reconnect. The header name is Last-Event-ID (hyphenated, not camelCase).
Redis Pub/Sub
A messaging pattern in Redis where publishers send messages to named channels and subscribers receive those messages in real time. Redis delivers each message to all current subscribers on the channel at the moment of publish — delivery is at-most-once. If a subscriber is disconnected or slow at the moment of publish, the message is dropped with no retry or buffering. Pub/Sub is used to fan out realtime JSON events across multiple server instances: each server subscribes to Redis channels for the data its clients need, and any server that receives a new event publishes to Redis so all servers fan it out to their local connections. Pub/Sub is not appropriate when message delivery guarantees are required — use Redis Streams for at-least-once delivery with replay.
Redis Streams
A persistent, append-only log data structure in Redis (added in Redis 5.0) designed for at-least-once message delivery with replay. Producers append messages with XADD; each message gets an auto-generated ID consisting of a millisecond timestamp and a sequence counter. Consumers read with XREAD, specifying the last-seen ID to receive only newer messages — or 0 to read from the beginning. On reconnect after a client drop, the client provides its last-seen stream entry ID to replay all missed messages. Redis Streams also support consumer groups (via XGROUP) for load-balanced processing where each message is delivered to exactly one consumer in a group. Streams retain messages until explicitly trimmed with XTRIM. Redis Streams are the correct choice for realtime JSON fan-out when missed messages are unacceptable (chat, order updates, collaborative editing).

FAQ

Should I use Server-Sent Events or WebSockets for a realtime JSON API?

Use SSE when data flows one way — server to client. SSE works through all HTTP proxies, auto-reconnects with Last-Event-ID, requires no custom reconnect logic, and has no connection limit in HTTP/2. Use WebSocket when you need bidirectional communication — chat, collaborative editing, or a trading terminal where the client sends and receives JSON over the same persistent connection. WebSocket requires custom reconnect logic and heartbeat. Long Polling is a fallback for environments blocking SSE and WebSocket — it works everywhere HTTP works but adds one round-trip of latency per event. Decision: one-way streaming -> SSE on HTTP/2; bidirectional low-latency -> WebSocket; maximum compatibility -> Long Polling.

What is the JSON format for Server-Sent Events (SSE) messages?

SSE sends plain UTF-8 text with Content-Type: text/event-stream. Each event is a set of field lines followed by a mandatory blank line. To include a JSON payload, call JSON.stringify() on your object and place the result after data: — the entire JSON must be on one line (no literal newlines). A full SSE event looks like: id: 42\nretry: 3000\nevent: price_update\ndata: {"symbol":"AAPL","price":189.34}\n\n. The id: field enables reconnection replay via the Last-Event-ID header. The event: field routes the event to a named addEventListener handler on the client. Omit event: to use the default onmessage handler. The double trailing newline is mandatory. Send : keepalive\n\n (a comment line) every 20–30 seconds to prevent proxy timeouts.

How do I design a WebSocket JSON message protocol with request-response correlation?

Use a message envelope with four fields: type (namespaced string, e.g. market:subscribe), payload (message-specific data), id (a client-generated UUID for request-response correlation, or null for push events), and timestamp (Unix milliseconds). The client generates a unique id for each request and stores a callback in a Map<id, resolve>. When the server responds with the same id, the client resolves the promise. Push events from the server omit or null the id and are routed to event handlers by type. Implement heartbeat: send {"type":"ping"} every 25 seconds, expect {"type":"pong"} within 5 seconds, close and reconnect if not received.

How do I implement Server-Sent Events in Next.js App Router?

Create a Route Handler at app/api/stream/route.ts that exports a GET function returning a new Response(stream, headers) where stream is a ReadableStream. Inside the ReadableStream start callback, use a TextEncoder to enqueue SSE-formatted strings: encoder.encode(`id: 1\ndata: ${JSON.stringify(event)}\n\n`). Set headers: Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no (disables Nginx buffering). Listen to request.signal for the abort event to clean up subscriptions and close the controller when the client disconnects. Set export const runtime = 'edge' for lowest latency. On Vercel, set maxDuration: 300 in vercel.json for long-lived streaming connections.

How do I send only changed JSON fields (delta updates) in a realtime stream?

For flat objects, send a plain diff: compare previous state to new state, collect changed keys, send only those keys in the event payload. The client merges with Object.assign(local, delta) or spread. For nested objects and arrays, use JSON Patch (RFC 6902): use fast-json-patch to generate a patch array with jsonpatch.compare(previous, next), send it as the event payload, and apply on the client with jsonpatch.applyPatch(localState, patch). Always include a sequence number in every delta event. The client must apply deltas in order — buffer out-of-order deltas and apply them when the gap is filled. If more than 50 deltas are buffered (large gap), request a full snapshot. Delta updates reduce bandwidth by 80–90% for large objects with small per-event changes.

What is the concurrent connection limit for SSE and how do I work around it?

HTTP/1.1 browsers limit SSE connections to 6 per origin. The solutions in order of preference: (1) Deploy with HTTP/2 — SSE connections are multiplexed over a single TCP connection, removing the 6-connection limit entirely. Verify with browser DevTools Network tab — look for h2 in the Protocol column. (2) Multiplex multiple subscriptions over a single SSE connection — send different data on different named events using the event: field, with one EventSource using multiple addEventListener calls. (3) Use a SharedWorker — one SSE connection in the worker is shared across all same-origin tabs. (4) Switch to WebSocket — WebSocket connections are not subject to the HTTP/1.1 6-connection limit.

How do I scale a realtime JSON API to multiple servers using Redis Pub/Sub?

All server instances subscribe to Redis channels (e.g., market:AAPL) via a shared subscriber connection. When any server receives a new event (from an HTTP POST, database trigger, or background job), it publishes to Redis: redis.publish('market:AAPL', JSON.stringify(event)). Every subscribed server instance receives the message and fans it out to local SSE or WebSocket connections for clients on that channel. Use pSubscribe with glob patterns (e.g., market:*) to subscribe to all market channels with one subscription. Critical limitation: Redis Pub/Sub is at-most-once — messages published while a subscriber is disconnected are dropped. Use Redis Streams (XADD/XREAD) for at-least-once delivery with replay. Clients store the last stream entry ID and replay missed events on reconnect.

Further reading and primary sources