Vivian Voss

The Wait Before the Work

performance benchmarking nodejs tooling

Create an empty Next.js project. No features, no pages beyond the template, no business logic at all. 11 direct dependencies are declared.

438 packages arrive. 21,229 files. 468 megabytes.

Now run the checks that guard that project on every push. On an M1 Max with a gigabit line, which is faster than the machine your pipeline actually uses, it takes 12.4 seconds. Nothing in that time produces a feature, and nothing in it is your code.

What an Empty Project Costs Per Run

One pipeline run, empty project STEP MILLISECONDS npm ci (empty cache) 6,962 tsc --noEmit 961 eslint 1,205 next build 3,276 TOTAL 12,404 Apple M1 Max, 10 cores, 32 GB. The standard GitHub runner has 2 vCPUs and 7 GB, so read every figure as a floor.

Then the multiplication begins, and it is not a rhetorical multiplication. A modest matrix of 3 Node versions across 3 operating systems is 9 jobs, none of which share a cache with the others, so 12 seconds becomes nearly 2 minutes of machine time for one push. A monorepo with 10 packages multiplies it again. 20 pushes in a working day, which is a quiet day for 6 people, and the pipeline has spent well over half an hour doing arithmetic that produced nothing anybody asked for.

Nobody experiences it that way, of course. Each individual wait is short enough to check messages through.

The Same Arithmetic Where the Revenue Is

An empty template is the friendly case. The systems that actually take orders keep the ratios and enlarge every number, and the point of what follows is the shape they share, not the badge on any one of them.

3 commerce platforms, 3 published manifests. Spryker’s reference shop declares 266 direct dependencies and receives 1,202 packages, 899 of them its own modules. Magento’s monorepo replaces 241 modules. Shopware declares 165 direct.

Asked for, and arrived PROJECT DECLARED RESOLVED Next.js template 11 438 Spryker B2C shop 266 1,202 Magento monorepo 91 241 Shopware 165 direct only Read from the published lockfiles and manifests on 11 August 2026. Magento counts modules replaced by the monorepo.

Their published pipelines show where the minutes come from. Magento’s quality gate lints PHP syntax, then runs its code-style checker, then runs it again for XML and XSD, again for HTML, again for GraphQL, again for LESS: one tool invoked 6 times because 6 notations share one project. Shopware keeps 55 workflow files, one of which exists to warm the static-analysis cache, an optimisation for the checking rather than for the product. Spryker’s documented pipeline clones a Docker SDK, boots the containers, validates style, schema, XML names and transfer objects, runs PHPStan at level 4 and PHPMD against an architecture sniffer, then hands over to Codeception, with containers seeded and torn down around the whole affair.

Now add what a real deployment brings and the vendor documentation cannot: a staging environment and an integration environment, each with its own database, each running the suite again against its own data. At that point the wall clock has stopped counting in seconds, and the engineers waiting on it can tell you which stage is slowest without looking it up.

None of this is incompetence, and reading it that way would be cheap. These platforms carry real money for real merchants, they solve genuinely hard problems in tax, pricing and catalogue structure, and every check in those pipelines was added by somebody who had been burned by its absence.

Why Every Step Starts From Nothing

The reason those seconds cannot be optimised away by better hardware is visible further down, in the smallest measurement available: the emptiest program that can be written. Nothing is read and nothing is printed. Start, and stop.

Starting up to do nothing RUNTIME MILLISECONDS awk 3.4 swift binary 4.2 perl 5.0 sh 5.9 python3 26.5 deno 30.9 node 64.7 Median of 50 runs, 3 warm-up runs discarded, timed around fork and exec. macOS 26.5.2, Apple M1 Max.

64 milliseconds of Node to accomplish nothing, 19 times what awk needs for the same absence of work. On its own that is no scandal. A web server starts once and then runs for weeks, and Node was designed for exactly that shape, where the bootstrap is a rounding error and the event loop is the product.

The trouble is that a build pipeline is the opposite shape. Every step is a fresh process in a fresh container, and each one pays the full price of arriving before it does anything.

Watch it land in the ordinary case. A commit in a small repository with the usual hook setup takes 824 milliseconds; the same commit with hooks disabled takes 100. Of that difference, npx lint-staged alone accounts for 464, of which 262 is the wrapper starting a Node process to find a binary that a relative path locates for nothing. Then prettier runs on one file for 135 milliseconds, 64 of which is Node arriving for the second time. Subtract every bootstrap and roughly 70 milliseconds of actual formatting remains, 8.5 per cent of the wait.

