Skip to content
LUIGI MICCA

Published

4 min read

The quiet cost of running a Node server

Node is a superb place to render a page and a strange place to run a server. The memory you pay for, the one thread you share, the restarts you learn to schedule — and why none of it shows up until the day it does.

Every Node server I have inherited came with the same folklore. "It restarts at night, that's normal." "Don't run the export on the same box as the API." "If it gets slow, add a replica." Nobody could say why any of this was true. They had simply learned, one incident at a time, to work around a runtime that was never designed to be a server in the first place.

I say that with affection. I have shipped a lot of Node. But after a few years of operating it in production next to other things, the costs are consistent enough to name.

You pay for memory you never use

A Node process that does nothing sits at 40–60 MB. Add a web framework, an ORM, a validation library and a logger — the ordinary furniture of a backend — and the idle footprint is 100–200 MB before the first request arrives. Under load the V8 heap grows toward whatever --max-old-space-size allows, and it does not hand that memory back to the OS with any enthusiasm.

This matters in a specific, unglamorous way: it sets the floor on how small your instances can be. A service that could run comfortably on 128 MB needs a 512 MB slot because of what it is written in, not what it does. Multiply by environments, by replicas, by the "one more microservice" that every team accumulates, and the bill is real long before the traffic is.

One thread, shared with everyone

The event loop is Node's great idea and its defining constraint. Every request, every timer, every database callback is scheduled onto a single thread. As long as every piece of work is short, this is remarkably efficient. The moment one piece of work is long — parsing a 40 MB JSON upload, rendering a big page, compressing or hashing with a synchronous call — every other request on that process waits behind it.

The symptom is not a crash. It is latency that spikes for reasons that never correlate with the request that suffered. The cure everyone reaches for is worker threads or a second process, which means you are now operating a small distributed system to get what other runtimes give you with a single keyword.

There is a second-order effect that is easy to miss: a CPU-bound task does not just slow responses, it stops timers. A keep-alive that should fire every two seconds fires when the loop gets around to it. A health check answers late. A connection that looks alive from the outside is simply waiting its turn.

Restarts as a feature

pm2, forever, a systemd unit with Restart=always — every production Node setup grows a supervisor, and most of them grow a scheduled restart too. Ask why and the answer is some version of "memory creeps". Sometimes it is a real leak. More often it is fragmentation, or a cache that never evicts, or a promise chain that holds a reference a little too long. Nobody finds it because restarting is cheaper than finding it.

This is a rational decision in the moment and a slow poison over years. A system that needs to be restarted to stay healthy cannot be reasoned about, and the nightly restart becomes the thing that masks the next bug, and the next.

Where Node is genuinely right

None of this argues against Node for what it is unmatched at: turning components into HTML, running the same code on the server and in the browser, the ecosystem of tooling around that job. Rendering is bursty, stateless and short — exactly the shape the event loop was built for.

The trouble starts when the same process is also asked to be the thing that holds the database pool, enforces the rate limit, streams the export, keeps the websocket subscriptions and talks to the payment provider. Those are long-lived, stateful, occasionally CPU-heavy jobs, and they belong on a runtime that has threads, a small fixed footprint and a garbage collector tuned for servers rather than browsers.

The shape that works

The setups I trust most have stopped trying to make one runtime do both jobs. The front — rendering, hydration, the browser-facing edge — runs where the component model lives. The back — persistence, business rules, anything long-running — runs on something compiled, with a predictable memory ceiling and real concurrency. The two talk over a narrow, typed seam, and each is allowed to be good at one thing.

It is less novel than it sounds. It is how most serious systems were built before "JavaScript everywhere" made a single runtime feel like a simplification. It was never one. It was a deferral, and the nightly restart is where the interest gets paid.