Skip to main content

Runtimes and package managers

For its first fourteen years, JavaScript lived almost entirely in the browser. In 2009 it got a second home, and a year later a way to share code between projects. Almost everything about how JavaScript is run and installed today follows from the decisions made in those two years, from the mistakes among them, and from the tools that were built to fix the mistakes.

Self-study

This lecture has no session. Read it, run the examples on your own machine, and watch the videos marked as required at the end.

The examples need Node 22.18 or later. Every other tool is optional, and every example's output is shown.


JavaScript leaves the browser​

1.1 An engine, and everything around it​

In 2009, Ryan Dahl took V8, the JavaScript engine Google had just built for Chrome, and gave it a home outside the browser. He called it Node.

An engine runs the language. It parses JavaScript, compiles it and executes it, and provides everything the language specification defines, such as Array, Promise and Map. That is all it does. The language can't read a file, open a network connection or even wait: setTimeout and console aren't part of it. Everything else comes from the runtime around the engine:

  • An event loop, which decides when callbacks, timers and promise reactions run.
  • Input and output: files, the network, processes, the terminal.
  • A module loader, which turns import "x" into a file.
  • Globals, from fetch to process.
  • Often tools: a test runner, a formatter, a package manager.

The browser had always been a runtime, with the DOM (Document Object Model) and the rest of the web platform as its globals. Node was a second one, for servers: the same engine, with a different everything else. Three engines matter today, and each is inside more than one runtime:

EngineMade byUsed by
V8GoogleChrome, Edge, Node
JavaScriptCoreAppleSafari, Bun
SpiderMonkeyMozillaFirefox

1.2 What Node decided​

A server needs a great deal that a browser page doesn't, and in 2009 none of it had a standard. Node made its own answers, and because Node succeeded, they became everybody's:

  • A module system, CommonJS, with require. The language wouldn't have modules of its own for another six years.
  • Callbacks for everything asynchronous. Early Node had promises, and took them out again in 2010.
  • Its own APIs, application programming interfaces, for what servers do: fs for files, http, Buffer for binary data, process for the running program.
  • No limits. A Node program can do anything the user running it can do: read every file, open any connection, start any program.
  • A folder called node_modules, where require looks for packages: next to the importing file, then in each parent folder up to the root.

The last two turned out to matter most, because of what was built on top of them.

1.3 npm, and the shape of node_modules​

In 2010, Isaac Schlueter released npm: a registry where anybody could publish a package, and a command that installs packages into node_modules. A project lists what it needs in package.json, as ranges, and npm fetches those packages, then their dependencies, then theirs.

Every package has its own dependencies, so npm first nested them, each inside the package that needed it. A package used by twenty others was installed twenty times, and the paths grew so deep that Windows couldn't delete them. npm 3, in 2015, started hoisting: it moves as many packages as it can to the top of node_modules, side by side.

nested, before npm 3          hoisted, since npm 3
node_modules/ node_modules/
debug/ debug/
node_modules/ ms/
ms/

The paths got short, and something nobody had planned became possible. A project that depends on debug, and nothing else:

{
"type": "module",
"dependencies": {
"debug": "^4.4.3"
}
}

can import ms, which only debug uses, because hoisting put ms where every file can find it:

// main.js
import ms from "ms";
console.log(ms("2 days"));
npm install && node main.js # ?
Output
172800000

ms is a phantom dependency: the project depends on it, and nothing says so. The day debug stops using ms, or moves to a version with a different API, this code breaks, without a single change to its own package.json.

The other problem was time. A range allows any newer version, so two developers who installed a week apart could get different versions of the same package, and a bug that only one of them could reproduce. Yarn, a second package manager released by Facebook in 2016, answered that with a lockfile, yarn.lock: the exact version of every package in the tree, written once and installed the same everywhere. npm added its own, package-lock.json, in 2017.


Second thoughts​

2.1 The regrets​

In 2018, Dahl gave a talk at JSConf EU called "10 Things I Regret About Node.js". It is the first required video for this lecture, and it takes under half an hour. Among the regrets:

  • Taking promises out, which made every asynchronous API in Node a callback, for years.
  • Security. Any program, and any package it installs, can do anything.
  • The build system for native add-ons, which every package with compiled code still depends on.
  • package.json, and the central registry it assumes.
  • node_modules, and the complicated way require searches for it.
  • require("./file") without the .js, which forces the loader to guess.

None of these could be changed any more. Millions of programs relied on every one of them. At the end of the same talk, Dahl showed what he would do instead.

2.2 Deno: nothing without permission​

Deno, which reached 1.0 in 2020, answered the regrets one by one. It runs on V8 too, and is written in Rust. It runs TypeScript directly, uses ES modules and web APIs only, and imports packages by URL, without package.json or node_modules.

Its best-known answer is to the security regret. A script that reads a file:

// read.mjs
import { readFile } from "node:fs/promises";

console.log(await readFile("notes.txt", "utf8"));
node read.mjs # ?
deno run read.mjs # ?
Output
secret
┏ ⚠️  Deno requests read access to "notes.txt".
┠─ Requested by `node:fs.open` API.
┠─ To see a stack trace for this prompt, set the DENO_TRACE_PERMISSIONS environmental variable.
┠─ Learn more at: https://docs.deno.com/go/--allow-read
┠─ Run again with --allow-read to bypass this prompt.
┗ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all read permissions) >

Node runs it, because Node runs everything. Deno lets a program do nothing it wasn't allowed to. In a terminal it asks, and anywhere else it refuses. Flags grant access, as narrowly as you like: --allow-read=./data, --allow-net=api.example.com, --allow-env=API_KEY.

The clean break had a cost: Deno couldn't use the million packages on npm. Deno 2, in 2024, changed course. It reads package.json, installs into node_modules, and implements most of Node's built-in modules, which is why read.mjs, written with node:fs/promises, ran in Deno at all. It wasn't enough. In October 2026, Cloudflare bought the company behind Deno, and its team moved on to Cloudflare's own runtime. Deno gets bug fixes and security patches for about a year, and then its development ends.

Its ideas have done better than the runtime. Node took several of them.

2.3 pnpm: node_modules, done strictly​

Deno threw node_modules away. pnpm, started in 2016 by Rico Sta. Cruz and maintained for most of its life by Zoltan Kochan, kept it, and fixed what hoisting had broken. Its node_modules contains only what package.json lists, as symbolic links, files that point to a folder somewhere else, and a hidden folder with everything else:

node_modules/
debug → .pnpm/debug@4.4.3/node_modules/debug
.pnpm/
debug@4.4.3/node_modules/
debug/ ← the package
ms → ../../ms@2.1.3/node_modules/ms
ms@2.1.3/node_modules/
ms/

debug finds ms, because ms is linked next to it. The project doesn't, because nothing links ms into the top. Each package sees exactly what it declared. The same project, in a fresh folder, installed with pnpm:

pnpm install && node main.js # ?
Output
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'ms' imported from …/main.js

The phantom dependency is now an error on the first run, not a surprise a year later.

The files themselves live in a content-addressable store: one folder per machine, where every file is kept once, under a name made from a hash of its contents. Projects get hard links into it, a second name for the same file on disk, so ten projects with the same version of a package store it once, and an install that finds a package already in the store copies nothing.

Yarn went further still. Its Plug'n'Play mode, the default since Yarn 2 in 2020, writes no node_modules at all, only a loader file that tells Node exactly where each package is on disk. Every tool has to understand it, which is why some projects switch it off.

2.4 Bun: everything in one​

Bun, started by Jarred Sumner and released as 1.0 in 2023, took a different engine, JavaScriptCore from Safari, and put a whole toolchain around it: a runtime, a package manager, a bundler and a test runner, in one program, built to be fast. Unlike Deno, it set out to run Node's code from the start: node_modules, CommonJS, Node's built-in modules. Its package manager hoists like npm's, and bun install of the same debug project runs main.js without complaint. Bun was rewritten from Zig, a young systems programming language, to Rust for version 1.4, in 2026.

By then, the alternatives had changed Node as much as Node had shaped them.


Converging​

3.1 One set of APIs​

Server runtimes used to invent their own API for everything. Now they implement the browser's wherever one exists. Node added fetch in 2022, and today every major runtime has fetch with Request, Response and Headers, URL, AbortController, TextEncoder, structuredClone, web streams and crypto.subtle.

That list has a name. In 2022, Cloudflare, Deno and Node's core contributors started a community group to agree on it. In 2025 it became WinterTC, Ecma International's Technical Committee 55, and published the Minimum Common Web API as the standard ECMA-429: the web APIs, from the W3C (World Wide Web Consortium) and the WHATWG (Web Hypertext Application Technology Working Group), that every web-interoperable runtime should have.

The other half is Node's own modules. Bun implemented them from the start, and Deno from version 2. So code written against web APIs and node: modules runs everywhere. What doesn't carry over is everything named after one runtime, such as Bun.file or Bun.serve.

What is shared shows up in a small test. The same file, asking which globals exist:

// runtime.mjs
console.log(navigator.userAgent);
console.log({
window: typeof window,
process: typeof process,
Bun: typeof Bun,
fetch: typeof fetch,
require: typeof require,
});
node runtime.mjs # ?
bun runtime.mjs # ?
Output
Node.js/24
{
window: 'undefined',
process: 'object',
Bun: 'undefined',
fetch: 'function',
require: 'undefined'
}
Bun/1.4.3
{
window: "undefined",
process: "object",
Bun: "object",
fetch: "function",
require: "function",
}

Both have fetch, and both have process, though process began as Node's alone. Bun adds a global of its own, and even offers require in an ES module. Code that checks typeof process to detect Node, or typeof window to detect a browser, guesses wrong somewhere. Check for the feature you need, typeof fetch === "function", or for the runtime's own global.

3.2 Node learns back​

Node, in turn, took what made the others attractive:

  • A test runner, node --test, and watch mode, node --watch, in 2022.
  • A permission model, in 2023, with network access added in Node 25.
  • TypeScript without a build step, by default since 2025. Node strips the type annotations and runs what is left, so syntax that generates code, such as an enum, is refused. Bun transpiles instead, and runs enums too. Neither checks the types when it runs a file: that stays a separate step, tsc, or bun check since Bun 1.4.3.

Node's permission model is opt-in. --permission turns it on, and each --allow-* flag opens one door again:

node --permission read.mjs # ?
Output
Error: Access to this API has been restricted. Use --allow-fs-read to manage permissions.
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: 'notes.txt'
node --permission --allow-fs-read=. read.mjs
secret

On Node 24, --permission covers files, child processes, workers and native add-ons, but not the network: a fetch still succeeds. On Node 26, the same fetch fails with ERR_ACCESS_DENIED unless --allow-net is given.

A seat belt, not a sandbox

Node's documentation says its permission model "does not protect against malicious code". It stops trusted code from touching what it shouldn't by mistake.

3.3 Install scripts, switched off​

The security regret had a second half, on the package manager's side. A package can declare preinstall, install and postinstall scripts, which run when it is installed. They exist for good reasons, mostly to download or compile a native binary. They also run whatever code the package's author put there, or whoever stole the author's credentials, on every machine that installs it, before anybody has imported a line.

The Shai-Hulud worm of September 2025 spread exactly that way. Its install script searched each machine for npm tokens, and used them to publish infected versions of more packages. Bun never ran dependencies' install scripts by default. pnpm stopped in version 10, in 2025, and npm in version 12, in July 2026:

ManagerDependencies' install scriptsTo allow one
npm 12not run, with a warningnpm install-scripts approve <pkg>, saved as allowScripts in package.json
pnpm 11+not run, and the install fails until you decidepnpm approve-builds <pkg>, saved as allowBuilds in pnpm-workspace.yaml
Bunnot run, except for a built-in list of popular packagesbun pm trust <pkg>, saved as trustedDependencies in package.json

The project's own scripts, such as build or test, run as before. Only the scripts of installed packages are affected.

Bun itself is an example. Its package on npm downloads the right binary for your system in a postinstall script, so under npm 12:

npx bun runtime.mjs
Error: Bun's postinstall script was not run.

This occurs when using --ignore-scripts during installation, or when using a
package manager like pnpm that does not run postinstall scripts by default.

npx --allow-scripts=bun bun runtime.mjs allows that one package, and it runs.

Since version 11, pnpm also waits one day before it installs a newly published version, which is often enough for a malicious release to be found and removed. minimumReleaseAge changes the delay.

3.4 What is left to choose​

After years of borrowing in both directions, the runtimes differ less than their histories suggest:

NodeBun
EngineV8JavaScriptCore
Written inC++Rust, since 1.4. Zig before
TypeScriptstrips the types, then runstranspiles, then runs
Checking typesno, that is tsc's jobbun check
Permissionseverything, unless --permissioneverything
Built intest runner, watch modepackage manager, bundler, test runner

and so do the package managers:

ManagerComes withLockfilePackages on disk
npm 12Nodepackage-lock.jsonflat node_modules
pnpm 12separatelypnpm-lock.yamlnode_modules of links into a store
Yarn 4separatelyyarn.lockno node_modules by default, a loader file instead
BunBunbun.lockflat node_modules

The versions are from October 2026: Node 24 and 26, Bun 1.4, npm 12, pnpm 12, rewritten in Rust that year, and Yarn 4. Each claims to be fastest at something, and each benchmark measures one thing under one setup. Choose for what your code needs, and measure your own code if speed decides it.


One repository, many packages​

4.1 Why repositories grow​

A library rarely stays alone. async-kit has an app that uses it, and each lives in a folder of its own, with its own install. To try a change to the library in the app, you build the library, publish a version and install that version in the app. Every experiment costs a version number, so most changes skip the loop: they are published, and the app finds out later.

A monorepo keeps several packages in one repository instead. Vue, Vite, React and Babel all work this way. What it gives you:

  • One change, one commit, across a library and the code that uses it, reviewed and tested together.
  • No publishing to try a change. The app uses the library as it is on disk right now.
  • One install, one lockfile, one set of tools.

What it costs: every command has to say which package it is for, builds and tests grow with the repository, and every tool has to understand the layout. The package manager's half of that understanding is called workspaces, and every package manager has them. Yarn introduced them in 2017, and npm followed in 2020. The idea is the same in all of them: the root lists which folders are packages, one install at the root installs them all, and a package that depends on a sibling gets a link to the sibling's folder instead of a copy from the registry.

4.2 Workspaces with npm​

npm comes with Node, so it is the one every machine already has. The library and the app move under one root:

kit/
package.json
packages/
async-kit/ the library, 0.2.0
app/ uses async-kit

The root package.json is "private": true, because nobody publishes the repository itself, and lists the packages:

{
"name": "kit",
"private": true,
"workspaces": ["packages/*"]
}

The app depends on the library as it always did, with a range: "async-kit": "^0.2.0". One npm install at the root installs every package's dependencies, writes one package-lock.json, and links the library instead of downloading it:

npm ls async-kit
kit@ /…/kit
+-- app@ -> ./packages/app
| `-- async-kit@0.2.0 deduped -> ./packages/async-kit
`-- async-kit@0.2.0 -> ./packages/async-kit

-> is a link: the app's async-kit is the folder next to it. -w runs a script in one package, and --workspaces runs it in all of them:

npm run build -w async-kit
npm run build -w app
npm start -w app
[ 2, 4, 6 ]
42
npm runs workspaces in the order they are listed

npm run build --workspaces doesn't look at which package uses which. With a glob such as packages/*, the order is alphabetical, so on a fresh clone app is built before the library it imports:

main.ts(1,38): error TS2307: Cannot find module 'async-kit' or its corresponding type declarations.

npm then carries on and builds async-kit, so the second run passes, with the first run's dist still on disk. A clean build server fails every time. List the packages in the order they must be built, "workspaces": ["packages/async-kit", "packages/app"], or build the library on its own first.

The link has a condition: npm links a sibling only while its version fits the range. When it doesn't, npm goes to the registry instead, without a warning. Raise the library to 0.3.0 and leave the app at ^0.2.0, and npm installs the 0.2.0 you published earlier, so the app goes on running the old version. The registry may even have an unrelated package with the same name. There is one called async-kit on npm, so an app here that asks for ^2.0.0 gets somebody else's library:

kit@ /…/kit
+-- app@ -> ./packages/app
| `-- async-kit@2.2.4
`-- async-kit@0.2.0 -> ./packages/async-kit

4.3 pnpm's comfier ways​

The same repository with pnpm. The list of packages moves into a file of its own, pnpm-workspace.yaml:

packages:
- "packages/*"

and the app names its sibling with the workspace protocol:

"dependencies": {
"async-kit": "workspace:^"
}

workspace: means "the package of that name in this repository, never one from the registry". workspace:^ follows the sibling's version, whatever it is. A range written out, such as workspace:^0.3.0, has to fit, and when it doesn't, the install stops instead of fetching something else:

No matching version found for async-kit@workspace:^0.3.0 inside the workspace.
Available versions: 0.2.0

When a package that depends on a sibling is published, pnpm replaces workspace:^ with a real range, ^0.2.0, because nobody outside the repository could resolve workspace:. Yarn and Bun understand the protocol too. npm doesn't:

npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:^

pnpm install at the root installs everything, and reports what it found:

Scope: all 3 workspace projects

The two packages, and the root. The app's node_modules then holds exactly what its package.json lists:

packages/app/node_modules/
async-kit → ../../async-kit
typescript → ../../../node_modules/.pnpm/typescript@7.0.2/node_modules/typescript

Every package in the repository sees only what it declared. In the npm workspace, async-kit's build tools were hoisted to the root, so the app could import vite without listing it, a phantom dependency inside your own repository. With pnpm, the same import fails, as the phantom ms did.

From the root, -r, recursive, runs a command in every package. In which order?

pnpm -r build # ?
Output
$ vite build
vite v8.3.5 building client environment for production...
…
✓ built in 1.21s
$ tsc

vite build is async-kit's build, and tsc is the app's. -r runs in dependency order: a package after everything it depends on, whatever the folders are called. --filter picks packages instead:

pnpm --filter app start                 # a script in one package
pnpm --filter async-kit add -D vitest # a dependency for one package

Last, both packages use TypeScript, and two copies of "typescript": "^7.0.2" drift apart the first time somebody updates one of them. A catalog names the version once, in pnpm-workspace.yaml:

catalog:
typescript: ^7.0.2

and every package refers to it as "typescript": "catalog:". Like workspace:, catalog: is replaced by the real range when a package is published.

4.4 Why this course uses pnpm​

Any of these managers can run a workspace. This course uses pnpm from here on, for reasons that hold well beyond it:

  • It is strict by default. Phantom dependencies fail on the first run, in the repository and in every package in it.
  • Workspaces need nothing else: the workspace: protocol, dependency order, filters and catalogs are all built in.
  • One store per machine. Each version of each file is on disk once, and an install copies nothing that is already there.
  • It is what you will meet. Vue, Vite, Vitest, Nuxt, Svelte and Next.js are all developed in pnpm workspaces.

Install it once, with the npm you already have:

npm install --global --allow-scripts=pnpm pnpm@12
pnpm --version

pnpm's own package has an install script, which puts a native binary for your system in place, so the command allows that one package's script.

Corepack

Older guides install pnpm with corepack enable instead. Corepack was a tool that came with Node and installed whichever package manager a project named in its package.json. Node stopped shipping it in version 25. It still exists as a package of its own, but npm install --global does the same job.

The commands map almost one to one. npm install becomes pnpm install, npm install zod becomes pnpm add zod, npm run build becomes pnpm build, and npx becomes pnpm exec for a tool the project installed, or pnpm dlx for one it didn't.

4.5 When workspaces aren't enough​

With dozens of packages, two more kinds of tool appear:

  • Task runners, Turborepo and Nx, run scripts across packages in parallel and cache the results, so a build that already ran with the same inputs isn't run again.
  • Release tools version and publish each package on its own. Changesets has the author add a small file with each change, naming the packages it affects and whether it is a patch, minor or major change, and turns those files into version numbers and changelogs at release time.

For a library and an app, the package manager's workspaces are all you need.


Choosing, and writing it down​

5.1 Which Node​

Node has released a new major version every April and October:

  • Even-numbered versions become LTS, long-term support, in October: about 30 months of fixes, first active, then maintenance with security fixes only.
  • Odd-numbered versions live for about eight months, and few projects use them.
VersionIn October 2026End of life
22maintenanceApril 2027
24active LTS, maintenance from 20 OctoberApril 2028
26current, active LTS from 28 OctoberApril 2029

Node 26 is the last line on that schedule. From Node 27, in April 2027, there will be one major version a year. Every one becomes LTS, and there are no odd versions any more. An alpha channel, from October to March, takes over what odd versions were for: trying what is coming.

A version manager switches between installed versions: nvm or fnm, which read an .nvmrc file in the project with the version in it.

5.2 One project, one manager​

Each manager reads only its own lockfile. A project where one person runs npm install and another pnpm install ends up with two lockfiles that disagree, and two developers with different node_modules, the very problem lockfiles were invented to solve. A project chooses one manager, and one range of Node versions, and says so, in devEngines in package.json:

"devEngines": {
"packageManager": { "name": "pnpm", "version": "^12.0.0", "onFail": "error" },
"runtime": { "name": "node", "version": ">=24", "onFail": "error" }
}

Both npm and pnpm read it. npm install in that project:

npm error code EBADDEVENGINES
npm error EBADDEVENGINES The developer of this package has specified the following through devEngines
npm error EBADDEVENGINES Invalid devEngines.packageManager
npm error EBADDEVENGINES Invalid name "pnpm" does not match "npm" for "packageManager"

and pnpm, with a Node older than the runtime entry allows:

Error: ERR_PNPM_BAD_RUNTIME_VERSION

× This project requires Node.js >=30. Your current Node.js is v24.21.0

You will also see an older field, "packageManager": "pnpm@12.14.0", which was Corepack's. pnpm still reads it, and refuses to work in a project that names another manager. npm 12 ignores it.

To move an existing project from npm to pnpm, pnpm import writes pnpm-lock.yaml from the package-lock.json, with the same versions. Delete package-lock.json in the same commit.


Take these 5 things from here​

  1. A runtime is an engine plus everything else: event loop, input and output, modules, globals and tools. Node set the defaults, Deno and Bun answered its mistakes, and Node took back the answers that worked.
  2. Detect features, not runtimes. typeof process and typeof window no longer tell you where your code runs.
  3. Hoisting lets code import what it never declared. pnpm's linked layout gives each package exactly what it lists, and stores each file once per machine.
  4. Dependencies' install scripts no longer run by default. Allow each one on purpose, and know which file records the decision.
  5. Every package manager has workspaces. npm runs them in the order they are listed and falls back to the registry. pnpm adds the workspace: protocol, dependency order and strictness, which is why this course uses it. One project, one manager, written down in devEngines.

You should now be able to​

  • Explain the difference between an engine and a runtime, and name the engine behind each major runtime.
  • Explain which of Node's early decisions shaped the ecosystem, and which ones Deno, pnpm and Bun set out to fix.
  • Write code that runs on Node and Bun, and say which APIs it may use.
  • Explain why typeof window and typeof process are unreliable ways to detect the runtime.
  • Restrict what a program may do with Node's --permission, and explain what it doesn't protect against.
  • Explain how hoisting creates phantom dependencies, why they break later, and how pnpm prevents them.
  • Explain why install scripts are dangerous, and allow one in npm, pnpm and Bun.
  • Set up a workspace with npm and with pnpm, and explain what the workspace: protocol, -r and catalogs add.
  • Explain why npm run --workspaces can build a package before its dependency, and how to prevent it.
  • Say when a monorepo needs a task runner or a release tool.
  • Read Node's release schedule, choose a Node version, and pin a project to one package manager and one Node version with devEngines.

Reading​

Required

Recommended


Demos​