HomeJournalThis post

SSE vs NDJSON for AI Response Streaming

Compare SSE and NDJSON on framing, browser APIs, typed events, cancellation, proxies, resume, errors, and one matched AI response contract.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

SSE vs NDJSON is a transport choice for the same product contract: a browser receives an ordered, one-way stream of AI response events over HTTP. This comparison holds the event meanings constant and tests framing, APIs, cancellation, recovery, proxies, and terminal errors instead of benchmarking token screenshots.

SSE vs NDJSON begins with one event model

Define response.started, content.delta, tool.started, tool.progress, tool.finished, citation, usage, response.completed, and response.failed before choosing bytes. SSE vs NDJSON is fair only when both candidates carry identical IDs, sequence numbers, timestamps, payload limits, and terminal semantics. Keep token fragments as one event type rather than treating every transport chunk as a token.

Transport boundaries can split UTF-8 and combine several logical events. The decoder must buffer incomplete text, frame complete records, validate their schema, and apply sequence rules. A streaming response protocol should also declare whether content deltas are append-only, replace a range, or update a typed block. Those product meanings belong above both encodings.

Synthetic transport fixture (not captured response traffic): event 7 has type content.delta, sequence 7, and text “café”; event 8 has type response.completed and sequence 8. The expected client accepts sequence 7 once, appends four Unicode characters, and closes only after 8. Both fixture decoders enforce a 64 KB record ceiling and reject a sequence gap before changing transcript state.

SSE vs NDJSON operating modelEvents means Freeze; SSE means Fields; NDJSON means Lines; Client means Equal.EventsFreezeSSEFieldsNDJSONLinesClientEqual
  • Events: Freeze
  • SSE: Fields
  • NDJSON: Lines
  • Client: Equal
Figure 1: SSE vs NDJSON connects Events → SSE → NDJSON → Client as one inspectable argument.

Read the SSE frame contract exactly

SSE uses the text/event-stream media type with fields such as event, data, id, and retry separated by a blank line. Multiple data lines join with newline semantics. The HTML server-sent events standard defines parsing and EventSource behavior, including reconnection and Last-Event-ID. Server-sent events AI implementations must escape payloads into data fields correctly and finish each event delimiter.

EventSource is convenient for GET requests and browser-managed reconnect, but it offers limited request customization. Auth headers, POST bodies, and application-specific backoff may push a team toward fetch streaming while retaining SSE framing. SSE vs NDJSON therefore compares framing separately from the browser client used to consume it.

Synthetic SSE frame (not a packet capture): bytes contain “event: content.delta”, “id: 7”, the JSON data field, a blank line, then response.completed fields for ID 8. The expected decoder joins fields only at the blank line; neither a TCP chunk nor one data line is treated as a complete application event prematurely.

Treat NDJSON as records over a byte stream

A newline-delimited JSON stream emits one complete JSON value per line. The NDJSON specification describes the simple convention, while content type and exact error policy still need agreement between peers. Producers must serialize embedded line breaks inside JSON strings, and consumers must reject an oversized line before allocating without bound.

Fetch plus ReadableStream exposes request methods, headers, body, and cancellation, making NDJSON attractive when AI response streaming begins with a POST. Yet those are Fetch capabilities, not gifts from the framing format. SSE vs NDJSON should not credit NDJSON for flexibility that fetch can also provide to an SSE-framed response.

Synthetic NDJSON frame (not captured network bytes): an equivalent fixture contains the content.delta JSON object, one line feed, then response.completed and a final line feed. A newline inside text is encoded as an escaped JSON character, never as a delimiter. The expected parser buffers until an actual line feed, checks byte length before JSON parsing, and rejects blank non-heartbeat records.

Compare browser integration, not sample size

For native EventSource, measure authentication fit, reconnect ownership, connection limits, error visibility, and whether GET can express the prompt contract safely. For fetch, implement TextDecoder in streaming mode, frame buffering, abort, status handling before body consumption, and reconnect policy. The MDN Streams API is the primary browser reference for the latter path.

Count application code and operational obligations, not minified bytes alone. A mature newline parser can be small but still needs hostile fixtures. The streaming NDJSON parser tutorial covers chunk boundaries in detail. SSE vs NDJSON often turns on which lifecycle the team can own cleanly.

Synthetic hostile framing fixture (not observed traffic): split the two-byte UTF-8 encoding of é between chunks, divide the SSE blank delimiter across two later chunks, and place both NDJSON records in one final chunk. Expected TextDecoder streaming behavior reconstructs café and each framer emits sequences 7 and 8; the fixture deliberately demonstrates why per-chunk JSON parsing fails.

Run the bounded teaching fixture before adapting the pattern to production.

Runnable artifact — sse-ndjson-equivalence.test.mjs

import assert from "node:assert/strict";const events=[{seq:1,type:"delta",text:"hi"},{seq:2,type:"done"}];const nd=events.map(JSON.stringify).join("\n")+"\n";const sse=events.map(e=>"event: "+e.type+"\nid: "+e.seq+"\ndata: "+JSON.stringify(e)+"\n\n").join("");const fromNd=nd.trim().split("\n").map(JSON.parse);const fromSse=sse.trim().split("\n\n").map(f=>JSON.parse(f.split("\n").find(x=>x.startsWith("data: ")).slice(6)));assert.deepEqual(fromSse,fromNd);console.log("PASS: SSE and NDJSON decode the same events");

Run node sse-ndjson-equivalence.test.mjs. Expected receipt: PASS: SSE and NDJSON decode the same events.

