archiver
Archiver builds ZIP and TAR archives in Node without shelling out to zip or tar. An archive instance is a Transform stream: you pipe it to a file, an HTTP response, or an S3 upload, then push entries into it with append (buffer or stream), file, directory, glob, and symlink, and call finalize when you are done. Because it streams, you can produce a multi-gigabyte ZIP while holding almost nothing in memory, which is why it is the standard answer for 'let the user download all of this as a zip'. It only writes archives. Reading or extracting them is a different library entirely.
For streaming a zip or tar out of a Node server, Archiver is still the default and it does the job well. Read the 8.0.0 notes before upgrading: it is ESM-only with a new constructor API, and most existing code is on 7.x for exactly that reason.
Use it if
- You need to generate a ZIP or TAR on the fly and stream it straight to an HTTP response or an object store without staging a temp file
- You are packaging a directory tree with globs, per-entry names, permissions, and symlinks, and want that in JavaScript rather than in a shell command
- You need archives bigger than memory: entries go through a queue with backpressure, so a 20 GB zip costs you roughly one file handle at a time
- You want the same API for zip and tar.gz so switching output format is a one-line change
- You need to read or extract archives: Archiver only writes. Use yauzl, unzipper, or node-stream-zip for reading, and tar for extraction
- You are on CommonJS: version 8.0.0 is ESM-only and dropped the archiver('zip', opts) factory in favour of named ZipArchive/TarArchive classes, so require('archiver') no longer works at all and you have to stay on 7.x
- You want first-party TypeScript types: none are shipped in the package, so you depend on the community @types/archiver, which is a separate release train that can lag the library
- You need maximum compression throughput: it uses Node's zlib on the main thread, so a big zip at level 9 competes with your event loop; a child process running zip or a worker thread will beat it under load
- You want a small, quiet dependency: it pulls in nine runtime packages including async, readable-stream, tar-stream, and zip-stream, and carries 143 open issues (166 including PRs) against a repo maintained largely by one person
Setup reality
npm install archiver is dependency-heavy but painless, with no native builds. Node 18 or newer is required from 8.0.0. The upgrade to 8 is the real cost: the package is now type: module with a single ESM export, the archiver(format, options) factory is gone, and you construct new ZipArchive(options) or new TarArchive(options) instead, so every CommonJS caller breaks. Types come from the separate @types/archiver package. The behavioral trap that catches everyone is error handling: entry-level problems arrive as 'error' and 'warning' events on the stream rather than as thrown exceptions or rejected promises, and an unhandled 'error' event takes the process down. Register both listeners, plus a listener on the destination stream, before you append anything.
Patterns
Write a zip to diskzip-to-file
import fs from 'node:fs'
import { ZipArchive } from 'archiver'
const output = fs.createWriteStream('out.zip')
const archive = new ZipArchive({ zlib: { level: 9 } })
output.on('close', () => console.log(archive.pointer(), 'bytes'))
archive.on('warning', (err) => { if (err.code !== 'ENOENT') throw err })
archive.on('error', (err) => { throw err })
archive.pipe(output)
archive.file('README.md', { name: 'README.md' })
await archive.finalize()Attach the listeners before piping. finalize() resolves when the format module is done, but the file on disk is only complete on the output stream's 'close' event.
Stream a zip straight to an HTTP downloadstream-zip-http-response
import { ZipArchive } from 'archiver'
app.get('/export', (req, res) => {
res.attachment('export.zip')
const archive = new ZipArchive({ zlib: { level: 6 } })
archive.on('error', (err) => res.destroy(err))
archive.pipe(res)
archive.directory('data/', 'export')
archive.finalize()
})You cannot set Content-Length because the size is unknown until the end, so the response is chunked and browsers show no progress bar. Level 6 is a much better throughput trade than 9 for a live response.
Add generated content without touching diskappend-buffer-and-string
archive.append('id,name\n1,ada\n', { name: 'users.csv' })
archive.append(Buffer.from(JSON.stringify(config)), { name: 'config.json' })
archive.append(readableStream, { name: 'logs/app.log' })name is required and is the path inside the archive; forward slashes only, even on Windows. Appending a stream means Archiver owns consuming it, so do not read from it yourself.
Add a whole directoryadd-directory-tree
// contents land under 'assets/' inside the zip
archive.directory('public/', 'assets')
// contents land at the archive root
archive.directory('public/', false)Passing false as destpath flattens the tree to the root; passing an empty string does not do the same thing. Hidden dotfiles are included.
Add files matching a globglob-selected-files
archive.glob('**/*.js', {
cwd: 'src',
ignore: ['**/*.test.js', '**/node_modules/**'],
}, { prefix: 'src' })Paths inside the archive are relative to options.cwd, and the third argument sets entry data such as prefix. Without cwd you get the full path from the process working directory baked into the archive.
Produce a tar.gz instead of a ziptargz-output
import { TarArchive } from 'archiver'
const archive = new TarArchive({ gzip: true, gzipOptions: { level: 9 } })
archive.pipe(fs.createWriteStream('bundle.tar.gz'))
archive.directory('dist/', 'dist')
await archive.finalize()gzip is a TarArchive option; the zlib option used by ZipArchive is ignored here. Tar preserves Unix modes, which is why release tarballs use it instead of zip.
Control permissions and timestamps per entryentry-permissions-and-dates
archive.file('scripts/deploy.sh', {
name: 'bin/deploy.sh',
mode: 0o755,
date: new Date('2026-01-01T00:00:00Z'),
})Pinning date is how you get byte-reproducible archives; without it every build differs because mtimes differ. Zip stores modes but many Windows extractors ignore them.
Add a symlink entry that has no file behind itadd-symlink
archive.file('releases/v2/app', { name: 'releases/v2/app' })
archive.symlink('current', 'releases/v2/app', 0o777)symlink() writes a link entry into the archive without touching the filesystem, so the target only needs to exist inside the archive. Zip symlinks are widely but not universally supported by extractors.
Report progress while packingtrack-progress
archive.on('progress', ({ entries, fs: bytes }) => {
console.log(`${entries.processed}/${entries.total} entries`)
console.log(`${bytes.processedBytes}/${bytes.totalBytes} bytes`)
})totals grow as the stat queue discovers files, so early percentages jump around. Byte counts only cover filesystem sources, not buffers or streams you appended.
Tell fatal errors apart from skippable oneshandle-errors-and-warnings
archive.on('warning', (err) => {
if (err.code === 'ENOENT') {
console.warn('missing file, skipping:', err.data)
} else {
throw err
}
})
archive.on('error', (err) => {
console.error(err.code, err.message)
archive.abort()
})A missing file is a warning, not an error, so the archive completes with the entry silently absent. An unhandled 'error' event on a stream crashes the process.
Cancel an archive that is still being builtabort-in-flight
res.on('close', () => {
if (!res.writableFinished) archive.abort()
})Without this, a client that cancels a large download leaves the stat and append queues running to completion, holding file handles and CPU for nothing.
Skip compression for already-compressed payloadsno-compression-store
const archive = new ZipArchive({ store: true })
// or per entry:
archive.file('video.mp4', { name: 'video.mp4', store: true })STORE mode copies bytes through. Deflating JPEGs, MP4s, or existing zips burns CPU for roughly zero size reduction and slows the whole stream down.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| yazl | npm | You only need ZIP, want a tiny dependency tree, and prefer an explicit low-level API over event streams |
| tar | npm | You are working with tar and tar.gz only, and you also need extraction, which Archiver cannot do |
| jszip | npm | The archive has to be built in a browser, or you need to read and modify an existing zip rather than only write one |
| adm-zip | npm | Small archives where a simple synchronous read-and-write API matters more than memory usage |