Skip to content

fix(gateway): stop retaining every payload we gzip - #442

Merged
Menci merged 1 commit into
mainfrom
perf/blob-stream-gzip
Aug 8, 2026
Merged

Menci merged 1 commit into
mainfrom
perf/blob-stream-gzip

Conversation

@Menci

@Menci Menci commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • compress through a ReadableStream we build instead of new Blob([bytes]).stream(), which pins its source buffer to a GC root for the life of the process
  • share one gzip module between the Responses payload store and the dump store, which had each grown their own copy of it
  • drop the new Uint8Array(bytes) copy that existed only to satisfy the lib's BufferSource narrowing
  • ban new Blob in server-side production sources so the retention cannot return through a new call site

The defect

Blob is a Node BaseObject registered in the realm's BaseObjectList, which is a GC root, and Blob.prototype.stream() leaves the source buffer attached to it. Every payload the gateway compressed therefore stayed resident for the life of the process. The retained bytes are native, so heapUsed stayed flat while RSS climbed — a gateway storing Responses payloads leaks roughly one full copy of every stored item, and nothing in the heap profile shows it.

Upstream: nodejs/node#63574 (confirmed-bug), nodejs/node#64105.

The bug is present on Node v24.16.0 through v24.18.0. docker/Dockerfile pins NODE_VERSION=22.23.1, where the old code measures flat, so the shipped image was not affected — a self-built or self-hosted deployment on the 24.x line was.

Memory benchmark

Node 24.18.0 on macOS arm64, forced GC before every sample.

Helper in isolation, 300 gzips of a 256 KiB payload:

peak arrayBuffers RSS
Before 75.5 MiB, linear at 250 KiB/call 187.8 MiB
After 0.5 MiB, flat 82.4 MiB

Whole gateway on a real SQLite database, 200 /v1/responses requests with store: true and a 256 KiB input, stubbed Ollama upstream:

arrayBuffers growth RSS growth heapUsed growth
Before +49.0 MiB +96.5 MiB +1.7 MiB
After +0.0 MiB +34.9 MiB -0.5 MiB

The residual RSS growth is present with and without the change and decelerates; it does not track payload size.

Guard

The ESLint rule covers packages/, apps/platform-*, tools/ and scripts/, and exempts __tests__ and apps/web: tests construct Blobs deliberately as fixtures for code that must accept a caller-supplied one, the dashboard uses Blob to hand bytes to a browser download, and the hazard is retention across a long-lived process. The rule was verified to fire on a probe in production code and to stay silent on apps/web.

A runtime regression test was considered and rejected: the retention only reproduces on the affected Node line, so on CI's Node the assertion would pass against the defective code as well and give false assurance. The lint rule is the version-independent guard.

Test Plan

  • gzip container and round-trip tests, including empty input, every byte value, a 1 MiB payload, a view over a larger buffer, and non-gzip input
  • Responses payload store and dump store suites
  • Helper-level and whole-gateway memory benchmarks above
  • Lint rule fires on server-side production code and spares apps/web
  • Workspace typecheck
  • Repository lint
  • Agent protocol check
  • GitHub Verify workflow

Compressing a stored Responses payload or a dump body went through
`new Blob([bytes]).stream()`. A Blob is a Node `BaseObject` registered
in the realm's `BaseObjectList`, which is a GC root, and its stream
leaves the source buffer attached to it, so every compression pinned a
full copy of its input for the life of the process. The bytes are
native, so `heapUsed` stayed flat while RSS climbed — the shape of an
OOM kill on a gateway that looks healthy.

Feed the compression transform from a `ReadableStream` we build
ourselves instead. The pipeline is otherwise identical, still uses the
web-standard streams both deployment targets provide, and drops the
defensive `new Uint8Array(bytes)` copy that only existed to satisfy the
lib's `BufferSource` narrowing. The two call sites had grown their own
copies of the same four lines; they now share one module.

An ESLint rule bans `new Blob` in server-side production sources, so
the retention cannot come back through a new call site. Tests and
`apps/web` are exempt: the hazard is retention across a long-lived
process.

Measured on Node 24.18.0, 300 gzips of a 256 KiB payload with forced
GC: `arrayBuffers` grew to 75.5 MiB before and stays at 0.5 MiB after.
The leak only bites the affected Node line — v24.16 through v24.18 —
and the shipped image pins 22.23.1, where the old code measured flat.

nodejs/node#63574
nodejs/node#64105
@Menci
Menci merged commit f1a4fcb into main Aug 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant