Skip to content

[Bug] CborEncoder accumulates output with one Array.push per byte, so a 10 MiB application/cbor response transiently costs a 10-million-element JS array plus the Uint8Array copy #970

Description

@pathosDev

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

  • CborEncoder writes into a Uint8Array; no number[] accumulator remains.
  • writeString and writeBytes copy in bulk rather than per byte.
  • Encoded output is byte-identical to today for the existing CborCodec test corpus.
  • After encode() returns, the encoder retains no reference to the produced body.
  • A benchmark (or the existing serialization bench) shows the allocation for a multi-MiB payload dropping to roughly the output size.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions