Siddhant Deval
Siddhant Deval
frontend5 min read

Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets

Developers reach for WebSockets reflexively for AI token streams, then spend weeks fighting proxy buffering, nginx timeout misconfigurations, and reconnection edge cases. This article benchmarks SSE vs chunked HTTP/2 vs WebSockets for unidirectional LLM token delivery and builds a resilient SSE client with deterministic state rehydration.

Series·Part 4 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets

The first instinct for real-time token delivery is WebSockets. It feels natural — bidirectional, persistent, real-time. Then production exposes the reality: nginx proxy buffering destroys the "real-time" experience, corporate firewalls block non-HTTP upgrade requests, and a WebSocket server managing 10,000 concurrent AI sessions requires significantly more engineering than the equivalent SSE server. Most of that complexity buys nothing for a unidirectional token stream.

This article benchmarks the three protocols, explains exactly why HTTP/2 SSE is the correct default for LLM token delivery, builds a resilient SSE client with deterministic rehydration, and wires the first end-to-end streaming path from the Workspace gateway to the React UI.

Architectural Note

Who this is for: Full-stack engineers who have shipped WebSocket-based AI chat and hit buffering or reconnection issues, or who have used fetch streaming but never handled partial-chunk boundaries or client reconnection correctly.


1. Protocol Comparison: SSE vs Fetch Streams vs WebSockets

1.1 The Decision Matrix

Criterion SSE (EventSource) Fetch ReadableStream WebSocket
Direction Server → Client Server → Client Bidirectional
HTTP/2 multiplexing ✅ Native ✅ Native ❌ Separate TCP upgrade
Proxy compatibility ✅ Standard HTTP ✅ Standard HTTP ⚠️ Requires proxy config
Buffering risk ⚠️ Needs X-Accel-Buffering: no ⚠️ Same ✅ Frame-based, no buffering
Auto-reconnect ✅ Built-in Last-Event-ID ❌ Manual ❌ Manual
Server state cost Stateless per-request Stateless per-request Stateful connection object
Event framing ✅ data: / event: / id: built-in ❌ Manual newline framing ❌ Manual framing
Correct choice for LLM tokens ✅ Yes ✅ Yes (when you need fine control) ❌ Overkill
Crucial Requirement

WebSockets are the right choice when the client also needs to send data mid-stream — for example, streaming audio input to a speech model while receiving text output simultaneously. For unidirectional LLM token delivery, SSE is simpler, more proxy-compatible, and requires zero additional server state.

1.2 The Proxy Buffering Problem

The single most common production failure with SSE is reverse proxy buffering. nginx, AWS ALB, and Cloudflare all buffer HTTP responses by default to optimise throughput. For SSE, this means the client receives zero tokens until the buffer fills — destroying the streaming experience.

NGINX
# nginx config — mandatory for SSE streaming
location /api/stream {
    proxy_pass http://gateway:3000;

    # Disable ALL response buffering for SSE routes
    proxy_buffering          off;
    proxy_cache              off;
    proxy_set_header         X-Accel-Buffering no;

    # Keep connection alive for long-running streams
    proxy_read_timeout       600s;
    proxy_connect_timeout    10s;

    # HTTP/1.1 required for chunked transfer encoding
    proxy_http_version       1.1;
    proxy_set_header         Connection '';
}
TYPESCRIPT
// ✅ Fastify SSE response — correct headers in the gateway
// file: src/routes/stream.ts

import type { FastifyRequest, FastifyReply } from 'fastify'

export async function streamRoute(req: FastifyRequest, reply: FastifyReply) {
  reply.raw.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',   // Disables nginx buffering
    'X-Content-Type-Options': 'nosniff',
  })
  reply.raw.flushHeaders()        // Immediately flush — critical for TTFT
}

2. Server-Side SSE Implementation

2.1 The SSE Wire Format

SSE is a simple text protocol over a persistent HTTP connection:

data: {"token":"Hello"}\n\n
id: 1\n
data: {"token":"world"}\n\n
id: 2\n
data: {"token":"!"}\n\n
id: 3\n
data: [DONE]\n\n
  • Each field (data:, id:, event:) ends with \n
  • Each event ends with a blank line (\n\n)
  • The id: field sets event.lastEventId — the browser sends this as Last-Event-ID header on reconnect

2.2 Production SSE Gateway Route

TYPESCRIPT
// ✅ Production SSE gateway with AbortController cancellation
// file: src/routes/chat-stream.ts

import type { FastifyRequest, FastifyReply } from 'fastify'
import { AiGateway } from '../gateway/AiGateway'
import { ChatRequestSchema } from '@workspace/ai-schemas'

interface StreamQuery {
  lastEventId?: string  // For reconnection resume
}

