Bun (JavaScript/TypeScript)
A speed-first, all-in-one JavaScript runtime + toolkit whose bun install package manager treats npm workspaces as a native monorepo primitive — pairing glob-based topology discovery, the workspace: and catalog: protocols, a choice of hoisted (Yarn-style) or isolated (pnpm-style) node_modules layouts, and a parallel, dependency-ordered bun run --filter task runner — all implemented in Rust (the project completed its Zig→Rust rewrite) for sub-second installs.
| Field | Value |
|---|---|
| Language | Rust (package-manager/runtime core; the Zig→Rust rewrite that began May 2026 has landed — the install/runtime sources are now *.rs, with no *.zig left in src/) |
| License | MIT (Bun itself; bundled JavaScriptCore is LGPL/BSD) |
| Repository | oven-sh/bun |
| Documentation | bun.com/docs · Workspaces · Isolated installs · Filter |
| Category | JS/TS Package Manager |
| Workspace model | Virtual or root package: a root package.json with a glob workspaces array; members linked via the workspace: protocol |
| First released | 0.1.0 on July 5, 2022 (Jarred Sumner); 1.0 on September 8, 2023 |
| Latest release | 1.4.0 (the 1.4.x line; 1.3.14 is the prior stable tag) |
Latest release:
1.4.0(June 2026). Key monorepo milestones: the text lockfilebun.lockshipped in1.1.39(Dec 2024) and became the default in1.2.0(Jan 2025), superseding the binarybun.lockb; catalogs and isolated installs (pnpm-style, default for new workspaces) also landed in the1.2.xline. In December 2025 the project joined Anthropic; the Zig→Rust rewrite that began in May 2026 has since landed in-tree — the install/runtime sources quoted below are now Rust (*.rs), with no*.zigremaining undersrc/. (File paths and snippets here are pinned to commitdf92f8f.)
Overview
What it solves
Bun's thesis is that the JavaScript toolchain — runtime, bundler, test runner, and package manager — is too slow and too fragmented, and that a single native binary can replace node + npm/yarn/pnpm + webpack + jest at a fraction of the latency. For the monorepo case specifically, bun install reads npm workspaces directly and installs the whole repo in one pass. From the workspaces guide:
"If package
bdepends ona,bun installwill install your localpackages/adirectory intonode_modulesinstead of downloading it from the npm registry. … Ifaandbshare a common dependency, it will be hoisted to the rootnode_modulesdirectory. This reduces redundant disk usage and minimizes 'dependency hell' issues associated with having multiple versions of a package installed simultaneously."
So Bun occupies the same JS/TS package-manager niche as npm, Yarn Berry, and pnpm — but with two differentiators. First, raw speed: the original binary lockfile let bun install benchmark, on the project's own numbers, "33.28 ± 4.13 times faster than … npm install" (text-lockfile blog). Second, a model toggle: since 1.2, Bun ships both the classic hoisted installer and a pnpm-like isolated installer, and auto-selects between them based on whether the project is a workspace.
NOTE
Like pnpm, npm, and Yarn Berry, Bun is a package manager with a workspace + task layer, not a build-graph engine. bun run --filter schedules member scripts in dependency order, but it has no per-task input hashing, no build/test result cache, and no remote execution (see Caching & Remote Execution). Teams wanting memoized, affected-only builds layer Turborepo or Nx on top — both of which support Bun as the underlying installer.
Design philosophy
Three commitments shape Bun's monorepo surface, each restated as a column of the trade-offs table:
- Speed is the feature. Bun is written in a systems language (Rust, after the 2026 rewrite from Zig) precisely so the package manager is I/O-bound, not CPU-bound; the binary lockfile, the global cache with hard-link/clonefile materialization, and a parallelized installer all serve the same end. The text lockfile was adopted in
1.2only after the team was satisfied it kept "bun installalmost 30x faster than npm" (text-lockfile blog). - Drop-in npm compatibility, then improve. Bun reads the standard
package.jsonworkspacesfield, theworkspace:protocol, andnode_modules— there is no proprietary manifest. Improvements (catalogs, isolated installs, the text lockfile) are added on top of that compatible base so migration isbun installin an existing repo. - Two isolation models, auto-selected. Rather than force one
node_moduleslayout, Bun offers--linker hoisted(flat, Yarn-style) and--linker isolated(symlinked, pnpm-style), defaulting toauto: isolated for new workspaces, hoisted for single packages. From the isolated-installs docs: "Isolated installs create a non-hoisted dependency structure where packages can only access their explicitly declared dependencies."
How it works
The install pipeline and the two linkers
bun install resolves the dependency graph into a lockfile, then materializes node_modules through one of two linkers, selected in install_with_manager.rs:
// src/install/PackageManager/install_with_manager.rs (abridged)
let mut linker = manager.options.node_linker;
loop {
match linker {
NodeLinker::Auto => match config_version {
ConfigVersion::V0 => { linker = NodeLinker::Hoisted; continue; } // legacy bun.lockb projects
ConfigVersion::V1 => { // text bun.lock projects
if !load_result.migrated_from_npm()
&& manager.lockfile.workspace_paths.len() > 0 {
linker = NodeLinker::Isolated; continue; // new workspace → pnpm-style
}
linker = NodeLinker::Hoisted; continue; // single package → flat
}
},
NodeLinker::Hoisted => break 'install_summary install_hoisted_packages(...)?,
NodeLinker::Isolated => break 'install_summary install_isolated_packages(...)?,
}
}- Hoisted (
hoisted_install.rs) builds one mostly-flatnode_modulestree at the workspace root, deduplicating shared versions upward — the Yarn-Classic / npm model, and Bun's original behavior. - Isolated (
isolated_install.rs) builds a non-hoisted layout undernode_modules/.bun/<name>@<version>/, with each package'snode_modulescontaining symlinks only to its declared dependencies — the pnpm model. From the docs: "All packages are installed innode_modules/.bun/package@version/directories" with "Top-levelnode_modulescontains symlinks pointing to the central store."
The CLI flag is documented in CommandLineArguments.rs (--linker <STR> — "one of 'isolated' or 'hoisted'"); the default is NodeLinker::Auto.
The isolated store and its content-addressed dedup key
The isolated installer's Store (isolated_install/Store.rs) is the most sophisticated piece. It does a DFS over the lockfile to build a tree of Nodes, then computes a content hash per entry so that identical subtrees share one materialized copy in a global virtual store. From the source comment:
// src/install/isolated_install/Store.rs
/// Content hash of (package + sorted resolved dependency global-store keys),
/// used to key the global virtual store at `<cache>/links/<storepath>-<entry_hash>/`.
/// Two projects that resolve the same package to the same dependency closure
/// share one global-store entry; if a transitive dep version differs, the
/// hash differs and a new global-store entry is created. Computed after the
/// store is built (see `computeEntryHashes`).
pub entry_hash: u64,Materialization into that store is done by clonefile / hard-link / copy (the isolated_install/ directory carries FileCloner, Hardlinker, FileCopier, and Symlinker), so a package's bytes are written once and reflinked/hard-linked everywhere it is needed — the same disk-saving trick as pnpm's content-addressed store, here keyed by the resolved dependency closure so peer-dependency variants get distinct entries.
The lockfile: binary bun.lockb → text bun.lock
Bun originally shipped a binary lockfile (bun.lockb) for parse speed. Since 1.2 the default is a text lockfile, bun.lock (lockfile/bun.lock.rs), a JSONC document whose version enum has since grown a V2:
// src/install/lockfile/bun.lock.rs
#[repr(u32)]
pub enum Version {
V0 = 0,
V1 = 1, // fixed unnecessary listing of workspace dependencies
V2 = 2, // stricter parsing: integrity for off-registry tarballs, safe git/github tag paths
}
impl Version {
pub const CURRENT: Version = Version::V2;
}V2 only tightens parse-time validation on otherwise-identical content (an already-written v0/v1 lockfile keeps loading), so the on-disk shape readers see is unchanged. It is a single, unified lockfile at the workspace root covering every member. Its "workspaces" object keys each member by path (the root is the empty-string key ""), and a top-level "catalog" / "catalogs" block records catalog versions. There is no per-member lockfile.
The workspace: and catalog: protocols
Dependency-specifier parsing (dependency.rs) recognizes both protocols as first-class tags. workspace: is detected by prefix and resolved against in-repo members:
// src/install/dependency.rs — Tag::infer (abridged)
b'w' => { if dependency.starts_with(b"workspace:") { return Tag::Workspace; } }
b'c' => { if dependency.starts_with(b"catalog:") { return Tag::Catalog; } }
// ...
Tag::Workspace => { // value is the range after "workspace:"
let mut input = dependency;
if input.starts_with(b"workspace:") {
input = &input[b"workspace:".len()..];
}
// ...
}During development a workspace: dependency is satisfied by the local member directory (no registry download); on bun publish the specifier is rewritten to a concrete range — workspace:* → the member's exact version, workspace:^/workspace:~ → the caret/tilde range (workspaces guide). The catalog: protocol (tag Catalog = 9) is parsed identically: catalog: references the default catalog and catalog:<group> a named one, both defined once in the root package.json ("catalog" and "catalogs" fields) and substituted at install time. This is Bun's pnpm-style workspace dependency registry for killing version drift.
Workspace discovery
WorkspaceMap::process_names_array (lockfile/Package/WorkspaceMap.rs) reads the root package.json workspaces field, globs each pattern to member directories, reads each member's package.json for its name/version, and builds the member map. Both manifest shapes are accepted — a bare array, or the Yarn-Classic object form {"packages": [...]} (handled in filter_arg.rs):
// src/runtime/cli/filter_arg.rs — get_candidate_package_patterns (abridged)
let json_array = match prop.expr.data {
ExprData::EArray(arr) => arr, // "workspaces": [ ... ]
ExprData::EObject(obj) => match (*obj).get(b"packages") { // "workspaces": { "packages": [ ... ] }
Some(packages) => /* the inner EArray */ ...,
None => break 'walk,
},
_ => break 'walk,
};The five dimensions
1. Workspace Declaration & Topology
Bun uses the standard npm workspaces field in the root package.json — no separate manifest (contrast pnpm's dedicated pnpm-workspace.yaml). The value is an array of globs, with negative patterns for exclusion (workspaces guide):
{
"name": "monorepo-root",
"private": true,
"workspaces": [
"packages/**",
"!packages/**/test/**",
"!packages/**/template/**"
]
}The Yarn-Classic object form "workspaces": { "packages": ["packages/*"] } is also accepted. Discovery is glob-based: WorkspaceMap::process_names_array expands each pattern to directories containing a package.json, reads each member's name, and the inter-member edges are derived from each member's dependencies/devDependencies that resolve to a workspace name. The root is typically a virtual root ("private": true, only orchestration scripts), though a root package may itself be a member. A bun install from anywhere in the tree walks up to the nearest package.json carrying workspaces to find the root (get_candidate_package_patterns ascends parent directories).
2. Dependency Handling & Isolation
This is where Bun is unusually flexible — it implements both mainstream isolation models and picks per project:
Linker (--linker) | node_modules shape | Phantom deps | Default when |
|---|---|---|---|
hoisted | Flat, shared versions hoisted to root (npm/Yarn-Classic model) | Possible | single packages; legacy v0 lockfile |
isolated | node_modules/.bun/<pkg>@<ver>/ + symlinks (pnpm model) | Prevented | new workspaces with v1 bun.lock |
auto (default) | Chooses isolated for new workspaces, else hoisted | — | always, unless overridden |
- Cross-member local refs use the
workspace:protocol; in dev the member is the on-disk directory (symlinked under isolated, linked-in-place under hoisted), rewritten to a real range on publish. - One unified root lockfile (
bun.lock) resolves all members together — no per-member lockfiles. - Isolated mode prevents phantom dependencies exactly as pnpm does — "Packages cannot accidentally import dependencies they haven't declared" (isolated docs) — because a member's
node_modulescontains only symlinks to what it declared. The architectural nuance Bun calls out: "Bun uses symlinks innode_moduleswhile pnpm uses a global store with symlinks" — Bun's central store lives undernode_modules/.bun/per install, content-keyed by the resolved dependency closure (see How it works). - Catalogs (
catalog:/catalog:<group>) centralize shared version ranges in the root manifest, the same anti-drift mechanism as pnpm's catalogs and Gradle version catalogs.
3. Task Orchestration & Scheduling
bun run --filter <pattern> <script> (and the shorthand bun --filter …) builds a member-level DAG and runs the matching script across members in parallel by default, respecting dependency order. From the filter docs: "Bun will respect package dependency order when running scripts" — a dependent "only start[s] running once" its workspace dependencies finish. The scheduler is in filter_run.rs:
// src/runtime/cli/filter_run.rs (abridged) — build the dependents graph
for handle in state.handles.iter_mut() {
for name in &handle.config.deps {
if let Some(pkgs) = map.get(&**name) { // is the dep a workspace member?
for &dep in pkgs {
unsafe { (*dep).dependents.push(std::ptr::from_mut(handle)) }; // edge: dep → handle
handle.remaining_dependencies += 1; // Kahn-style in-degree
}
}
}
}
// ... a process starts only when remaining_dependencies == 0; on exit it
// decrements each dependent's counter and starts any that reach zero.
for handle in state.handles.iter_mut() {
if handle.remaining_dependencies == 0 { handle.start()?; }
}It is a classic Kahn topological execution: each member-script ProcessHandle tracks remaining_dependencies; a script is spawned when that hits zero, and on exit it releases its dependents. Independent legs run concurrently (each spawned as a real OS process via bun.spawn), with a live multi-process terminal UI multiplexing their output.
Cycle handling is pragmatic — a DFS (has_cycle) checks the graph, and if any cycle exists, dependency ordering is dropped entirely (everything runs unordered):
// src/runtime/cli/filter_run.rs
if has_cycle_flag { // give up on ordering, run all at once
for handle in state.handles.iter_mut() {
handle.dependents.clear();
handle.remaining_dependencies = 0;
}
}pre/post script ordering within a member is wired as extra edges after the cycle check, so lifecycle hooks stay ordered even in a cyclic graph.
| Capability | Bun answer |
|---|---|
| Task/target DAG | Member DAG (workspace → workspace) for the named script; not a fine-grained per-target graph |
| Concurrent execution | Yes — parallel by default; --parallel / --sequential flags; independent legs spawn together |
| Ordering controls | Topological by default (remaining_dependencies / Kahn); --sequential forces serial; --elide-lines for UI |
| Change detection | Not in bun run --filter (no [git-ref] selector); bun test --changed does git-diff affected tests |
Cross-script dependsOn | No explicit per-task dependsOn; ordering is inferred from the workspace dependency graph only |
So, like pnpm, Bun schedules packages, not arbitrary tasks; there is no turbo.json-style dependsOn pipeline.
4. Caching & Remote Execution
Install caching: strong. Task-result caching: none.
- A global package cache (default
~/.bun/install/cache) stores extracted package versions once; installs materialize from it via clonefile/hard-link rather than re-downloading or re-copying — the dominant install-time cache. - The isolated store content-addresses each package by its resolved dependency closure, so identical subtrees share one on-disk entry (
computeEntryHashes, above). bun.lockgives reproducible resolution;--frozen-lockfileenforces it in CI.
There is no build/test result cache, no per-task input hashing, and no remote execution / REAPI backend. Bun never asks "have I already run this member's test for this input?" — that boundary is owned by Turborepo (local + remote task cache), Nx (computation cache + Nx Cloud), and the polyglot engines (Bazel, Buck2 with BuildBuddy/NativeLink remote execution). The nearest Bun comes to "skip unchanged work" is bun test --changed, which is affected-test selection, not memoization (next section).
5. CLI / UX Ergonomics
Bun's member-slicing centers on one --filter flag with a name-or-path glob grammar (filter_arg.rs, FilterSet):
- Name patterns match the
package.jsonname:--filter '*'(all),--filter 'pkg*'(prefix),--filter '@scope/app'(exact). "Name patterns select packages based on the package name" (filter docs). - Path patterns start with
./and match member directories:--filter './packages/cli'. "Path patterns are specified by starting the pattern with./". - Negation via
!:bun install --filter 'pkg-*' --filter '!pkg-c'. --filter '*'/--filter '**'setsmatch_all(broadcast to every member).
// src/runtime/cli/filter_arg.rs — FilterSet::init classifies each pattern
let is_path = !filter_utf8.is_empty() && filter_utf8[0] == b'.'; // "./…" → path glob
// else → name glob; "*"/"**" → match_allThe command boundary:
- Targeted —
bun run --filter '@scope/app' build, orbun --filter './packages/*' dev. - Broadcast —
bun run --filter '*' testruns the script in every member (topologically). - Install scoping —
bun install --filterlimits which members' deps are installed.
IMPORTANT
Unlike pnpm's --filter grammar, Bun's bun run --filter has no dependents/dependencies closure operators (pkg..., ...pkg, ^) and no [git-ref] changed-since selector. Affected-detection lives in a different command — bun test --changed — which is git-aware and graph-aware (below). This is a real gap versus pnpm/Turborepo for "run X only on what changed and its dependents".
bun test --changed — git + module-graph affected detection
The one place Bun does true affected-detection is its test runner (test/ChangedFilesFilter.rs), vitest-compatible:
// src/runtime/cli/test/ChangedFilesFilter.rs (module header)
//! 1. Ask git for the set of changed files relative to HEAD (uncommitted,
//! staged, and untracked) or relative to a user-supplied ref.
//! 2. Run the bundler over every discovered test file ... to produce the full
//! parse graph (transitive imports) without linking or emitting code.
//! 3. Starting from each changed file ..., walk the reverse import edges to find
//! every test entry point that can reach it.So bun test --changed runs git diff --name-only (uncommitted/staged/untracked, or against --changed <ref>), builds the module import graph, and runs only the tests whose transitive imports reach a changed file — finer-grained (file-level, via the real import graph) than pnpm's member-level [git-ref] filter, but scoped to testing rather than general task selection.
Strengths
- Speed-first installs — native (Rust) implementation, a global cache materialized by clonefile/hard-link, and the binary-then-text lockfile make
bun installamong the fastest in this survey (self-reported ~30x npm). - Two isolation models, auto-selected — ships both a hoisted (Yarn-style) and an isolated (pnpm-style)
node_moduleslinker, defaulting to isolated for new workspaces and hoisted for single packages. - Phantom-dependency prevention — isolated mode enforces "import only what you declared," with a content-addressed store keyed by the resolved dependency closure.
- Standard-manifest compatibility — reads npm
workspaces,workspace:, andnode_modulesdirectly; migrating an existing monorepo is justbun install. - Catalogs — root-level
catalog:/catalogs:central version registry abolishes cross-member version drift, as in pnpm/Gradle. - Parallel, topological task runner —
bun run --filterspawns member scripts concurrently in dependency order with a live multi-process UI; cycles degrade gracefully to unordered. - Unified text lockfile — one human-reviewable
bun.lockat the root for the whole workspace. - Graph-aware affected tests —
bun test --changedwalks the real import graph from git-changed files.
Weaknesses
- No task-result cache, no remote execution — Bun runs scripts; it does not memoize outputs. Teams add Turborepo/Nx for incrementality and remote caching — the single largest gap vs. dedicated orchestrators.
- Thin
--filtergrammar forrun— no dependents/dependencies closure operators and no[git-ref]selector inbun run --filter(cf. pnpm); affected-detection exists only forbun test. - Member-level DAG only — orchestration granularity is the workspace member, not the individual task; no
dependsOnpipeline. - Cycle handling is coarse — any dependency cycle disables ordering for the entire run rather than reporting/erroring on the offending edge.
- Maturity / churn — fast-moving (
workspace:, catalogs, isolated installs, text lockfile all arrived across1.1→1.2); the just-completed Zig→Rust rewrite and the Anthropic acquisition add organizational flux. - Symlink friction (isolated mode) — like pnpm, the symlinked layout can trip symlink-unaware tooling, Windows without Developer Mode, and some Docker/overlay filesystems;
--linker hoistedis the escape hatch.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Native (Rust) implementation, global cache + clonefile/hard-link | Make installs I/O-bound, not CPU-bound; ~30x npm on the project's own benchmarks | A large native codebase; a recent whole-codebase Zig→Rust rewrite; fewer external contributors than JS tools |
Reuse npm workspaces / package.json (no proprietary manifest) | Zero-friction migration; reads existing monorepos as-is | Inherits npm's quirks; no place for richer first-class workspace metadata |
Two linkers (hoisted + isolated), auto default | Offer both mainstream models; safe defaults (isolated for new workspaces) | Two materialization code paths to maintain; behavior differs by project age / lockfile version |
| Isolated store content-keyed by resolved dependency closure | Dedup identical subtrees on disk; correct peer-dependency variants | Symlink-layout friction on some platforms; more complex than a flat tree |
catalog: central version registry in root manifest | One source of truth for shared ranges; abolishes drift | Another concept to learn; only as good as discipline in using it |
Single text lockfile bun.lock at the root (v2) | Human-reviewable, diffable, one resolution for the whole workspace | Slower to parse than the old binary bun.lockb; a single contention point for huge repos |
Kahn topological bun run --filter, no task-result cache | Correct build order + parallelism from the member graph, kept simple | No memoization/affected-by-hash; needs Turborepo/Nx for incrementality and remote cache |
| Cycles disable ordering for the whole run | Always make progress; never deadlock on a bad graph | A single cycle silently degrades the entire run to unordered, masking a real configuration error |
Affected-detection only in bun test --changed | Graph-accurate (git diff + reverse import edges) where it matters most for CI | Not available to bun run --filter; cannot bound general task runs to changed members |
Sources
- oven-sh/bun — GitHub repository (source for all quoted file paths; pinned to commit
df92f8f,1.4.0working tree) - Bun documentation — bun.com/docs
- Workspaces — bun.com/docs/install/workspaces
- Isolated installs — bun.com/docs/install/isolated
- Filter — bun.com/docs/cli/filter
- Lockfile — bun.com/docs/install/lockfile
src/install/PackageManager/install_with_manager.rs—autolinker selectionsrc/install/isolated_install/Store.rs— content-addressed isolated storesrc/install/dependency.rs—workspace:/catalog:protocol parsingsrc/install/lockfile/Package/WorkspaceMap.rs— workspace discoverysrc/install/lockfile/bun.lock.rs— text lockfile (v2), catalogssrc/runtime/cli/filter_run.rs— Kahn topological member-script runner + cycle handlingsrc/runtime/cli/filter_arg.rs—--filtername/path glob grammarsrc/runtime/cli/test/ChangedFilesFilter.rs—bun test --changedaffected tests- Bun's new text-based lockfile (Bun blog)
- Bun 1.2 release notes
- Bun 1.0 release notes
- Bun is joining Anthropic (Bun blog)
- Bun (software) — Wikipedia (history)
- Sibling deep-dives: npm · Yarn Berry · pnpm · Composer · Cargo · go-work · Gradle · Turborepo · Nx · Bazel · Buck2 · BuildBuddy · NativeLink · comparison · dub baseline · D landscape