Make resume an application guarantee

SSE exposes id and Last-Event-ID, but a server must retain events and interpret the cursor for reconnection to be meaningful. NDJSON has no built-in cursor field, yet the shared event envelope can carry sequence and resume_from on a new request. In both cases, replayed events need stable IDs and idempotent client application.

Decide retention window, authorization on resume, missing-range behavior, and whether a completed response is replayable. Never append duplicate content because a connection recovered after the UI had already applied sequence 41. SSE vs NDJSON cannot solve storage; each can express a robust resume contract when the service owns an ordered event log.

Synthetic reconnect fixture (not an outage record): after applying 7, interrupt the connection before 8. The expected SSE request sends Last-Event-ID 7, while the NDJSON client sends a new POST with resume_from 7. A stub server authorizes the cursor and replays only 8; deliberate replay of 7 must remain harmless because the transcript store rejects an applied sequence before appending text.

Normalize cancellation and terminal failures

A browser close or AbortSignal should cancel the reader, propagate to the server request context, stop model/tool work where supported, and produce an internal terminal reason. The client may not receive a final frame after disconnect, so server truth cannot depend on successfully writing response.failed. The AI streaming UX without jitter article shows why the interface also needs a stable stopped state.

Map HTTP failures before streaming and in-band failures after headers into one product error taxonomy. Once a 200 response starts, status cannot change. SSE vs NDJSON both need a terminal event containing a safe code, retryability, and last durable sequence without exposing provider internals.

Synthetic cancellation fixture (not a service incident): abort between the two records. Both readers are expected to cancel, a stub server marks user_stop after durable sequence 7, and the transcript labels café as partial. A separate constructed failure carries code tool_timeout, retryable true, and last sequence 7 as event 8; neither client may invent an HTTP error after a successful response begins.

NeedSSENDJSONDecision
Native reconnectBuilt inAuthorEvidence
POST + headersFetchFetchTie
Typed event fieldNativePayloadContract
Figure 2: The SSE vs NDJSON decision matrix compares Native reconnect, POST + headers, Typed event field without hiding the operating trade-off.

Probe proxies for buffering and idle timeouts

Test through the real CDN, load balancer, compression layer, server runtime, and browser. Capture time to headers, time to first logical event, inter-event gaps, flush behavior, disconnect recognition, and bytes overhead. Comments can act as SSE keepalives; NDJSON can carry an explicit heartbeat record. Either approach needs a bounded cadence and server cleanup.

Disable transformation only where evidence requires it, and document which hop owns compression. Tiny deltas may be coalesced even when application code flushes. SSE vs NDJSON should be decided with p50 and p95 first-event latency plus long-idle survival, not localhost impressions. Compare SSE vs WebSockets only if bidirectional messaging is genuinely part of the intent.

Synthetic proxy matrix (not production performance data): assign fetch-SSE first-event p95 of 412 milliseconds and fetch-NDJSON 409 to demonstrate a difference too small for the fixture to award. Both constructed cases include a 45-second pause and 15-second heartbeat. Native EventSource is expected to own reconnect, while both fetch clients require explicit cursor and backoff code; real proxy measurements must replace these teaching values before a performance claim.

Run a hostile matched-transport corpus

Feed both decoders UTF-8 split across chunks, several events in one chunk, empty data, embedded newlines, an oversized record, a malformed JSON payload, duplicate sequence, skipped sequence, reconnect replay, cancellation mid-frame, and a terminal error. Assert the same accepted event objects and the same failure codes. The AI token transport is correct only when bytes cannot silently change product meaning.

Throttle the network and background the tab while tool-progress events interleave with content. SSE vs NDJSON implementations should preserve ordering without assuming one chunk maps to one event. Keep fixtures dependency-free where possible, then repeat with the production client and proxy to expose buffering outside the parser.

Proposed hostile corpus (not a completed 24-case run): include 24 constructed cases covering split UTF-8, split delimiter, combined records, multiline SSE data, escaped NDJSON newline, blank frame, a 64 KB boundary, oversized record, malformed payload, duplicate, gap, replay, mid-frame abort, and failure. The required result is identical accepted events and terminal states, with generated evidence retaining raw byte offsets rather than reconstructed strings alone.

Choose with a transport decision receipt

Choose native EventSource when GET, simple headers, browser-managed reconnect, and SSE field semantics fit the service. Choose fetch with SSE framing when typed fields are useful but POST or custom headers are required. Choose fetch with NDJSON when one-JSON-value-per-line aligns with existing tooling and the team will own framing, reconnect, and cursor behavior explicitly.

Archive the event schema, client API, proxy matrix, first-event tails, cancellation trace, resume test, error mapping, and fallback. Compare WebTransport vs WebSockets only for a different interaction class. SSE vs NDJSON has no universal winner; the defensible choice is the smallest transport whose full lifecycle your browser and operations stack can prove.

Synthetic endpoint decision (not a production selection): for a POST fixture with bearer authorization, native EventSource fails the method/header requirement. Fetch-SSE and fetch-NDJSON tie on specified cancellation and resume ownership; the example selects fetch-SSE for typed event fields and assumed server tooling. A different constructed service with line-oriented observability selects NDJSON, showing how actual evidence should drive the same table.

  1. 1Frame

    Decode complete records

  2. 2Validate

    Check typed envelope

  3. 3Apply

    Enforce sequence once

  4. 4Finish

    Record terminal state

Figure 3: The SSE vs NDJSON proof runs Frame → Validate → Apply → Finish before it can claim a result.