Parsing Untrusted Long Streams in the Browser: Resource Caps, Pull-Based Backpressure, and Post-Commit Recovery
Beyond basic SSE parsing, a production client must also constrain malicious long records, slow consumers, and reconnect replays. This article builds a resumable link with bounded framing, on-demand pull, and a post-commit cursor.
Key Points
- Topic Focus: AI、TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
response.body.getReader() returns byte chunks of arbitrary size: UTF-8 characters and JSON records can span chunks, and a single chunk can hold multiple records. LLM Streaming Protocols already covers SSE fields, cross-chunk carry, and basic parsing; this article doesn't repeat protocol basics—it only handles hard resource caps for untrusted long streams, pull-driven backpressure, schema rejection, and post-commit recovery.
Framing Is the Prerequisite for a Resource and Recovery Contract
Bare JSON has no boundary between consecutive records. The protocol should choose and version its framing: SSE parses fields per spec; length-prefix reads a fixed header first and then the specified bytes; JSON Text Sequences follow RFC 7464, adding a 0x1E Record Separator and ending with a newline. Don't treat "the server writes one line per call" as a protocol.
Each envelope contains at least protocolVersion, streamId, a monotonic sequence, kind, and a payload. The client rejects wrong streamId, unknown required versions, and regressed sequence numbers; a duplicate sequence may produce the same application effect only once. Evolution principles are covered in REST API Versioning—being parseable on the wire is not the same as business compatibility.
The Decoder Must Preserve Cross-Chunk State
You can use TextDecoderStream("utf-8", { fatal: true }) to preserve incomplete multi-byte sequences; manual decoding must call decoder.decode(bytes, { stream: true }) on intermediate chunks and flush at the end. Creating a new decoder per chunk breaks cross-chunk characters. fatal: true turns illegal UTF-8 into a protocol error, preventing replacement characters from entering signatures, cursors, or JSON validation.
const decoded = response.body!
.pipeThrough(byteLimitAndMetrics())
.pipeThrough(new TextDecoderStream("utf-8", { fatal: true }));
for await (const value of parseJsonSequence(decoded, {
maxRecordChars: 262_144,
maxCarryChars: 262_144,
})) {
const event = decodeCompleteEnvelope(value); // value stays unknown for now
await applyIdempotently(event);
await commitCursor(event.sequence);
}
The parser maintains the carry and calls JSON.parse only on complete records. When the stream ends with leftover fragments, it reports truncated_record and must not treat them as results. TypeScript Async Iteration can handle consumption syntax but won't deal with framing or runtime types.
Incomplete Records Must Also Be Bounded by Hard Caps
Data without a terminator can keep feeding a huge carry. The parser simultaneously limits raw bytes per record, decoded characters, nesting depth, array elements, strings, cumulative records, and total response bytes. Caps are versioned in the production manifest; on overflow it cancels the reader and aborts the fetch, without logging the full malicious payload. Character limits can't substitute for raw-byte and decoded-memory caps.
After JSON parsing succeeds, the value is still unknown. Only complete records enter the schema decoder, which checks the envelope and the payload corresponding to that kind; you mustn't run regex to "salvage fields" on a half-string, nor merge unvalidated objects into state as tokens arrive. Structured Output Constraints apply to model results; the network envelope still needs independent size, version, and sequence validation.
The recovery strategy for a bad record is decided by the framing. JSON Text Sequences can scan to the next RS to isolate a corrupted element, but whether to continue must be decided by the business type: telemetry can log the gap and continue, while ledgers, tool calls, or conversation state should terminate immediately and replay from the confirmed cursor. Don't make a blanket promise that "the parser auto-recovers."
Backpressure Comes from On-Demand Reads, Not from Delay Loops
highWaterMark is a signal for when to keep pulling, not a hard cap on the queue. If one network chunk contains ten thousand small records, calling enqueue() ten thousand times in a loop within the same transform() only drives desiredSize negative; enqueue() won't wait for the consumer to drain. So you can't prove memory is bounded with "small HWM + synchronous batched enqueue."
The parser should use a pull-driven ReadableStream or async generator: produce only one complete record per pull() / next() demand, keeping the rest as a bounded carry of not-yet-parsed objects; the consumer requests the next record only after await applyIdempotently completes. Before a new chunk is added, check the current carry, the chunk, and the total response budget together; on overflow, cancel the source rather than copying and continuing. The browser and network stack may still prefetch a limited number of bytes, so what's constrained here is the application queue and carry—you can't claim backpressure makes the remote model stop producing immediately.
If the UI only needs to refresh every 50ms, you can aggregate validated events before rendering, but fact processing and the cursor shouldn't be metered by React render count. When the queue hits the high-water mark, keep draining accepted records and stop reading or explicitly cancel; randomly dropping intermediate deltas makes the final text and sequence unverifiable. End-to-end resource release can be combined with Next.js AI Streaming Response Governance.
The Cursor Advances Only After Side Effects Are Committed
User stop, page navigation, timeout, and parse errors all cancel the fetch through a single AbortController and let the reader, parser, and consumption task end. Logs distinguish user_cancel, deadline, invalid_utf8, oversized_record, schema_rejected, and sequence_gap. Events with an old streamId that arrive after cancellation are ignored unconditionally.
The recovery cursor represents "the sequence of the last idempotently applied record," not the last received or parsed record. Apply side effects and advance the local cursor within the same transaction where possible; on reconnect, send the cursor and the server replays from after it. If a crash happens before commit, the same record may arrive again, and the client must deduplicate by (streamId, sequence). When the server can't provide a stable replay window, you can only mark the current result incomplete and regenerate; you must not splice two streams from different generations.
Tests use a proxy that splits chunks at every byte position, especially covering Chinese, emoji, RS, very long numbers, and ending EOF; then pack tens of thousands of small records into a single chunk, paired with a slow consumer to assert that each demand produces at most one record and the carry stays in bounds. Also inject delimiter-less floods, illegal UTF-8, bad JSON, schema errors, mid-stream cancellation, pre-cursor-commit crashes, and expired replay windows. The gate requires that any chunking yields the same object sequence, corrupted records don't enter state, connections are released after cancellation, and recovery doesn't duplicate side effects. Achieve all this and streaming structured data is a protocol, not a guess about network chunking.
Further Reading
Related
Related Content
Article
What Is Vibe Coding? Will It Change How Programmers Work?
Technical trends, engineering practices, and an indie developer perspective on Vibe Coding, AI, and independent development.
Article
From Copilot to Codex: AI Coding Tools Enter the Agent Era
Technical trends, engineering practices, and indie-developer perspectives around AI, AI Agents, and Vibe Coding.
Article
Complete Technology Stack Analysis for LLM Application Development
Covers tech trends, engineering practices, and indie developer perspectives around large models, RAG, and AI.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.