Problem
CborEncoder accumulates its output in a plain JS number[] and appends one Array.push per byte. Every byte of a byte string, every byte of an encoded text string, every byte of a float and every byte of a multi-byte header goes through an individual push into this.chunks, and only at the very end does encode() copy the whole thing with new Uint8Array(this.chunks).
For a 10 MiB application/cbor response that is 10,485,771 pushes into an array that has to grow ~24 times, then a full copy — so the peak is a ten-million-element JS array plus the finished buffer, on the response path of a request that a client triggered with an Accept: application/cbor header. In a runtime that stores such an array as packed doubles, ten million slots is ~80 MB of backing store for 10 MB of output.
Two details make it worse than a transient spike:
encode() resets this.chunks = [] at the start of the next call, not at the end of this one. A pooled or reused encoder therefore pins the previous body's array until it encodes again. CborSerializer constructs a fresh encoder per call today, so this is latent rather than active — but it is a footgun sitting in a class that is exported and reusable.
- The array is the only buffering strategy. There is no size hint, no chunk list, no
DataView — so the cost scales linearly with payload size with no way for a caller to opt out.
The fix is mechanical: write into a growable Uint8Array (double-on-demand, set() for bulk copies, subarray() at the end). The bulk paths — writeString and writeBytes — already hold a Uint8Array and could set() it in one call instead of looping.
Evidence
The accumulator and the final copy:
src/serialization/CborCodec.ts:34-41
export class CborEncoder {
private chunks: number[] = [];
encode(value: unknown): Uint8Array {
this.chunks = [];
this.writeValue(value);
return new Uint8Array(this.chunks);
}
The two bulk paths, both looping over a Uint8Array they already have:
src/serialization/CborCodec.ts:92-101
private writeString(text: string): void {
const bytes = new TextEncoder().encode(text);
this.writeHeader(3, bytes.length);
for (const byte of bytes) this.chunks.push(byte);
}
private writeBytes(bytes: Uint8Array): void {
this.writeHeader(2, bytes.length);
for (const byte of bytes) this.chunks.push(byte);
}
The same pattern in the bignum and float paths:
src/serialization/CborCodec.ts:111-116
private writeDouble(value: number): void {
this.chunks.push((7 << 5) | 27);
const buffer = new ArrayBuffer(8);
new DataView(buffer).setFloat64(0, value, false);
for (const byte of new Uint8Array(buffer)) this.chunks.push(byte);
}
The encoder is reached from the HTTP edge by content negotiation — no application code has to opt in:
src/http/Marshalling.ts:26-42
/** Pick a serializer for the response body, using the client's `Accept`. */
export function pickResponseSerializer(request: HttpRequest): {
serializer: Serializer;
contentType: string;
} {
const accept = request.headers['accept'] ?? 'application/json';
for (const tok of accept.split(',')) {
const mediaType = tok.trim().split(';')[0]!.toLowerCase();
if (mediaType === 'application/cbor' || mediaType === 'application/x-cbor') {
return { serializer: new CborSerializer(), contentType: 'application/cbor' };
}
if (mediaType === 'application/json' || mediaType === '*/*') {
return { serializer: new JsonSerializer(), contentType: 'application/json; charset=utf-8' };
}
}
return { serializer: new JsonSerializer(), contentType: 'application/json; charset=utf-8' };
}
Proposal
- Replace
chunks: number[] with a growable Uint8Array plus a write cursor: push becomes buffer[pos++] = b with a doubling grow(), and encode() returns buffer.subarray(0, pos) copied out once.
writeString / writeBytes / writeDouble / the bignum path use set() instead of per-byte loops — one memcpy each rather than N pushes.
- Reset the cursor (and drop the buffer, or keep a bounded one) at the end of
encode() so a reused encoder does not retain the last body.
- Optional: accept a size hint on
encode() so callers that know the rough payload size skip the growth steps.
- No wire-format change — the produced bytes are identical, so this is a pure internal change with no CHANGELOG
BREAKING marker and no doc updates beyond a performance note.
Acceptance sketch
Adjacent issues: #880 (codec input hardening caps for JSON, CBOR and MessagePack) covers inbound limits; this is the outbound accumulator and neither implies the other. #618 (no depth cap in CborDecoder.readValue), #567 (quadratic bignum decode) and #581 (__proto__ in map decode) are all decoder-side. #408 (ring buffer for Mailbox) is the same class of fix — replace an array-shaped accumulator with a cursor — in a different file.
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution. chunks.length after an encode is exactly the number of pushes, so the count is a structural result rather than an estimate:
one 10 MiB byte string | output 10.00 MiB | chunks[] length 10.485.771 | pushes == bytes: true | heapUsed delta 27.1 MiB
one 10 MiB text string | output 10.00 MiB | chunks[] length 10.485.771 | pushes == bytes: true | heapUsed delta 175.2 MiB
200k small records | output 4.07 MiB | chunks[] length 4.268.659 | pushes == bytes: true | heapUsed delta -180.1 MiB
after one 4 MiB encode, encoder still retains chunks[] of length: 4.194.315
The push counts and the retained-array length are exact. The heapUsed deltas are indicative only — GC timing is not controlled here (the third row is negative because a collection landed inside the window), and other work was running on the machine.
Part of the production-readiness review batch — tracked in #913.
Problem
CborEncoderaccumulates its output in a plain JSnumber[]and appends oneArray.pushper byte. Every byte of a byte string, every byte of an encoded text string, every byte of a float and every byte of a multi-byte header goes through an individual push intothis.chunks, and only at the very end doesencode()copy the whole thing withnew Uint8Array(this.chunks).For a 10 MiB
application/cborresponse that is 10,485,771 pushes into an array that has to grow ~24 times, then a full copy — so the peak is a ten-million-element JS array plus the finished buffer, on the response path of a request that a client triggered with anAccept: application/cborheader. In a runtime that stores such an array as packed doubles, ten million slots is ~80 MB of backing store for 10 MB of output.Two details make it worse than a transient spike:
encode()resetsthis.chunks = []at the start of the next call, not at the end of this one. A pooled or reused encoder therefore pins the previous body's array until it encodes again.CborSerializerconstructs a fresh encoder per call today, so this is latent rather than active — but it is a footgun sitting in a class that is exported and reusable.DataView— so the cost scales linearly with payload size with no way for a caller to opt out.The fix is mechanical: write into a growable
Uint8Array(double-on-demand,set()for bulk copies,subarray()at the end). The bulk paths —writeStringandwriteBytes— already hold aUint8Arrayand couldset()it in one call instead of looping.Evidence
The accumulator and the final copy:
The two bulk paths, both looping over a
Uint8Arraythey already have:The same pattern in the bignum and float paths:
The encoder is reached from the HTTP edge by content negotiation — no application code has to opt in:
Proposal
chunks: number[]with a growableUint8Arrayplus a write cursor:pushbecomesbuffer[pos++] = bwith a doublinggrow(), andencode()returnsbuffer.subarray(0, pos)copied out once.writeString/writeBytes/writeDouble/ the bignum path useset()instead of per-byte loops — one memcpy each rather than N pushes.encode()so a reused encoder does not retain the last body.encode()so callers that know the rough payload size skip the growth steps.BREAKINGmarker and no doc updates beyond a performance note.Acceptance sketch
CborEncoderwrites into aUint8Array; nonumber[]accumulator remains.writeStringandwriteBytescopy in bulk rather than per byte.CborCodectest corpus.encode()returns, the encoder retains no reference to the produced body.Adjacent issues: #880 (codec input hardening caps for JSON, CBOR and MessagePack) covers inbound limits; this is the outbound accumulator and neither implies the other. #618 (no depth cap in
CborDecoder.readValue), #567 (quadratic bignum decode) and #581 (__proto__in map decode) are all decoder-side. #408 (ring buffer forMailbox) is the same class of fix — replace an array-shaped accumulator with a cursor — in a different file.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution.chunks.lengthafter an encode is exactly the number of pushes, so the count is a structural result rather than an estimate:The push counts and the retained-array length are exact. The
heapUseddeltas are indicative only — GC timing is not controlled here (the third row is negative because a collection landed inside the window), and other work was running on the machine.Part of the production-readiness review batch — tracked in #913.