node-stream-zip
node-stream-zip is a Node.js ZIP reader and extractor built for archives that should not be loaded into memory all at once. It reads the archive in chunks, indexes entries, can return a small entry as a Buffer or expose it as a readable stream, and can extract one entry, a folder, or the whole archive. It supports ZIP64, self-extracting archives, deflate through Node's built-in zlib, callback and Promise APIs, and ships with TypeScript declarations. It does not create ZIP files and does not support AES-encrypted archives.
A practical Node-only reader for large archives, with no dependency or native-build burden and a useful Promise wrapper. Keep 1.16.0 or newer, close every archive, stream large entries, and choose something else for ZIP creation, browsers, or AES encryption.
Use it if
- You need to inspect or extract large ZIP and ZIP64 files without buffering the entire archive
- You want a dependency-free Node implementation with no native addon or external unzip binary
- You need either a Promise API for new code or the older event and callback API for entry-by-entry processing
- You want entry-name validation against paths such as `../` and Windows absolute paths enabled by default
- You need to create or update ZIP archives: the public API only reads, streams, and extracts existing archives, while adm-zip covers both reading and writing
- You need AES-encrypted ZIP support: the README lists AES-encrypted files as out of scope and says opening one throws an error
- You need a browser library: the implementation depends on Node file descriptors, streams, Buffer, fs, and built-in zlib
- You must reliably decode old archives from many locales without knowing their filename encoding: UTF-8 is the default and the README names non-ASCII filenames as a known issue, though `nameEncoding` can help when the encoding is known
- You want a uniform Promise surface for every operation: the recommended async wrapper covers listing, streaming, reading, extraction, and close, but low-level operations such as `openEntry`, synchronous entry reads, custom filesystem wiring, and error events belong to the callback class
Setup reality
`npm i node-stream-zip` installs a CommonJS package with no runtime dependencies, peer dependencies, native compilation, credentials, command-line binary, or config file. It uses Node's built-in `fs`, streams, Buffer, and zlib, so it is server-side only. New code should construct `new StreamZip.async({ file: 'archive.zip' })`; the unusual lowercase `.async` is a class exposed as a static property, not an async factory. Opening still has work to do: await `entries()`, `entriesCount`, `entryData()`, `stream()`, or another method before assuming the central directory is ready. Always call `await zip.close()` in a `finally` block after every read or extraction, because the archive file descriptor stays open otherwise. `entryData()` returns a full Buffer for that entry, so the archive can be huge while one huge entry still exhausts memory; use `stream()` and enforce compressed and uncompressed size limits for untrusted input. Extraction creates nested directories, but the README examples create the top-level output directory first. Entry names are checked by default for malicious paths such as `../` and `c:\123`; never enable `skipEntryNameValidation` for user-controlled archives. Version 1.16.0 fixed compressed-size reading after a reported memory-overflow path involving specially crafted ZIPs, so do not pin an older release when handling uploads. AES encryption is explicitly unsupported. Filename decoding defaults to UTF-8 and `nameEncoding` is available for known legacy encodings, but the README still lists non-ASCII filenames as a known issue. Types are included in `node_stream_zip.d.ts`, although they use `export =`, so TypeScript import syntax depends on `esModuleInterop`. The package claims compatibility as far back as Node 0.12, but production users should judge runtime support by their own maintained Node line rather than treat that permissive engine declaration as a recommendation.
Patterns
Open an archive and list its entriesopen-and-list
const StreamZip = require('node-stream-zip');
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
const entries = await zip.entries();
for (const entry of Object.values(entries)) {
console.log(entry.name, entry.isDirectory ? 'directory' : entry.size);
}
} finally {
await zip.close();
}`entries()` stores and returns the complete entry map; for extremely large entry counts, consume the `entry` event instead.
Read one small entry into a Bufferread-entry-buffer
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
const data = await zip.entryData('docs/readme.txt');
console.log(data.toString('utf8'));
} finally {
await zip.close();
}The archive is chunked, but `entryData()` still buffers the entire selected entry; do not use it for an unbounded large file.
Stream an entry to a writable destinationstream-entry
const { pipeline } = require('node:stream/promises');
const { createWriteStream } = require('node:fs');
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
const input = await zip.stream('video/demo.mp4');
await pipeline(input, createWriteStream('demo.mp4'));
} finally {
await zip.close();
}Wait for the pipeline before closing the archive; closing early cancels pending reads and can truncate the output.
Extract one entry to a chosen fileextract-one-entry
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
await zip.extract('docs/readme.txt', './output/readme.txt');
} finally {
await zip.close();
}Create or verify the top-level output directory first, and keep default entry-name validation enabled for untrusted archives.
Extract a folder from inside the archiveextract-folder
const { mkdir } = require('node:fs/promises');
await mkdir('./output', { recursive: true });
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
await zip.extract('assets/images/', './output/images');
} finally {
await zip.close();
}Use the archive's exact folder prefix, including its slash, rather than assuming directory entries exist in every ZIP producer.
Extract every entryextract-entire-archive
const { mkdir } = require('node:fs/promises');
await mkdir('./unpacked', { recursive: true });
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
const count = await zip.extract(null, './unpacked');
console.log(`Extracted ${count} entries`);
} finally {
await zip.close();
}Passing null selects the whole archive. Do not set `skipEntryNameValidation` when the ZIP came from another user.
Reject an oversized entry before buffering itinspect-before-reading
const zip = new StreamZip.async({ file: 'upload.zip' });
try {
const entry = await zip.entry('payload.json');
if (!entry) throw new Error('payload.json is missing');
if (entry.size > 10 * 1024 * 1024) throw new Error('payload.json is too large');
const data = await zip.entryData(entry);
} finally {
await zip.close();
}Metadata checks reduce accidental memory use, but hostile-archive handling should also cap total extracted bytes, entry count, and compression ratios.
Observe entries while the directory is readprocess-entry-events
const zip = new StreamZip.async({ file: 'large.zip', storeEntries: false });
zip.on('entry', (entry) => {
console.log(entry.name, entry.compressedSize, entry.size);
});
try {
await zip.entriesCount;
} finally {
await zip.close();
}With `storeEntries: false`, later name-based lookup is unavailable; the entry event is the intended low-memory access path.
Decode names from a known legacy encodingset-filename-encoding
const zip = new StreamZip.async({
file: 'legacy.zip',
nameEncoding: 'latin1',
});
try {
console.log(Object.keys(await zip.entries()));
} finally {
await zip.close();
}UTF-8 is the default. Set another encoding only when the archive producer's filename encoding is known.
Read from an existing file descriptoropen-file-descriptor
const fs = require('node:fs');
const fd = fs.openSync('archive.zip', 'r');
const zip = new StreamZip.async({ fd });
try {
console.log(await zip.entriesCount);
} finally {
await zip.close();
}The option has accepted a file descriptor since 1.12. Coordinate ownership carefully so surrounding code does not close it during reads.
Use the included TypeScript entry typesuse-typescript
import StreamZip = require('node-stream-zip');
const zip = new StreamZip.async({ file: 'archive.zip' });
try {
const entry: StreamZip.ZipEntry | undefined = await zip.entry('data.csv');
if (entry?.isFile) console.log(entry.size);
} finally {
await zip.close();
}The declarations use `export =`; default-import syntax usually requires `esModuleInterop` or `allowSyntheticDefaultImports`.
Use the legacy event and callback APIuse-callback-api
const StreamZip = require('node-stream-zip');
const zip = new StreamZip({ file: 'archive.zip' });
zip.on('error', (error) => {
console.error(error);
zip.close();
});
zip.on('ready', () => {
const entry = zip.entry('notes.txt');
console.log(entry?.size);
zip.close();
});The callback class needs an `error` listener; prefer the Promise wrapper unless you need its extra low-level methods or event timing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| unzipper | npm | Choose it when a pipeline-oriented streaming API and parse or extract streams fit the surrounding Node code |
| yauzl | npm | Choose it when you want a deliberately low-level, lazy ZIP reader and will build extraction policy yourself |
| adm-zip | npm | Choose it when archives are modest and you also need to create or modify ZIP files through a simpler in-memory API |