Node.js 26.8 Ships Native ZIP in node:zlib

Node.js 26.8.0 landed on the Current line on 26 August 2026, and with it an addition that had sat open in the project's tracker for years: reading and writing ZIP archives from inside node:zlib directly, with no third-party package.

The problem: ZIP is not a compression algorithm

node:zlib has understood Gzip, Deflate, Brotli and Zstd for a long time. All four are stream algorithms: bytes in, fewer bytes out. ZIP is something else — a container format, and that difference is the whole story. An archive carries a central directory that sits at the end of the file, and every member has its own local header, compressed payload, crc32 checksum, and metadata for permissions and modification time.

That layout — index at the tail, not the head — is what makes ZIP efficient to read: parse the last few kilobytes and you know every member and where it lives, without touching a byte of content. It is also what makes ZIP a parsing problem rather than a decompression problem, which is why zlib alone was never enough.

So any project opening a ZIP file — ingesting a customer upload, unpacking a plugin bundle, reading a build artifact out of a pipeline — reached for a dependency: adm-zip, yauzl, jszip. That reach is never free. Each package is another node in the dependency tree, another thing to keep patched, and another square metre of attack surface on npm. A small library called once a year is still present at every install.

What changed

26.8.0 adds three classes and two helpers to node:zlib:

  • ZipFile — random access to an archive on disk. Opening it reads only the file tail and the central directory; member content loads lazily on demand. It exposes open(), get(), stream(), add(), delete() and compact(), each with a synchronous counterpart. This is the right choice for a large archive when you need one or two files out of it.
  • ZipBuffer — a zero-copy view over an archive already held in memory as a Buffer. It copies nothing and reads members straight out of that same region. Well suited to an archive that arrived over the network in a single request and never needs to touch disk.
  • ZipEntry — a single member, with content() for a buffered read and contentIterator() for bounded-memory streaming, plus create(), createStream() and createSymlink() for building. It surfaces name, size, compressedSize, crc32, isFile and mode.

Alongside them, createZipArchive() turns a sequence of ZipEntry objects into a readable stream — switching to Zip64 structures automatically when the classic format's limits are exceeded — and setMaxZipContentSize() sets the default memory ceiling for content().

A practical example

js
import { ZipFile, ZipEntry, createZipArchive } from 'node:zlib';
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

// Reading: only the tail and central directory are parsed on open
const zip = await ZipFile.open('release.zip');
try {
  for (const entry of await zip.entries()) {
    if (!entry.isFile) continue;
    console.log(entry.name, entry.size, '->', entry.compressedSize);
  }

  // Small member: buffer it
  const manifest = await zip.get('manifest.json');
  const config = JSON.parse((await manifest.content()).toString('utf8'));

  // Large member: stream it, bounded memory
  await pipeline(zip.stream('bundle.js'), createWriteStream('bundle.js'));
} finally {
  await zip.close();
}

// Writing: entries in, archive stream out
await pipeline(
  createZipArchive([
    await ZipEntry.create('manifest.json', JSON.stringify({ ok: true })),
    ZipEntry.createStream('bundle.js', createReadStream('dist/bundle.js')),
  ]),
  createWriteStream('out.zip'),
);

The detail worth noticing: the API is built around streaming, not whole-file loading. createZipArchive() consumes entries as it produces output, and ZipEntry.createStream() builds an archive from a source that would never fit in memory.

Caveats from the real world

  • Every one of these APIs carries Stability 1.0 — Early development. Signatures can shift between releases, so nothing here belongs in a stable public contract yet.
  • setMaxZipContentSize() defaults to 256 MiB (268435456 bytes) and acts as a zip-bomb guard: an entry larger than the ceiling is rejected before allocation. But the guard applies to content() only — reads through contentIterator() are unaffected. That is another reason to prefer streaming for untrusted input, not just a performance argument.
  • In-place modification through a writable ZipFile is not crash-atomic. An interruption mid-write can leave the archive unreadable, so the safe pattern remains: write a new file, then swap it in atomically.
  • ZipBuffer does not copy. Mutating or reusing the underlying Buffer while it is in use corrupts reads silently — the class of bug that never shows up in tests.
  • Entry names come from a file you do not control and may contain ../ or an absolute path. Guarding against path traversal before writing to disk is the application's job, not the module's — a classic vulnerability that has hit plenty of archive libraries before.
  • The feature shipped on Current, not LTS; anyone on 24.x has reason to wait. And a practical note: 26.8.1 followed almost immediately, fixing a version string that wrongly reported an alpha designation. Pin to 26.8.1.

Takeaway

ZIP in node:zlib opens fewer doors than it closes. A common operation that used to cost a permanent dependency is now part of the platform. The direction has been visible in Node.js for a while — test runner, then SQLite, now ZIP — and each step trims the dependency tree, and with it the surface supply-chain attacks are built on. The call is simple: try it on a branch, measure, and hold off on production until the API settles.

Primary source: Node.js 26.8.0 release notes

For the dependency-tree security context: npm Worm Plants Hooks in Your AI Coding Agent

Node.js 26.8 Ships Native ZIP in node:zlib · bahashwan.dev