Memory follows suit. An empty Node process holds 46.1 MB before it has been told what to do, prettier peaks at 64.0 and eslint at 76.2, against 1.4 MB for awk making a comparable pass over the same file. Multiply that by the number of containers on a build host and the pattern stops being about time at all.

What Is Actually Being Checked

Here is the part that ought to give a technical director pause, and it has nothing to do with milliseconds.

Look at what those 12 seconds consist of. 7 of them fetch and unpack 438 packages, a number that exists because 11 wishes were expressed and the ecosystem answered with a supply chain. 1 second checks types, a step required because the language has none of its own. Another applies rules that a compiler would enforce as a matter of course. 3 seconds translate the result into something a browser will accept, because what was written is not what runs.

The commerce pipelines decompose the same way, in a different dialect. There is a code style pass, because the language has no canonical form. There is static analysis at level 4, because types are optional and therefore missing wherever nobody insisted. There is an architecture sniffer, because the architecture is a convention that no framework can enforce by itself. Schema and transfer validation follow, because those objects are generated from declarations that nothing verifies at compile time. And 6 style passes for 6 notations, because a single project is written in 6 languages at once.

The stage, and the guarantee it replaces STAGE STANDS IN FOR install a manifest that states what actually arrives type check types the language does not have lint rules a compiler would enforce anyway build a runtime that reads what was written code style a canonical form the language never fixed architecture sniffer structure no framework enforces by itself Each row is a repair. Nobody made a mistake to warrant it; the property being repaired arrived with the choice of tooling, and each repair needs its own tool, config, pipeline slot and runtime to start.

Every one of those steps is a repair, and every check stands in for a guarantee the tooling declined to give. Nobody made a mistake to warrant any of it; the property being repaired arrived with the choice of tooling, and each repair now needs its own tool, its own configuration, its own place in the pipeline, and its own runtime to start up before it can begin.

This is where the arrangement turns circular. A pipeline of this kind spends most of its life measuring and correcting consequences of the framework it was built to serve. Add a container per service and the whole apparatus is instantiated again, with its own base image, its own dependency tree, its own audit and its own set of the same checks, all of it running on every push, all of it scaffolding for scaffolding.

The industry has a word for what remains when you remove all of that. It calls it the business logic, which is a curious way to describe the only part anybody wanted.

The Limit

The counter-arguments are real and deserve better than a subordinate clause.

Caching removes a good deal of this. A warm dependency cache took npm ci from 6,962 milliseconds down to 738, and a pipeline configured by somebody who cares looks considerably better than the figures above. Node ships --build-snapshot, which restores a prepared heap instead of repeating the bootstrap, saving 50 to 150 milliseconds per start. Editors keep a language server resident rather than launching a formatter per keystroke. To its credit lint-staged bundles every staged path into one invocation rather than spawning per file, which is the difference between 325 milliseconds and 13,417 for the same 100 files.

The checks also buy something. A type error caught before review is worth more than the second it cost, and the framework buys real things too: routing, rendering, a hiring pool, an answer to questions nobody on the team wants to solve twice.

Nor is the compiled side a free lunch, and it would be dishonest to imply otherwise. Rust and Go run their own check, format, lint and test steps, so the number of stages barely moves. What moves is where the guarantee lives. A large Rust workspace is measured in minutes rather than seconds, and anybody reading this as an advertisement for compilation has not sat through a release build.

And the measurement has boundaries: a single machine, warm caches throughout, a project with nothing in it, and a network most pipelines would envy. A large application changes the ratios, mostly by making them worse.

What the Second Actually Buys

None of this argues that the seconds are unaffordable. They plainly are affordable, which is precisely why nobody has counted them.

The question worth putting to a team is a different one, and it is not about performance. Of the time your pipeline spends on every push, what fraction goes to the thing you are selling, and what fraction goes to repairing the consequences of what you chose to build it with? The figure is measurable in an afternoon, and most teams have never seen it.

There is a second cost, and it lands on the checks themselves. The reports on slow hooks converge on one behaviour, which is that developers reach for --no-verify, a flag Git makes trivial and Husky does nothing to discourage. Somebody who waits a second on every commit finds that flag within a fortnight, and from then on the guarantee runs on the machines of the people who never disabled it. Which is to say it is no longer a guarantee.