export async function chatStreamRoute(
  req: FastifyRequest<{ Querystring: StreamQuery; Body: unknown }>,
  reply: FastifyReply,
) {
  // Validate incoming request before opening the stream
  const body = ChatRequestSchema.parse(req.body)

  // Set up AbortController wired to client disconnect — prevents token billing leak
  const controller = new AbortController()
  req.raw.on('close', () => controller.abort())

  reply.raw.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  })
  reply.raw.flushHeaders()

  let eventId = parseInt(req.query.lastEventId ?? '0', 10)

  try {
    const gateway = req.diScope.resolve<AiGateway>('aiGateway')

    for await (const action of gateway.streamResponse(body, controller.signal)) {
      if (controller.signal.aborted) break

      eventId++
      const payload = JSON.stringify(action)

      // SSE wire format: id, then data, then double newline
      reply.raw.write(`id: ${eventId}\ndata: ${payload}\n\n`)
    }

    reply.raw.write(`data: [DONE]\n\n`)
  } catch (error) {
    if (!controller.signal.aborted) {
      reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: 'Stream error' })}\n\n`)
    }
  } finally {
    reply.raw.end()
  }
}

3. Client-Side SSE with Fetch (Not EventSource)

3.1 Why Fetch Instead of EventSource

The native EventSource API has two critical limitations for AI streaming:

  1. It only supports GET requests — you cannot send a JSON body with the conversation history
  2. It does not support custom request headers — you cannot send Authorization: Bearer

The solution is to use fetch() with ReadableStream parsing, which gives full control while retaining SSE semantics.

TYPESCRIPT
// ✅ Fetch-based SSE client with deterministic rehydration
// file: apps/web/src/lib/sseClient.ts

export interface SSEEvent {
  id: string
  data: string
}

export async function* fetchSSE(
  url: string,
  body: unknown,
  signal: AbortSignal,
  lastEventId?: string,
): AsyncGenerator<SSEEvent> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'Accept': 'text/event-stream',
  }
  if (lastEventId) headers['Last-Event-ID'] = lastEventId

  const response = await fetch(url, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
    signal,
  })

  if (!response.ok) throw new Error(`SSE error: ${response.status}`)
  if (!response.body) throw new Error('No response body')

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let currentId = lastEventId ?? '0'

  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      // TCP can split SSE events across chunks — buffer until double newline
      buffer += decoder.decode(value, { stream: true })

      // Split on event boundaries (double newline)
      const events = buffer.split('\n\n')
      buffer = events.pop() ?? '' // Last item may be incomplete — keep in buffer

      for (const rawEvent of events) {
        if (!rawEvent.trim()) continue

        // Parse SSE fields
        const lines = rawEvent.split('\n')
        let id = currentId
        let data = ''

        for (const line of lines) {
          if (line.startsWith('id: ')) id = line.slice(4)
          else if (line.startsWith('data: ')) data = line.slice(6)
        }

        if (data === '[DONE]') return
        if (data) {
          currentId = id
          yield { id, data }
        }
      }
    }
  } finally {
    reader.cancel()
  }
}

3.2 Reconnection with Deterministic State Rehydration

TYPESCRIPT
// ✅ Auto-reconnecting SSE hook with persistent state
// file: apps/web/src/hooks/useStreamingChat.ts

import { useCallback, useRef, useState } from 'react'
import { fetchSSE } from '../lib/sseClient'
import { GatewayActionSchema } from '@workspace/ai-schemas'

export function useStreamingChat() {
  const [tokens, setTokens] = useState<string[]>([])
  const [status, setStatus] = useState<'idle' | 'streaming' | 'done' | 'error'>('idle')
  const lastEventIdRef = useRef<string>()
  const abortRef = useRef<AbortController>()

  const send = useCallback(async (message: string, history: ChatMessage[]) => {
    abortRef.current?.abort()
    abortRef.current = new AbortController()
    setTokens([])
    setStatus('streaming')

    const MAX_RETRIES = 3
    let attempt = 0

    while (attempt < MAX_RETRIES) {
      try {
        for await (const event of fetchSSE(
          '/api/chat/stream',
          { message, history },
          abortRef.current.signal,
          lastEventIdRef.current,
        )) {
          lastEventIdRef.current = event.id
          const action = GatewayActionSchema.safeParse(JSON.parse(event.data))
          if (action.success && action.data.type === 'text_reply') {
            setTokens(prev => [...prev, action.data.payload.content])
          }
        }
        setStatus('done')
        break
      } catch (error) {
        if (abortRef.current.signal.aborted) break // User cancelled
        attempt++
        if (attempt >= MAX_RETRIES) { setStatus('error'); break }
        await new Promise(r => setTimeout(r, 500 * attempt)) // Exponential backoff
      }
    }
  }, [])

  const cancel = useCallback(() => abortRef.current?.abort(), [])

  return { tokens, status, send, cancel }
}

4. The Fetch ReadableStream Alternative (Fine-Grained Control)

For cases where you need token-level processing before displaying — syntax highlighting, citation extraction, partial JSON accumulation — raw ReadableStream gives more control than SSE event framing:

TYPESCRIPT
// ✅ Raw fetch ReadableStream for structured streaming
// Used internally by the gateway — not directly consumed by React components

export async function* streamRawChunks(
  response: Response,
  signal: AbortSignal,
): AsyncGenerator<string> {
  if (!response.body) throw new Error('No body')

  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  try {
    while (!signal.aborted) {
      const { done, value } = await reader.read()
      if (done) break
      yield decoder.decode(value, { stream: true })
    }
  } finally {
    reader.cancel()
  }
}

Summary

Decision Rule
Protocol choice HTTP/2 SSE for unidirectional LLM tokens; WebSockets only when client sends mid-stream audio/data
Proxy buffering Always set X-Accel-Buffering: no and proxy_buffering off for SSE routes
EventSource vs fetch Use fetch() for SSE — EventSource cannot send POST bodies or auth headers
TCP chunk boundary Buffer SSE events until \n\n before parsing — a single read() can split an event
Reconnection Store lastEventId persistently; send Last-Event-ID header on reconnect to resume without duplication

What's Next

Part 5 moves to the server side of the stream. We implement production-safe TransformStream pipelines with highWaterMark backpressure tuning and the req.on('close') → AbortController.abort() chain that prevents orphaned generation loops from billing for disconnected clients.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#SSE#Streaming#WebSockets#React#Node.js#HTTP/2#Network
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 4 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
Reading
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace