essay · 12 min read · 2026-05-19

Why I'm rewriting npm install in 200 lines of Rust

By Lee Kim · Engineering · Posted on May 19, 2026

Contents
The problem with npmWhat a minimal install looks likeWhere the perf comes fromWhat I broke (and what I fixed)

I'm rewriting npm install because it's slow. Not slow in the abstract, but slow in a specific way: it spends 80% of its wall time on filesystem syscalls it doesn't need to make. So I built one that doesn't.

The problem with npm

Run strace npm install in any reasonably sized project. You'll see 240,000 open syscalls and 1.4M read calls. Most of them are reading metadata for packages it's already decided to install.

That's not laziness. That's how the resolver was designed: it reads package.json, then reads node_modules, then re-reads the dependency tree, then re-reads node_modules. Every byte of node_modules is touched at least three times.

The fastest tool is the one that doesn't do the work you didn't ask for.

What a minimal install looks like

My rewrite, tinypm, makes one pass:

// 200 lines · Rust 1.78 · single allocator
pub async fn install(root: &Path) -> Result<()> {
    let manifest = read_manifest(root).await?;
    let tree = resolve_parallel(&manifest).await?;
    fetch_and_link(&tree, root).await?;
    Ok(())
}

Three steps. Two of them are parallelizable. resolve_parallel processes 240 packages in 800ms on my M3.

Where the perf comes from

Result on a fresh checkout of this repo: 1.4 seconds vs npm's 38. Same tree. Same modules.

Try tinypm

The 0.6 release is up. Binary for macOS, Linux, Windows. ~600KB, no runtime.

$ curl tinypm.sh | sh →

What I broke (and what I fixed)

Initially I assumed every package's postinstall was malicious. That broke six packages I depend on. So I added an explicit allowlist. Surface area: 14 lines. Confusion budget: zero.

Still working on registry mirrors, peer-dep warnings that aren't garbage, and a watch mode. Issues + PRs at github.com/leekim/tinypm.