kolu
Blog
← all posts

Making nix develop 30x faster with zero flake inputs

What four months of measuring Nix evaluation in Kolu found — including a 0.014-second dev shell we deleted.

·Sridhar Ratnakumar

Kolu is built with Nix, and entering its dev shell used to take 7.5 seconds. It now takes 0.12s warm, 864ms cold.

This is what the measurements found. All numbers are medians of five runs on x86_64-linux, Nix 2.31.3. Claude Code ran the loop — measure, change one thing, commit with the numbers, repeat.

Run autoresearch / ralph loop fixing Nix evaluation times for nix develop. Change Nix files, delete ~/.cache/nix and run nix develop -c echo measuring its time. Then optimize the .nix code, measuring again. Go on loop until an hour.

It was the fetcher cache, not the eval cache

~/.cache/nix is at least two caches, and deleting it wholesale hides which one costs you. Clearing them separately:

nix develop -c echo — which cache actually costs youboth cleared7.5sfetcher cache only7.2seval cache only2.6s02467.5sNix re-verifies every flake input against the fetcher cache on every invocation.

The eval cache is the famous one. It costs 2.6s. The fetcher cache — which holds your flake inputs — costs 7.2s.

Each flake input costs about 1.5 seconds

Our flake.lock had six nodes: nixpkgs, flake-parts, systems, git-hooks, process-compose-flake, plus nixpkgs-lib transitively.

A flake input is not a one-time cost. It’s a per-invocation tax. The lock file already pins the exact revision; Nix re-verifies it anyway, every time.

So we went to zero:

  • flake-parts → a three-line eachSystem helper
  • git-hooks → a checked-in .pre-commit-config.yaml
  • process-compose → just’s [parallel] attribute
  • nixpkgs → plain fetchTarball in nix/nixpkgs.nix, pinned by npins

flake.lock deleted. Cold 7.5s → 2.6s, warm 3.6s → 0.3s. #152

We built a 0.014s dev shell, then deleted it

With the inputs gone, nix-shell became competitive, so we wrapped it: enter once, dump the environment with export -p, hash every .nix file, cache the dump under that key. Later runs eval the file and exec.

plain nix-shell:  2.34s  2.34s  2.36s
cached env:       0.014s 0.014s 0.014s

It broke CI four hours later. The cached environment held a PLAYWRIGHT_BROWSERS_PATH pointing at a store path from an older nixpkgs revision. The path was gone, Playwright had no browsers, and the failure looked like a Playwright bug rather than a stale cache.

The other traps were worse because they hadn’t fired yet:

  • The cache key is a hand-maintained file list. Add a .nix file, forget the list, and your changes silently don’t apply.
  • shellHook side effects only run on a miss. The cache stores the result, not the commands.
  • Only one store path is checked for garbage collection. A partial nix-collect-garbage leaves a stale cache that looks fine.

Then the head-to-head, each command in an isolated $HOME:

Entering the dev shell, zero-input flake, isolated $HOMEnix develop1.7s — keptnix-shell2.5snix-shell-fast (miss)~5snix-shell-fast (hit)0.014s — deleted012345sThe fastest option is the one we deleted — it went stale and took CI down.

With zero inputs, plain nix develop beats nix-shell — 1.7s against 2.5s, no overlap. My guess is that nix develop gets more out of the daemon’s eval cache while nix-shell with fetchTarball re-verifies the tarball against the store each time. I haven’t proven that.

But it means the whole nix-shell migration had only ever paid off because of the cache bolted on top of it. So we deleted the wrapper, went back to nix develop, and put a banner comment in flake.nix explaining why it has no inputs — aimed at the next agent that offers to add one back.

A cache whose invalidation rules live in a comment is a bug that hasn’t happened yet.

Twelve more cycles: 2500ms to 864ms

nix develop -c true, cold — twelve cycles250015005000 ms692ms — an EMPTY nix develop. The floor.2500ms−522ms writeShellApplication → writeShellScriptBin−269ms registry lookups off−655ms Playwright → .#e2e864ms051012cycleCycles 2–5 went slightly UP. They shipped anyway — architecture cleanups measured honestly as noise.

Three findings out of that run. #534

path: disables the eval cache

Our justfile used nix develop path:., on the belief that path: avoids copying the repo into the store. It also turns the eval cache off completely.

Switching to the default git+file:// took just dev from 4221ms to 121ms. It isn’t a step on the chart because the cold number didn’t move — cold-only measurement would have missed it entirely.

The cost: a brand-new .nix file must be git added before Nix sees it. Changes to tracked files are fine.

