pkg vs Bun vs Deno
Three tools can turn JavaScript into a standalone executable today: @yao-pkg/pkg, bun build --compile, and deno compile. They solve the same problem three different ways — stock Node + VFS, JavaScriptCore + embedded FS, V8 + eszip. This page lays out the trade-offs, then runs the same project through all three to show what actually happens.
TL;DR
- Bun is the fastest to build and produces the smallest binary, but it targets Node-API addons indirectly (
node-pre-gyppackages need manual intervention) and runs on JavaScriptCore, not V8. - Deno auto-detects
node_modules, cross-compiles cleanly, and embeds assets via--include, but native addons need--self-extractingand a handful of Node built-ins are still stubbed. - pkg (SEA) runs on stock Node.js — same V8, same npm, same addon ABI. It ships the largest binary but is the only one with a path to zero-patch, official Node.js support via
node:vfs. - pkg (Standard) is the only option that can strip source code to V8 bytecode.
Feature matrix
| Dimension | @yao-pkg/pkg (Standard) | @yao-pkg/pkg (SEA) | bun build --compile | deno compile |
|---|---|---|---|---|
| Runtime | Patched Node.js (V8) | Stock Node.js (V8) | Bun (JavaScriptCore) | Deno (V8) |
| Bundle format | Custom VFS, compressed stripes | Single archive + @roberts_lando/vfs | $bunfs/ embedded FS | eszip in-memory VFS |
| Cross-compile | ⚠️ Node 22 regression, OK on 20/24 | ✅ all targets | ✅ all targets (icon/metadata local-only) | ✅ 5 targets, any host |
| Targets | linux/mac/win × x64/arm64 (+ linux-arm64-musl via build-from-source) | Same as Standard | linux/mac/win × x64/arm64 + linux musl + baseline/modern CPU variants | linux/mac/win × x64/arm64 (no musl, no win-arm64) |
Native .node addons | ✅ extracted to ~/.cache/pkg/<hash>/ | ✅ same cache strategy | ⚠️ direct require("./x.node") only — node-pre-gyp breaks bundling | ⚠️ requires --self-extracting for runtime dlopen |
| Bytecode / ahead-of-time compile | ✅ V8 bytecode, source-strippable | ❌ source in plaintext | ✅ --bytecode (JSC, CJS default, ESM since v1.3.9) | ❌ V8 code-cache only |
| Asset embedding | pkg.assets glob in package.json | pkg.assets glob | Extra entrypoints + --asset-naming="[name].[ext]" or import ... with { type: "file" } | --include path/ |
| npm compatibility | Full (it is Node) | Full (it is Node) | High (bundler-hostile patterns break) | Partial — 22/44 built-ins fully, some stubbed (node:cluster, node:sea, node:wasi, …) |
| ESM + top-level await | ⚠️ ESM → CJS transform can fail on TLA + re-exports | ✅ native ESM | ✅ native ESM | ✅ native ESM (primary) |
| Compression | ✅ Brotli / GZip per stripe | ❌ | ❌ | ❌ |
| Windows icon / metadata | ✅ (via rcedit at build) | ✅ | ✅ --windows-icon + rich metadata (disabled when cross-compiling) | ✅ --icon (cross-compile OK), no version/product fields |
| Security updates | Wait for pkg-fetch to rebase patches and republish | Same day Node.js releases | Tied to Bun release cadence | Tied to Deno release cadence |
| License model | MIT — runs stock V8 | MIT — runs stock V8 | MIT | MIT |
Architectural difference in one picture
All three produce one binary, all three expose embedded files to fs.readFileSync, all three launch in under a second. What differs is whose runtime you are shipping. pkg ships the same Node.js you tested against. Bun and Deno ship their own runtime with their own compat layer.
Native addon support — the real differentiator
Almost all non-trivial Node projects load something through dlopen: better-sqlite3, sharp, bcrypt, canvas, Prisma's query engine, etc. How each tool handles this is the most common reason a migration stalls.
| Scenario | pkg | Bun | Deno |
|---|---|---|---|
Direct require("./addon.node") | ✅ VFS extracts to ~/.cache/pkg/<sha256>/ on first load, SHA-verified, persists | ✅ documented | ✅ with --self-extracting |
node-pre-gyp / node-gyp-build indirection (sharp, better-sqlite3, bcrypt) | ✅ walker detects + bundles | ⚠️ docs say "you'll need to make sure the .node file is directly required or it won't bundle correctly" | ⚠️ needs --self-extracting to land the .node on disk before dlopen |
| ABI compatibility | Exactly matches the Node version in the binary | JSC's N-API compatibility layer — works for most but not all addons | Node-API compat layer in Deno 2+ |
For anything that uses node-pre-gyp today (which is a large fraction of native addons), Bun requires you to pin the specific .node file and require it directly. pkg's walker and Deno's extraction do this transparently.
Claude Code case study
We took the last pure-JS release of @anthropic-ai/claude-code (v1.0.100) — an ESM-only, 9 MB minified single-file CLI that uses top-level await, loads yoga.wasm at startup via require.resolve, ships ripgrep as a vendored binary, and has sharp as an optional native dep. Perfect stress test.
All numbers on Linux x64, Node 22.22.1, Bun 1.3.12, Deno 2.7.12:
| Command | Outcome | Binary | First --version |
|---|---|---|---|
pkg cli.js -t node22-linux-x64 (Standard) | ❌ Failed to generate V8 bytecode — top-level await + ESM re-exports block pkg's CJS transform. Warning suggests --fallback-to-source or --sea | — | — |
pkg cli.js --fallback-to-source -t node22-linux-x64 (Standard) | ❌ Source-as-fallback path still loads as CJS; ERR_MODULE_NOT_FOUND at runtime | 83 MB | — |
pkg . --sea -t node22-linux-x64 (SEA, no pkg.assets) | ❌ Runs, but Cannot find module './yoga.wasm' — walker can't see dynamic resolves | 134 MB | — |
pkg . --sea with "pkg": {"assets": ["yoga.wasm","vendor/**/*"]} | ✅ | 194 MB | 979 ms |
bun build --compile cli.js | ❌ Runtime: Cannot find module './yoga.wasm' from '/$bunfs/root/cc-bun' | 108 MB | — |
bun build --compile cli.js yoga.wasm --asset-naming="[name].[ext]" | ✅ | 108 MB | 797 ms |
bun build --compile --bytecode --format=esm … (+ asset flag) | ✅ | 190 MB | 828 ms |
bun build --compile --bytecode --minify … | ❌ Expected ";" but found ")" — parser regression on the already-minified ESM | — | — |
deno compile --allow-all --include yoga.wasm cli.js | ✅ auto-included the entire node_modules/ | 184 MB | 1256 ms |
What this reveals
- Nothing in the matrix "just works." Every tool needs a hint about
yoga.wasm— the symptoms just differ. pkg silently succeeds with a broken binary if you forgetpkg.assets; Bun silently succeeds but renames the asset unless you pass--asset-naming; Deno auto-hooversnode_modules(which is convenient but ballooned the binary to 184 MB). - Bun produced the smallest working binary (108 MB) and fastest cold start (797 ms) — at the cost of running claude-code on JavaScriptCore, not the V8 it was tested against.
- Bun's
--bytecodenearly doubled the binary (190 MB) without improving startup for this async-heavy CLI. The docs warn that bytecode output can be ~8× larger per module and skips async/generator/eval, which matches what we saw. - pkg Standard mode is the wrong choice for ESM-first apps with top-level await. Use
--sea. This is explicitly why SEA mode exists and is the recommended default going forward. - Deno's startup penalty (~1.3 s vs ~0.8 s) is explained by the permission system init + Node compat layer boot; the runtime still parses source at launch (no ahead-of-time V8 snapshot of user code).
Hello-world baseline for reference
Same build machine, same Linux x64 target, trivial console.log script:
| Tool | Binary | Best of 5 startup |
|---|---|---|
pkg Standard | 71 MB | 40 ms |
pkg SEA | 119 MB | 60 ms |
Bun --compile | 96 MB | 10 ms |
Deno compile | 93 MB | 50 ms |
Cross-compilation
All three can build Linux / macOS / Windows × x64 / arm64 from any host. The edges differ:
- Bun supports the widest target list — including
bun-linux-x64-musl,bun-linux-arm64-musl, and CPU-tier splits (baselinefor pre-2013 chips,modernfor Haswell+). Windows icon / version / metadata flags are disabled when cross-compiling. - Deno supports 5 targets (
x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-apple-darwin,aarch64-apple-darwin,x86_64-pc-windows-msvc) — no musl target, no Windows arm64. - pkg cross-compiles cleanly in SEA mode on Node 20 and Node 24. Node 22 Standard mode has a known regression.
Bytecode / source protection
The only tool that can strip source code from the binary is @yao-pkg/pkg in Standard mode. The fabricator compiles JS to V8 bytecode, stores it as STORE_BLOB with sourceless: true, and throws the source away — a byte dump of the executable contains bytecode only. See Bytecode.
Everything else stores source:
- pkg SEA stores JS source in the archive (SEA has no
sourcelessequivalent yet). - Bun
--bytecodeemits.jscalongside.js— the.jsfallback stays because bytecode can't represent async/generator/eval bodies. - Deno compiles at runtime; source is embedded in the eszip.
If source protection is a hard requirement, pkg Standard is the only option. For everything else, obfuscate first and accept that "compiled" ≠ "secret."
When to pick which
- You need source protection →
pkgStandard (V8 bytecode with source stripping). - You're on stock Node.js and want security fixes the day Node releases them →
pkg --sea. - You want the smallest binary and fastest cold start, your stack is all-ESM, no
node-pre-gypaddons, and JavaScriptCore's npm compat matrix covers your deps →bun build --compile. - You're writing a Deno-first app that also uses some
npm:specifiers →deno compile. - You have
sharp/better-sqlite3/bcrypt/ anything vianode-pre-gyp→pkg(either mode) — no flag gymnastics, no first-run extraction tax. - You need musl / Alpine →
pkg(vialinux-arm64-muslbuild from source) orbun(bun-linux-x64-musl,bun-linux-arm64-musl).
Final verdict
There is no single winner. Each tool owns a different lane.
Lane-by-lane
Bun wins on raw numbers. 108 MB binary, 797 ms cold start for claude-code, and the widest target list (musl + baseline/modern CPU variants). Best default for greenfield CLIs with no
node-pre-gypnative addons when size and cold start are the KPIs. The trade is a runtime swap (JavaScriptCore instead of V8) and a handful of bundler-hostile footguns.Deno wins on DX for Deno-first apps.
--includeand automaticnode_modulesdetection made it the lowest-effort build in the matrix. Pick it if you already rundenoday-to-day — not as a drop-in for an existing Node project. Startup is the slowest here (1.26 s for this workload) and a handful of Node built-ins are stubbed (node:cluster,node:sea,node:wasi).pkg --seais the safest default for shipping a real Node.js app. It runs the stock Node binary you tested against — same V8, same N-API ABI, same npm semantics. Native addons withnode-pre-gyp(sharp,better-sqlite3,bcrypt) bundle transparently. The cost is the largest binary and no compression. When Node ships a CVE fix, your next build has it the same day.pkgStandard is the only answer to one specific question: can the shipped binary contain no recoverable source code? V8 bytecode withsourceless: trueis the only mechanism any tool here offers. Nothing else is in the running if IP protection is a hard requirement.
If we had to name one default
For a production Node.js app in 2026: pkg --sea. The reason isn't performance or size — Bun wins those. The reason is you are not swapping runtimes. You are wrapping the Node.js you already tested against. "Ships stock Node.js" is a boring feature, and boring is exactly what you want in production.
Be critical — what these claims don't prove
This section is here because picking a bundler on marketing copy is how people ship surprises. Some of the claims above deserve scrutiny.
"V8 beats JavaScriptCore"
We never make that claim, and you should not read it into "pkg ships V8." JavaScriptCore is a peer-grade engine — it ships in Safari, powers billions of devices, and routinely wins specific benchmarks against V8. Bun's own perf work shows JSC holding its own or ahead on many JS-heavy workloads.
The real argument for V8 here is not "faster," it's "identical to what you tested":
- Every npm package was tested under V8/Node. Running it on JSC means trusting Bun's Node-API and built-in compat layers. Those layers are large, well-maintained, and broken in a small number of places you will only discover at runtime.
- Obscure V8-specific behaviors (stack trace formatting,
Error.prepareStackTrace,%Optnatives,--max-old-space-sizesemantics, async-hook timings) sometimes leak into production code, especially in observability and framework internals. Those bugs surface on the engine swap, not before.
This is a compatibility risk axis, not an engine-quality axis. On a greenfield codebase where you control every dep, the argument evaporates.
"Bytecode protects your source"
Overstated. V8 bytecode is not encryption — it's a serialization format with public decompilers (bytenode, v8-decompile-bytecode, several research tools). It raises the bar past "strings(1) and read the secret," but a determined reverse engineer will get readable JS back. Treat pkg Standard's source-stripping as a speed bump, not a vault. If you need real IP protection, you need obfuscation + licensing + server-side secrets — none of these tools solve that.
"pkg Standard ships stock Node.js"
No — only pkg --sea ships stock Node.js. Standard mode ships a patched Node.js distributed through pkg-fetch (~600–850 lines of patches per release). That is precisely why SEA mode exists and why the project is moving toward it. Do not conflate the two.
"The claude-code numbers prove Bun is faster / smaller"
n=1 on a single package on a single Linux x64 host. The conclusion scales narrowly:
- The Bun number is flattering partly because claude-code's
cli.jsis already a single pre-bundled file — Bun's bundler has almost nothing to do. A project with a sprawling dep graph and lots of dynamicrequirewill look different. - The Deno 1.26 s cold start includes permission-system init and the Node compat boot; for a long-running process (server, daemon) it's amortized to zero. Cold start matters for CLIs, not for servers.
- pkg SEA's 194 MB is largely the uncompressed SEA archive plus the Node binary. Standard mode with Brotli would be smaller, but Standard couldn't build this package at all — so the comparison is apples-to-oranges.
- Bun's
--bytecodedoubled the binary on this workload because it's async-heavy ESM with lots of generator /evalsites that fall back to shipping source alongside bytecode. A synchronous CJS-heavy app would see a different (smaller) delta.
Treat the matrix as "here is what these tools actually do, and here is one realistic data point." Not as a benchmark.
"You get Node security fixes the day they ship"
True for pkg --sea — it downloads the released Node binary from the official Node.js project. False for pkg Standard — you wait for pkg-fetch to rebase the patch stack. Also false for Bun and Deno — you wait for the Bun/Deno maintainers to pick up the V8 / JSC fix and cut a release. Bun and Deno generally move fast, but they are not on Node's release cadence.
"Bun and Deno can't bundle sharp / better-sqlite3"
Softer than the matrix implies. Both can — you just have more steps. Bun wants the .node file required directly; Deno wants --self-extracting so the addon lands on disk before dlopen. For a handful of addons you control, both work. The advantage pkg holds is that the walker does this detection automatically for the long tail of node-pre-gyp packages without per-addon configuration.
TL;DR of the caveats
These tools are closer than the headline table suggests. Pick on what you'll regret later, not on benchmark deltas:
- Regret running on a non-V8 engine? → pkg
- Regret your binary being 80 MB heavier than it needed to be? → Bun
- Regret that new Node CVEs take a week to land? → pkg SEA
- Regret that your source is shippable with
strings(1)? → pkg Standard (with obfuscation as belt-and-braces)
Reproducing the numbers
# Install toolchain
npm install -g @yao-pkg/pkg
curl -fsSL https://bun.sh/install | bash
curl -fsSL https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \
-o /tmp/deno.zip && unzip /tmp/deno.zip -d ~/.deno/bin/
# Test project
mkdir cc-test && cd cc-test && npm init -y
npm install --ignore-scripts @anthropic-ai/claude-code@1.0.100
# Build (pkg SEA — needs pkg.assets config; see Detecting assets)
node -e 'const p=require("./node_modules/@anthropic-ai/claude-code/package.json"); \
p.pkg={assets:["yoga.wasm","vendor/**/*"]}; \
require("fs").writeFileSync("node_modules/@anthropic-ai/claude-code/package.json", JSON.stringify(p,null,2))'
pkg ./node_modules/@anthropic-ai/claude-code --sea -t node22-linux-x64 -o cc-pkg
# Build (bun)
bun build --compile --asset-naming="[name].[ext]" \
node_modules/@anthropic-ai/claude-code/cli.js \
node_modules/@anthropic-ai/claude-code/yoga.wasm \
--outfile cc-bun
# Build (deno)
deno compile --allow-all \
--include node_modules/@anthropic-ai/claude-code/yoga.wasm \
--output cc-deno \
node_modules/@anthropic-ai/claude-code/cli.jsFurther reading
- pkg Architecture — Standard vs SEA internals
- pkg SEA vs Standard — when to pick which pkg mode
- Detecting assets — why the walker misses dynamic paths and how
pkg.assetsfixes it - Bun — Single-file executable
- Deno —
deno compile node:vfs— the future of SEA asset embedding
