Summary
JSONTextDecoder exhibits quadratic time and memory complexity when a large JSON string token spans multiple input chunks.
On a 270 MB input with a single ~48 MB string field, core-passthrough takes ~409 seconds at the default 64 KB chunk size, compared to ~0.7 seconds for raw I/O on the same file.
Problem
Three source-level behaviors combine to produce this:
consumeString() (src/utils/wire.ts) — rescans from the opening quote on every call and does not preserve scan position across invocations.
readToken() (src/modules/decoder.ts) — returns undefined when consumeString() returns 0, leaving the incomplete string in the unread region.
appendBytes() (src/modules/cursor.ts) — allocates a new Uint8Array of size unread.length + bytes.length and copies all unread bytes on every append.
Together, these behaviors cause repeated rescanning and repeated copying of the same growing unread buffer whenever a single string token crosses chunk boundaries.
Impact
- Severe performance degradation on large streamed inputs
- Memory usage grows unnecessarily due to repeated buffer reallocation and copying
- Default chunk sizes amplify the problem for large string values
Reproduction
Use a large JSON document where one string field is much larger than the stream chunk size, for example a ~270 MB file containing a single ~48 MB string field.
Expected behavior:
- Decoding time should scale approximately linearly with input size.
- Memory growth should remain bounded to what is necessary for incremental decoding.
Actual behavior:
- Runtime becomes effectively quadratic.
- Repeated unread-buffer copying and rescanning dominate execution time.
Suggested direction
Potential remediation areas:
- Preserve string scan progress across
consumeString() invocations so already-validated bytes are not rescanned.
- Adjust
readToken() / buffering behavior so incomplete strings do not force repeated whole-buffer work.
- Rework
appendBytes() to avoid allocating and copying the full unread region on every append.
Acceptance criteria
- Large string tokens spanning many chunks are processed with near-linear time complexity.
- Buffer growth avoids repeated full-copy behavior on each append.
- The provided large-input scenario no longer exhibits orders-of-magnitude slowdown relative to raw I/O.
Summary
JSONTextDecoderexhibits quadratic time and memory complexity when a large JSON string token spans multiple input chunks.On a 270 MB input with a single ~48 MB string field,
core-passthroughtakes ~409 seconds at the default 64 KB chunk size, compared to ~0.7 seconds for raw I/O on the same file.Problem
Three source-level behaviors combine to produce this:
consumeString()(src/utils/wire.ts) — rescans from the opening quote on every call and does not preserve scan position across invocations.readToken()(src/modules/decoder.ts) — returnsundefinedwhenconsumeString()returns0, leaving the incomplete string in the unread region.appendBytes()(src/modules/cursor.ts) — allocates a newUint8Arrayof sizeunread.length + bytes.lengthand copies all unread bytes on every append.Together, these behaviors cause repeated rescanning and repeated copying of the same growing unread buffer whenever a single string token crosses chunk boundaries.
Impact
Reproduction
Use a large JSON document where one string field is much larger than the stream chunk size, for example a ~270 MB file containing a single ~48 MB string field.
Expected behavior:
Actual behavior:
Suggested direction
Potential remediation areas:
consumeString()invocations so already-validated bytes are not rescanned.readToken()/ buffering behavior so incomplete strings do not force repeated whole-buffer work.appendBytes()to avoid allocating and copying the full unread region on every append.Acceptance criteria