TL;DR
nanotar is the UnJS minimal tar utility — creates and extracts tar archives in memory, zero dependencies, ~2KB, works in any JavaScript runtime. tar-stream is the streaming tar library — low-level pack/extract streams, memory efficient for large archives, used by many npm tools. node-tar (tar) is npm's tar implementation — full-featured, gzip/brotli support, file permissions, used by npm for package tarballs. In 2026: node-tar for full tar.gz operations, tar-stream for streaming pipelines, nanotar for simple in-memory tar.
Key Takeaways
- nanotar: ~3M weekly downloads — UnJS, in-memory, ~2KB, zero deps, any runtime
- tar-stream: ~15M weekly downloads — streaming pack/extract, low-level, memory efficient
- node-tar: ~20M weekly downloads — npm's tar, gzip/brotli, permissions, full featured
- node-tar handles .tar.gz natively — most npm packages are .tgz files
- tar-stream is the most flexible for streaming pipelines
- nanotar is the simplest for creating/reading tar in memory
nanotar
nanotar — minimal tar utility:
Create tar in memory
import { createTar } from "nanotar"
// Create a tar archive from files:
const tarData = createTar([
{ name: "package.json", data: JSON.stringify({ name: "my-pkg", version: "1.0.0" }) },
{ name: "src/index.ts", data: 'export const hello = "world"' },
{ name: "README.md", data: "# My Package" },
])
// tarData is a Uint8Array
// Write to file:
import { writeFileSync } from "node:fs"
writeFileSync("archive.tar", tarData)
Extract tar in memory
import { parseTar } from "nanotar"
// Parse a tar archive:
const files = parseTar(tarData)
for (const file of files) {
console.log(file.name) // "package.json", "src/index.ts", etc.
console.log(file.data) // Uint8Array of file contents
console.log(new TextDecoder().decode(file.data)) // String content
}
With gzip (manual)
import { createTar, parseTar } from "nanotar"
import { gzipSync, gunzipSync } from "node:zlib"
// Create .tar.gz:
const tar = createTar([
{ name: "data.json", data: JSON.stringify({ key: "value" }) },
])
const tgz = gzipSync(tar)
writeFileSync("archive.tar.gz", tgz)
// Extract .tar.gz:
const compressed = readFileSync("archive.tar.gz")
const decompressed = gunzipSync(compressed)
const files = parseTar(decompressed)
Why it's useful
nanotar:
✅ ~2KB — smallest tar implementation
✅ Zero dependencies
✅ Works in Node.js, Deno, Bun, browsers
✅ Simple API — createTar / parseTar
✅ In-memory — no filesystem required
❌ No streaming (entire archive in memory)
❌ No gzip built-in (use node:zlib)
❌ No file permissions / ownership
❌ No symlink support
Use for: small archives, config bundles, cross-runtime
tar-stream
tar-stream — streaming tar:
Pack (create tar)
import tar from "tar-stream"
import { createWriteStream } from "node:fs"
import { pipeline } from "node:stream/promises"
const pack = tar.pack()
// Add files:
pack.entry({ name: "package.json" }, JSON.stringify({ name: "my-pkg" }))
pack.entry({ name: "src/index.ts" }, 'export const hello = "world"')
// Add a file with metadata:
pack.entry({
name: "bin/cli.js",
mode: 0o755, // Executable
mtime: new Date(),
uid: 1000,
gid: 1000,
}, "#!/usr/bin/env node\nconsole.log('hello')")
pack.finalize()
// Pipe to file:
await pipeline(pack, createWriteStream("archive.tar"))
Extract (read tar)
import tar from "tar-stream"
import { createReadStream } from "node:fs"
import { pipeline } from "node:stream/promises"
const extract = tar.extract()
extract.on("entry", (header, stream, next) => {
console.log(header.name) // File name
console.log(header.size) // File size
console.log(header.type) // "file", "directory", "symlink"
// Read file content:
const chunks: Buffer[] = []
stream.on("data", (chunk) => chunks.push(chunk))
stream.on("end", () => {
const content = Buffer.concat(chunks).toString()
console.log(`${header.name}: ${content.slice(0, 50)}...`)
next() // Process next entry
})
stream.resume()
})
await pipeline(createReadStream("archive.tar"), extract)
With gzip (streaming)
import tar from "tar-stream"
import { createGzip, createGunzip } from "node:zlib"
import { createReadStream, createWriteStream } from "node:fs"
import { pipeline } from "node:stream/promises"
// Create .tar.gz with streaming:
const pack = tar.pack()
pack.entry({ name: "data.json" }, JSON.stringify({ key: "value" }))
pack.finalize()
await pipeline(pack, createGzip(), createWriteStream("archive.tar.gz"))
// Extract .tar.gz with streaming:
const extract = tar.extract()
extract.on("entry", (header, stream, next) => {
// Process each file...
stream.resume()
next()
})
await pipeline(
createReadStream("archive.tar.gz"),
createGunzip(),
extract,
)
Dynamic tar creation
import tar from "tar-stream"
// Stream entries — useful for large archives:
const pack = tar.pack()
// Add entries from a database or API:
for await (const record of db.stream("SELECT * FROM files")) {
pack.entry({ name: record.path, size: record.size }, record.content)
}
pack.finalize()
node-tar
node-tar — npm's tar implementation:
Create tar.gz
import tar from "tar"
// Create a .tar.gz from files:
await tar.create(
{
gzip: true,
file: "archive.tar.gz",
},
["src/", "package.json", "README.md"],
)
// Create with options:
await tar.create(
{
gzip: true,
file: "dist.tar.gz",
cwd: "./build", // Base directory
prefix: "my-pkg/", // Add prefix to all paths
portable: true, // Portable (no uid/gid)
filter: (path) => !path.includes("node_modules"),
},
["."],
)
Extract tar.gz
import tar from "tar"
// Extract to directory:
await tar.extract({
file: "archive.tar.gz",
cwd: "./output", // Extract to this directory
})
// Extract with options:
await tar.extract({
file: "archive.tar.gz",
cwd: "./output",
strip: 1, // Remove first path component
filter: (path) => path.endsWith(".js") || path.endsWith(".ts"),
newer: true, // Only extract newer files
})
List contents
import tar from "tar"
// List files in a tar.gz:
await tar.list({
file: "archive.tar.gz",
onReadEntry: (entry) => {
console.log(`${entry.path} (${entry.size} bytes)`)
},
})
Streaming API
import tar from "tar"
import { createReadStream } from "node:fs"
// Stream extract:
createReadStream("archive.tar.gz")
.pipe(tar.extract({ cwd: "./output", strip: 1 }))
// Stream create:
tar.create({ gzip: true }, ["src/"])
.pipe(createWriteStream("dist.tar.gz"))
How npm uses node-tar
// npm pack creates .tgz files using node-tar:
// npm publish sends the .tgz to the registry
// npm install extracts .tgz into node_modules
// Simplified npm pack:
await tar.create(
{
gzip: true,
file: `${name}-${version}.tgz`,
prefix: "package/",
cwd: projectDir,
portable: true,
},
files, // Files listed in package.json "files" field
)
Feature Comparison
| Feature | nanotar | tar-stream | node-tar |
|---|---|---|---|
| Create tar | ✅ (memory) | ✅ (stream) | ✅ (file/stream) |
| Extract tar | ✅ (memory) | ✅ (stream) | ✅ (file/stream) |
| Gzip support | ❌ (manual) | ❌ (manual) | ✅ (built-in) |
| Brotli support | ❌ | ❌ | ✅ |
| Streaming | ❌ | ✅ | ✅ |
| File permissions | ❌ | ✅ | ✅ |
| Symlinks | ❌ | ✅ | ✅ |
| List contents | ❌ | ✅ | ✅ |
| Strip paths | ❌ | ❌ | ✅ |
| Filter files | ❌ | ❌ | ✅ |
| Edge runtime | ✅ | ❌ | ❌ |
| Dependencies | 0 | 0 | Few |
| Size | ~2KB | ~15KB | ~100KB |
| Weekly downloads | ~3M | ~15M | ~20M |
When to Use Each
Use nanotar if:
- Need simple in-memory tar creation/extraction
- Want zero dependencies and tiny bundle
- Building for edge runtimes (Workers, Deno)
- Creating small config or data bundles
Use tar-stream if:
- Building streaming tar pipelines
- Need low-level control over tar entries
- Processing large archives without loading into memory
- Building custom archive tools
Use node-tar if:
- Need full tar.gz/tar.br support out of the box
- Working with npm package tarballs
- Need file permissions, symlinks, and path stripping
- Building build tools or package managers
Streaming vs In-Memory: Choosing the Right Approach
The most important architectural decision when working with tar archives is whether you load the archive entirely into memory or stream it entry by entry. nanotar takes the in-memory approach: createTar returns a Uint8Array and parseTar accepts one. This works well for small archives — config bundles, test fixtures, deployment manifests — where the total size is measured in kilobytes or low megabytes. The simplicity is real: no stream event handlers, no next() callbacks, no pipeline plumbing.
tar-stream sits at the opposite end of the design spectrum. Its pack and extract objects are Node.js Transform streams, which means data flows through them without ever requiring the full archive in RAM. This matters when you're packing a directory with gigabytes of logs, or extracting a compressed database dump on a server with limited memory. Because tar-stream is a stream, it composes naturally with node:zlib via pipeline: pack → createGzip() → createWriteStream(). The low-level entry/header model gives you precise control over file metadata — mode bits, uid/gid, symlink targets, mtime — that nanotar intentionally omits.
node-tar blends both worlds. Its high-level API (tar.create, tar.extract) manages files on disk and handles compression internally, but it exposes a streaming mode too. npm relies on this balance: npm pack creates a .tgz by streaming the project files through node-tar with the portable: true option (which strips uid/gid for cross-platform reproducibility), then uploads that .tgz to the registry. npm install reverses the process with strip: 1 to remove the package/ prefix that npm adds. If your use case looks like "build a publishable artifact and extract it later", node-tar's design is purpose-built for exactly that workflow.
Migration and Ecosystem Considerations
If you're already using node-tar and want to reduce bundle size for an edge deployment, migrating to nanotar is straightforward for read-only text extraction: replace tar.extract with parseTar and access file.data directly. The friction appears when your archives contain symlinks, executables (mode bits), or files larger than available heap — nanotar can't represent any of those.
Migrating from tar-stream to nanotar is riskier at scale. tar-stream's streaming model is a fundamental architectural choice; if you've built a pipeline around streams, switching to an in-memory model may require buffering logic that defeats the purpose. Conversely, migrating from nanotar to tar-stream for larger archives is additive: the tar format is identical, so existing archives remain readable.
For tooling integrations: archiver uses tar-stream internally for its .tar and .tar.gz formats, so if you're using archiver you already have tar-stream transitively. Vite's dependency pre-bundling and several monorepo tools use node-tar for package tarballs. In the UnJS ecosystem, nanotar is the natural fit alongside defu, ofetch, and other zero-dep, runtime-agnostic utilities. When building a Cloudflare Worker that needs to pack a few config files for transfer, nanotar's ~2KB footprint and Uint8Array-native API integrate cleanly with the Workers runtime without any polyfill overhead.
Performance Characteristics in Practice
For in-memory operations on small archives (under 1MB), nanotar is the fastest because it avoids stream overhead entirely. Benchmarks on Node.js 22 show nanotar creating a 10-file archive roughly 3–5x faster than tar-stream for the same payload, simply because there are no stream events or backpressure calculations. node-tar's file I/O operations involve syscalls that add latency regardless of archive size, so it's typically the slowest for in-memory scenarios — but that's not its intended use case.
For large archives (100MB+), the relationship inverts. tar-stream's streaming model keeps memory usage flat regardless of archive size: memory consumption stays roughly proportional to the largest single file rather than the total archive. node-tar with gzip: true uses zlib streams internally and achieves similar memory efficiency while also handling Brotli compression natively (the only one of the three to support it). Trying to use nanotar on a 500MB archive will spike heap usage proportionally and may trigger GC pressure or OOM errors in constrained environments.
For CI and build tooling workloads — creating .tgz artifacts from build output — node-tar's filter and prefix options eliminate the need for pre-processing file lists. The newer: true extraction option (only overwrite files if the archive entry is newer) is particularly useful for incremental deployments. These ergonomic features explain why node-tar maintains the highest weekly download count despite being the heaviest dependency.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on nanotar v0.1.x, tar-stream v3.x, and tar (node-tar) v7.x.
Compare archive tools and developer utilities on PkgPulse →
In 2026, nanotar is the right choice for lightweight, dependency-free tar creation in modern ESM projects, while tar-stream remains the gold standard for streaming large archives in Node.js applications that need fine-grained control over each entry.
Security Considerations: Path Traversal in Tar Extraction
A historically important security issue with tar extraction is path traversal attacks, where a malicious archive contains entries with paths like ../../etc/passwd that, when extracted, write files outside the intended destination directory. All three libraries handle this differently. node-tar mitigates path traversal by default in its extract function — it strips leading / characters and resolves .. components relative to the extraction directory, preventing writes outside the target. tar-stream gives you raw entry headers including potentially malicious paths, so validation is your responsibility: if you're extracting user-supplied archives with tar-stream, you must check that path.resolve(destDir, entry.name).startsWith(destDir) before writing each entry. nanotar's parseTar returns entries with their raw paths, and since it operates in memory without writing to disk, path traversal is only a concern if you then write the extracted data to the filesystem yourself — the same manual validation applies. For production systems that extract untrusted archives (user uploads, downloads from external sources), node-tar's default sanitization is a meaningful safety net.
See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).