perf(ext/fetch): remove quadratic line buffering in EventSource#35881
Merged
bartlomieju merged 3 commits intoJul 15, 2026
Merged
Conversation
bartlomieju
approved these changes
Jul 15, 2026
bartlomieju
enabled auto-merge (squash)
July 15, 2026 09:54
bartlomieju
disabled auto-merge
July 15, 2026 10:27
bartlomieju
enabled auto-merge (squash)
July 15, 2026 10:27
bartlomieju
pushed a commit
that referenced
this pull request
Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An SSE event carrying a large single-line
data:payload stalls the built-inEventSourcewith quadratic CPU on the event loop — 2.6 seconds ofblocking work for one 8 MiB line. This PR makes line splitting linear,
bringing wall time to Node parity:
Single
data:line delivered in 1460-byte fragments to the real built-inEventSource; medians of 5, macOS aarch64. Both Deno binaries were builtfrom the same tree with the same profile — the baseline is this branch's
base commit, so the only difference is this diff. The Node column is the
same workload against Node's
EventSource(undici,--experimental-eventsource). Every run integrity-verified: received eventlength exact and every byte checked against the sent pattern.
The baseline's ~3.7x growth per size doubling is the signature of the bug.
With this PR, growth is linear: ~5 ms of marginal cost per 2 MiB on top of
~15 ms of fixed connection overhead. Payloads this size are not exotic —
one SSE event carrying a large single-line JSON payload (LLM tool results,
base64 images) is enough, and a server that never sends a newline can pin
the client's event loop with quadratic work indefinitely.
Cause
The private
TextLineStreamcopy inext/fetch/27_eventsource.jsre-concatenates its entire pending buffer into every incoming fragment
(
chunk = this.#buf + chunk) and re-slices the remaining tail once perextracted line. When a line spans many network reads, each fragment costs
O(buffered bytes), so the total is O(n^2) in the line length. There is a
second quadratic behind the first: with
allowCR: true(always on forEventSource),
indexOf("\r")rescans from position 0 for every lineextracted from a multi-line chunk.
This is the same bug as [denoland/std#7211], which fixes the
@std/streamsTextLineStreamthis code was originally copied from. The built-in carriesits own copy, so it needs its own fix; the two land independently.
Fix
Same approach as the std PR. Fragments that cannot complete a line are
pushed to an array (O(1)) and joined only when a fragment arrives that
contains a line terminator. Complete lines are then extracted with
position-based
indexOf(x, start)instead of re-slicing the remainder,and the position of the next CR is cached across loop iterations, which
removes the second quadratic. Emitted lines are byte-identical to the
previous implementation (see Verification).
No regression on the common case
Small events were already fine and stay that way: 50,000 events of ~120 B
(one JSON token-delta frame per event, 1460 B fragments, real
EventSourceend to end) run in 48 → 44 ms (~0.9 µs/event), mediansof 5, all 50,000 events received and length-checked on every run.
Verification
#handle/flushimplementationverbatim vs the new one: ~300,000 cases across 5 PRNG seeds, random
fragmentation (including empty chunks and splits inside
\r\n) over 8adversarial alphabets (pure
\r, pure\n, CR-heavy, terminator-free,mixed), both
allowCRmodes, plus directed edge cases (trailing\rat chunk end, CR resolved by the next chunk, CR at EOF). Emitted line
sequences byte-identical in every case.
comments, multi-line data, field parsing, id/retry, trailing CR at EOF)
served at 5 fragment sizes (1/2/3/7/4096 bytes) to baseline and patched
binaries. The observed MessageEvent sequences (data, type, lastEventId)
are identical.
cargo test unit::event_source_testpasses on the patched tree.tools/format.jsandtools/lint.js --jspass.Benchmark source
The Node reference script, the small-events regression guard, and both differential test harnesses are separate scripts; happy to paste them on request.
I used Cursor (Claude) to help investigate and write this change.