writeShellApplication drags in ShellCheck

pkgs.writeShellApplication lints your script, which puts ShellCheck in the dependency graph, which Nix must evaluate to know what your shell needs.

One trivial script: 983ms with writeShellApplication, 354ms with writeShellScriptBin. Two of them in our clipboard shims; swapping both saved 522ms.

pkgs.playwright-driver.browsers has the same shape — ~600ms of realization for everyone entering the shell, including people not running e2e. It moved to its own devShells.e2e.

The 692ms floor

An empty mkShell still takes ~692ms to enter, ~290ms of which is importing nixpkgs. That’s Nix’s own startup. We finished at 864ms and stopped.

use-registries=false saved 269ms and broke macOS

One cycle disabled flake registry lookups, which are pure waste with zero inputs.

nix develop hardcodes a lookup of flake:nixpkgs#bashInteractive to find a shell. With registries off, that fails and Nix falls back to PATH bash — on macOS, /bin/bash 3.2, which doesn’t support the ;& case fall-through that recent nixpkgs setup scripts use.

Reverted the next day. 269ms is a bad price for a platform, and you only see it if you run the other platform. Our CI now runs nix run . -- --help on both. #511

Build time: dontFixup cut 32s to 15s

Evaluation and building are separate problems. nix build .#default took 32.29s; dontFixup = true took it to 15.87s. #537

stdenv’s fixupPhase walks the whole output tree to strip binaries, patch shebangs, and rewrite ELF headers. For a Node.js app that’s near-pointless — pnpmConfigHook already patched the shebangs, and the one native binary (node-pty’s .node) is linked by node-gyp. The tree was 395MB of node_modules, so fixup traversed all of it.

The saving exceeded the measured 6.4s fixup phase. The rest was Nix’s post-build overhead — NAR hashing, signing, registering — which is much cheaper when the output hasn’t been rewritten in place. Trimming build-only packages took the output to 208MB and that overhead from 12.6s to 1.9s. Final: 14.55s.

Dead end worth recording: pnpm prune --prod breaks pnpm’s virtual-store symlinks in a workspace monorepo. The build succeeds, then dies at runtime with ERR_MODULE_NOT_FOUND. We rm -rf known dev packages instead.

Subflakes: nix run shouldn’t evaluate the website

Four months on, nix run github:juspay/kolu still took 3s cold — not because Kolu is big, but because the root flake had grown a website, two @kolu/surface examples, and our CI coordinator. Evaluating the flake meant discovering all of them.

What “nix run github:juspay/kolu” has to evaluatebefore — 3.05sroot flakeKoluwebsitesurface examplessolid-browserodu coordinatorafter — 1.45sroot flakeKoluown zero-input subflake, unreachable from root:website/flake.nixsurface + solid-browser examplesci/flake.nixAdding an example can no longer make the runnable flake wider. 5-run mean, eval cache disabled.

Splitting them into their own zero-input subflakes cut cold nix run from 3.05s to 1.45s. #1974

Nothing here was slow and no expression got optimized. The cost was structural — a stranger was paying to discover products they hadn’t asked for, because everything hung off one output graph. That needs a boundary moved, not an expression tuned.

Zero inputs also removed our GitHub dependency

On 21 July, CI started 403ing on every cold box. The pool’s shared egress IP had exhausted GitHub’s anonymous REST limit of 60 requests an hour. Nix resolves an unpinned github:owner/repo ref by asking api.github.com for the current revision, so every build touching such a ref died at the fetch. (#1204.)

Kolu’s own builds made zero such calls, because a flake with no inputs has nothing to resolve and npins fetches immutable tarballs. The one failing call came from inside devour-flake, a CI helper whose lockfile pointed at an unpinned nixpkgs-unstable branch. We replaced it with direct nix build calls and confirmed the swap produced the same 20 store paths. #1920

That was luck. But it’s the kind you get when the fast path and the simple path are the same path — a flake with no inputs is faster and has nothing to resolve.

The rules we kept

  • Zero flake inputs. Enforced by a banner comment in flake.nix and a rule in AGENTS.md.
  • Measure cold and hot separately. They find different bugs; the 35x win was invisible in cold numbers.
  • Don’t cache what you can’t invalidate. If the invalidation rule is prose, it’s a bug with a delay fuse.
  • Commit the measurements. Two of the twelve cycles made things slower. That’s only visible if the numbers are in the commit.

Count the nodes in your flake.lock and multiply by 1.5 seconds. That’s what you pay, every invocation, for pins you already have.