Skip to content

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-gyp packages 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-extracting and 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 --compiledeno compile
RuntimePatched Node.js (V8)Stock Node.js (V8)Bun (JavaScriptCore)Deno (V8)
Bundle formatCustom VFS, compressed stripesSingle archive + @roberts_lando/vfs$bunfs/ embedded FSeszip in-memory VFS
Cross-compile⚠️ Node 22 regression, OK on 20/24✅ all targets✅ all targets (icon/metadata local-only)✅ 5 targets, any host
Targetslinux/mac/win × x64/arm64 (+ linux-arm64-musl via build-from-source)Same as Standardlinux/mac/win × x64/arm64 + linux musl + baseline/modern CPU variantslinux/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 compileV8 bytecode, source-strippable❌ source in plaintext--bytecode (JSC, CJS default, ESM since v1.3.9)❌ V8 code-cache only
Asset embeddingpkg.assets glob in package.jsonpkg.assets globExtra entrypoints + --asset-naming="[name].[ext]" or import ... with { type: "file" }--include path/
npm compatibilityFull (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 updatesWait for pkg-fetch to rebase patches and republishSame day Node.js releasesTied to Bun release cadenceTied to Deno release cadence
License modelMIT — runs stock V8MIT — runs stock V8MITMIT

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.

ScenariopkgBunDeno
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 compatibilityExactly matches the Node version in the binaryJSC's N-API compatibility layer — works for most but not all addonsNode-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:

CommandOutcomeBinaryFirst --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 runtime83 MB
pkg . --sea -t node22-linux-x64 (SEA, no pkg.assets)❌ Runs, but Cannot find module './yoga.wasm' — walker can't see dynamic resolves134 MB
pkg . --sea with "pkg": {"assets": ["yoga.wasm","vendor/**/*"]}194 MB979 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 MB797 ms
bun build --compile --bytecode --format=esm … (+ asset flag)190 MB828 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 MB1256 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 forget pkg.assets; Bun silently succeeds but renames the asset unless you pass --asset-naming; Deno auto-hoovers node_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 --bytecode nearly 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:

ToolBinaryBest of 5 startup
pkg Standard71 MB40 ms
pkg SEA119 MB60 ms
Bun --compile96 MB10 ms
Deno compile93 MB50 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 (baseline for pre-2013 chips, modern for 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 sourceless equivalent yet).
  • Bun --bytecode emits .jsc alongside .js — the .js fallback 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 protectionpkg Standard (V8 bytecode with source stripping).
  • You're on stock Node.js and want security fixes the day Node releases thempkg --sea.
  • You want the smallest binary and fastest cold start, your stack is all-ESM, no node-pre-gyp addons, and JavaScriptCore's npm compat matrix covers your depsbun build --compile.
  • You're writing a Deno-first app that also uses some npm: specifiersdeno compile.
  • You have sharp / better-sqlite3 / bcrypt / anything via node-pre-gyppkg (either mode) — no flag gymnastics, no first-run extraction tax.
  • You need musl / Alpinepkg (via linux-arm64-musl build from source) or bun (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-gyp native 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. --include and automatic node_modules detection made it the lowest-effort build in the matrix. Pick it if you already run deno day-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 --sea is 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 with node-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.

  • pkg Standard is the only answer to one specific question: can the shipped binary contain no recoverable source code? V8 bytecode with sourceless: true is 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, %Opt natives, --max-old-space-size semantics, 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.js is already a single pre-bundled file — Bun's bundler has almost nothing to do. A project with a sprawling dep graph and lots of dynamic require will 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 --bytecode doubled the binary on this workload because it's async-heavy ESM with lots of generator / eval sites 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

sh
# 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.js

Further reading

Released under the MIT License.