How Node Runs Your Code

Lesson 1 of 5

Why Node Exists

"It's time for web servers to handle ten thousand clients simultaneously, don't you think? After all, the web is a big place now." — Dan Kegel, The C10K Problem (1999)

Node exists because of that sentence, and understanding it is the difference between using Node and knowing why anyone bothered.

In 1999 a web server handled a connection by dedicating a thread to it. The thread called read(), the operating system suspended it until bytes arrived, and it woke up to do a few microseconds of work. That model is beautifully simple and it has a hard ceiling: each thread reserves a stack — a megabyte is typical — and the kernel must switch between them. Ten thousand simultaneous connections meant ten thousand threads, ten gigabytes of reserved stack, and a scheduler spending more time switching than working. Kegel's page asked what it would take to break that ceiling, and the answer the industry converged on was non-blocking I/O with an event loop: one thread, thousands of connections, and no waiting.

That answer existed before Node. epoll, kqueue, nginx, Twisted, EventMachine — all predate it. What Ryan Dahl noticed in 2009 was subtler and is the actual reason Node won: every existing language already had a blocking I/O library, and programmers kept reaching for it. Give a Python or Java developer an event loop and they will, somewhere deep in a handler, call a function that blocks — and one blocking call in an event-loop server stalls every connection it is serving.

JavaScript was the one mainstream language with no I/O library at all. It had grown up in a browser, where every API that touches the network is already callback-based, and where blocking the thread visibly freezes the page. Its programmers were already trained. That is the bet Node made, and this chapter is about what it bought and what it costs.

The big question

Node runs your JavaScript on one thread. A modern server has sixteen cores. How is using one of them ever the faster choice?

Because most servers are not computing — they are waiting. A typical request spends a millisecond in your code and two hundred milliseconds waiting on a database, an API, or a disk. In the thread-per-connection model that waiting occupies a whole thread and a megabyte of stack; under an event loop it occupies a callback and a few hundred bytes. So Node's single thread is not doing less work, it is doing none of the waiting — and when the work is I/O-bound, removing the waiting is worth far more than adding cores. The corollary is the whole cost of the bet, and it arrives in the last section: when your request actually does compute for two hundred milliseconds, that one thread is now the only thread, and everybody queues behind you.

The arithmetic of a thread per connection

The ceiling Kegel described is not subtle, and it is worth computing rather than asserting.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The gap is not a constant factor you could optimise away; it is structural. A thread's stack is reserved the moment the thread exists, regardless of whether it is computing or suspended on a read() — and in a web server it is overwhelmingly suspended.

Three consequences follow, and they are the whole argument for the model.

Memory is the first ceiling, not CPU. Long before the processor saturates, ten thousand threads have exhausted the address space you were willing to give them.

Context switching is the second. Each switch costs the kernel a few microseconds and evicts the CPU cache. With enough threads the machine spends its time deciding what to do rather than doing it.

Neither cost buys anything, because a suspended thread is not making progress. You are paying a megabyte to represent the fact that something has not happened yet — and a callback represents that in a few hundred bytes.

Everyday analogy

A restaurant with the thread-per-connection model assigns one waiter per table for the whole meal. The waiter takes the order, walks to the kitchen, and then stands there until the food is ready. Service is simple to reason about and you need a hundred waiters for a hundred tables, most of them standing still.

Node's model is one very good waiter who never stands still. They take an order, hand it to the kitchen, and immediately go to the next table; when a dish is ready the kitchen calls out and they deliver it. One person serves the whole room, because the job was never carrying plates — it was waiting for the kitchen. The failure mode is equally clear, and it is the last section of this chapter: if that one waiter ever sits down to chop vegetables themselves, the entire restaurant stops.

Concurrency without parallelism

The distinction people flatten is that Node is concurrent but not parallel: it interleaves many operations, it does not execute two at once. That sounds like a limitation and is the entire point.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Three sixty-millisecond waits take a little over two hundred milliseconds in sequence — the timers themselves are approximate — and about sixty concurrently, and no additional core was used. That is the shape of essentially every real Node performance win: not arithmetic done in parallel, but idle time reclaimed.

This is also the most common beginner mistake in the language, and it is worth naming now because it recurs for the rest of the volume. await inside a loop is sequential:

// Not what you want, almost always:
for (const id of ids) {
  results.push(await fetchUser(id));   // each waits for the last
}

// What you want, when the calls are independent:
const results = await Promise.all(ids.map(fetchUser));

The first version is not wrong — sometimes you need ordering, or you must not hammer a dependency with a hundred simultaneous requests, which is Part II's material. But writing it by accident, when the calls were independent all along, turns a sixty-millisecond handler into a six-second one, and it is invisible in code review because both versions read naturally.

A little history

Ryan Dahl presented Node at JSConf EU in November 2009, and the demo was a web server holding thousands of open connections on a laptop. The technical pieces were not new — V8 had shipped with Chrome in 2008, and epoll, kqueue and non-blocking I/O were a decade old. nginx had already used the event-loop model to beat Apache at exactly this, and Python's Twisted had offered it since 2002.

What was new was the absence of an alternative. Dahl's argument was that every other ecosystem shipped a blocking I/O library, and so their event-loop frameworks were perpetually one careless call away from stalling — a single psycopg2 query inside a Twisted handler blocks every connection that reactor is serving, and nothing in the language warns you. JavaScript had no such library to reach for, because it had never had a file system or a socket. Its entire standard idiom was already the callback.

Dahl himself has been the model's sharpest critic since. His 2018 talk, 10 Things I Regret About Node.js, lists security, the module system and the abandonment of promises among them, and led him to build Deno. That is worth knowing when you meet Node's rough edges later in this volume: the person who designed it agrees with you about several of them, and the parts he defends are precisely the event loop and the non-blocking model this chapter is about.

One thread means no locks

A consequence people rarely state explicitly: because your JavaScript runs on one thread, shared mutable state needs no synchronisation. Two callbacks never execute simultaneously, so there is no interleaving to protect against.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Read that output carefully, because it makes a distinction the whole volume depends on.

There is no data race. In Go or Java, counter = before + 1 from a hundred threads would tear — the read, the add and the write can interleave mid-instruction, and Volume 17 spent a chapter on why that is undefined behaviour rather than merely a wrong number. In Node it cannot happen. Each callback runs to completion before the next begins, so every individual statement is effectively atomic and no mutex is ever required.

There is still a race condition. Every await is a point where your function suspends and other code runs. The value read before the await may be stale by the time you write it back. That is the check-then-act bug from Part V of the Go volume, arriving in a language that cannot have data races — which is exactly why it surprises people.

The rule worth internalising now: await is a yield point, and you must re-check anything you read across one. Not because another thread might change it, but because another callback certainly might.

The engineer's notebook

Three questions before choosing Node for something.

Is this workload waiting or computing? Waiting on databases, APIs, disks and queues is what the model is for. Image resizing, cryptography, big joins in memory and machine learning are the opposite, and one thread is then exactly one thread.

What is the longest synchronous stretch in a request? That number is the latency floor for every other request in flight. Anything above a few milliseconds needs an answer — a worker thread, a queue, or a different runtime.

Does anything here block? A synchronous file read, a JSON.parse of ten megabytes, a regular expression with catastrophic backtracking. Each is a normal-looking line that stops the entire server, and none of them looks dangerous.

What blocking actually does

The cost of the bet is concentrated in one failure, and it deserves to be measured rather than described.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The second timer asked for twenty milliseconds and got a hundred and fifty, because the event loop cannot service anything while your synchronous code is running. Substitute a real workload for burnCpu and the sentence becomes the operational reality: a synchronous stretch in one request is added latency for every other request in flight.

This is the model's genuine weakness and the honest answer to "when should I not use Node":

CPU-bound work. Image processing, video transcoding, cryptographic key derivation, large in-memory sorts. One thread is one thread, and no amount of async makes computation concurrent.

Accidentally synchronous work, which is more common and worse because nobody chose it. JSON.parse on a large payload, a synchronous file read, bcrypt at a high cost factor on the main thread, a regular expression that backtracks catastrophically on hostile input — that last one is a denial-of-service vector, not just a slowdown.

Node's answers are worker threads for genuine CPU work, a queue and a separate process for heavy jobs, and native addons for the hot path. All three are Part VII. What matters at this stage is the diagnostic instinct: when a Node service is slow for everyone at once, look for one thing that is slow for one person.

In the real world

The characteristic Node incident is a service whose latency degrades uniformly — every endpoint slows together, including ones that touch nothing. That pattern is nearly diagnostic on its own. A database problem slows the endpoints that use that table; a blocked event loop slows everything, because everything shares the one thread.

The cause is usually a single request doing something synchronous that was fine at small scale. A report endpoint that JSON.parses a payload which used to be 50 KB and is now 40 MB. A regex validating user input that backtracks exponentially on a crafted string. A password hash with the cost factor raised for security, called on the main thread. In each case the code is correct, the tests pass, and one caller can stall the process for hundreds of milliseconds at a time.

The measurement that finds it is event loop lag: schedule a timer for a fixed interval and record how late it actually fires. Under a healthy service that number sits near zero; under a blocked one it spikes to exactly the length of the synchronous work. It is a handful of lines, it costs nothing, and it belongs on the dashboard of every Node service from the first deploy — because it is the one metric that distinguishes "our dependency is slow" from "we are blocking", which are opposite problems with opposite fixes.

What the runtime is actually made of

"Node is JavaScript on the server" is true and hides the interesting part. Node is two large C++ projects with your JavaScript sitting on top.

V8 is Chrome's JavaScript engine: it parses, optimises and executes your code, and it owns the heap and the garbage collector. It knows nothing about files, sockets or timers — V8 in isolation cannot open anything.

libuv is the event loop and the I/O layer, written for Node and now used well beyond it. It wraps the platform's non-blocking primitives — epoll on Linux, kqueue on macOS, IOCP on Windows — behind one interface, and it maintains the thread pool that handles work the OS cannot do without blocking.

That last point is the one that corrects a common half-truth:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Two things in that table are worth carrying forward.

Network I/O uses no thread at all. This is why Node scales to tens of thousands of sockets: the kernel notifies the loop when a socket is readable, and nothing is dedicated to waiting. It is the whole answer to Kegel's question.

File and DNS I/O do use threads, because most operating systems offer no genuinely asynchronous file API. libuv fakes it with a pool of four by default, which means four concurrent file operations saturate it and the fifth queues — a real bottleneck in file-heavy services, and one that surprises people who believe every async call is free.

The JavaScript you write sits above all of this and sees one consistent story: you call something, you get a promise or a callback, and the loop delivers the result when it is ready. The next chapter is about the machinery that does the delivering.

Common Mistakes

  1. await inside a loop for independent work. Each iteration waits for the last, turning a 60 ms handler into a 6 s one. Use Promise.all when the calls do not depend on each other.

  2. Believing single-threaded means no concurrency bugs. There are no data races, but every await is a yield point, so read-await-write is still a race condition.

  3. Confusing concurrency with parallelism. Node overlaps waiting; it does not execute two pieces of your JavaScript at once. Nothing computes faster.

  4. CPU-bound work on the main thread. Image processing, key derivation, large sorts. One thread is one thread — use a worker, a queue, or a different runtime.

  5. Accidentally synchronous work. A big JSON.parse, a sync file read, a backtracking regex. Correct code, passing tests, and the whole server stalls.

  6. Thinking "single-threaded" describes the runtime. Your JavaScript is single-threaded; libuv runs a thread pool, and file, DNS, crypto and zlib work all use it.

  7. Assuming every async call is free. The default thread pool is four, so the fifth concurrent file read queues even though your code never blocks.

  8. No event-loop lag metric. It is the one measurement that distinguishes "a dependency is slow" from "we are blocking" — opposite problems, opposite fixes.

  9. Choosing Node for the wrong workload. The model wins on waiting and loses on computing. Ask which one the service actually does.

  10. Treating the blocking risk as theoretical. A catastrophic-backtracking regex on user input is a denial-of-service vector, not a performance note.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
Node is single-threaded. How can it handle thousands of simultaneous connections?
Model answer — Because those connections are almost all waiting rather than computing. In the older model each connection got a thread, and a thread reserves a stack — around a megabyte — whether it is working or suspended on a read, so ten thousand connections meant gigabytes of reserved memory plus the kernel switching between them. Node uses non-blocking I/O and an event loop: the kernel tells the loop when a socket is readable, and until then the connection costs a callback and its socket buffers rather than a thread. So the single thread is not doing more work, it is doing none of the waiting. I would add the precise version of "single-threaded", because it is usually stated too broadly: one thread runs my JavaScript, and it never runs in parallel with itself. The runtime around it is not single-threaded — libuv keeps a thread pool, four by default, for file system, DNS, crypto and zlib work, because most operating systems have no genuinely asynchronous file API. Network I/O is the case that uses no thread at all, and that is what the scaling story actually rests on.
Mid
A colleague says single-threaded means you cannot have concurrency bugs in Node. Is that right?
Model answer — Half right, and the half that is wrong causes real bugs. There are no data races: two callbacks never execute at the same instant, so a statement like counter = counter + 1 cannot tear the way it can across threads in Go or Java, and I never need a mutex to protect a plain variable. But there are still race conditions, because every await is a yield point where the function suspends and other callbacks run. So if I read a value, await something, and then write based on what I read, the value may be stale by the time I write it — that is check-then-act, and it is the classic bug in a language that cannot have data races, which is exactly why it catches people. The rule I follow is that await is a suspension point and anything read across one must be re-checked. The practical shapes are a cache that gets populated twice because two requests both missed before either stored, or a balance check that passes and then a debit that overdraws. The fix is usually to make the operation atomic where it actually lives — a conditional update in the database, or an in-process promise map so concurrent callers share one in-flight operation instead of each starting their own.
Senior
When is Node the wrong choice, and how would you know it is going wrong in production?
Model answer — Wrong when the work is CPU-bound, because the model trades parallelism for the elimination of waiting, and if there is no waiting to eliminate you have simply chosen one core. Image and video processing, cryptographic key derivation, large in-memory joins, anything numeric at volume — those want a runtime with real threads or a different service entirely. The subtler answer is that the failure usually is not a deliberate choice, it is accidentally synchronous work: a JSON.parse of a payload that grew from 50 KB to 40 MB, a regex that backtracks exponentially on hostile input, a bcrypt call at a raised cost factor on the main thread. Correct code, passing tests, and one caller can stall every request in flight. In production the signature is distinctive and nearly diagnostic: latency degrades uniformly across every endpoint, including ones that touch no shared dependency — because a database problem slows the endpoints using that table, while a blocked loop slows everything, since everything shares the thread. The metric that proves it is event loop lag: schedule a timer at a fixed interval and record how late it fires. Near zero when healthy, and it spikes to exactly the length of the synchronous work when blocked. It is a few lines, it costs nothing, and it belongs on the dashboard from the first deploy — because it separates "our dependency is slow" from "we are blocking", which are opposite problems with opposite fixes.
Company lens — how the same topic gets asked
micro1Practical: turn a sequential await loop into concurrent work and explain the difference.
GooglePrecision: what "single-threaded" covers, what libuv's pool covers, and why network I/O needs no thread.
MetaDebugging: latency degrading uniformly across all endpoints — name the cause and the metric.
AmazonJudgement: choose or reject Node for a given workload, and defend it by where the time goes.

Hands-on

Hands-on

Four exercises on the model. Exercise 2 is the one that transfers — turning sequential awaits into concurrent ones is the single most common real speed-up in Node code.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • Node exists to answer the C10K problem: a thread per connection reserves a megabyte whether it is working or waiting, and web servers overwhelmingly wait.
  • The bet was JavaScript specifically, because it was the one mainstream language with no blocking I/O library for programmers to reach for by habit.
  • Concurrency, not parallelism. Node overlaps waiting; it never runs two pieces of your JavaScript at once, and nothing computes faster.
  • await in a loop is sequential. For independent work that is the commonest accidental slowdown in the language; Promise.all is the fix.
  • One thread means no data races and no mutexes — two callbacks never run at the same instant, so every statement is effectively atomic.
  • But await is a yield point, so read-await-write is still a race condition. Re-check anything you read across one.
  • "Single-threaded" describes your JavaScript, not the runtime. libuv keeps a thread pool — four by default — for file, DNS, crypto and zlib work.
  • Network I/O uses no thread at all, which is what the whole scaling story actually rests on.
  • Blocking is the cost of the bet. A synchronous stretch in one request is added latency for every request in flight.
  • Event loop lag is the metric that proves it — near zero when healthy, spiking to the length of the synchronous work when blocked.

This chapter treated the event loop as a black box that "delivers results when they are ready". The next opens it. Node's loop is not one queue but a sequence of phases — timers, pending callbacks, poll, check, close — each with its own queue, and the order they run in explains behaviour that otherwise looks arbitrary: why a setTimeout(fn, 0) sometimes runs before an I/O callback and sometimes after, and why setImmediate exists at all when a zero-delay timer looks like the same thing.

Editorial reflection

Node's design is a single trade made very deliberately, and its critics and advocates are usually arguing about different halves of it. The trade is: give up parallelism within a process, and in exchange give up the cost of representing waiting. For a service that spends 99% of its time waiting on other services — which is most web software — that is close to free money. For anything that computes, it is a straightforward loss.

What makes it interesting rather than merely a niche choice is the second-order effect on the programmer. Because there is one thread, there are no mutexes, no lock ordering, no data races, and none of the material Volume 17 needed two full Parts to cover. An entire category of the hardest bugs in systems programming simply does not arise. That is an enormous simplification, and it is under-credited — Node is often described as scaling well, when the more durable benefit is that concurrent code in it is dramatically easier to reason about.

The honest cost is that the simplification is partial in a way that misleads. "No data races" gets rounded up to "no concurrency bugs", and then someone writes read-await-write and is genuinely baffled, because the mental model they were handed said this could not happen. Every runtime that hides concurrency behind a simpler abstraction has this problem — the abstraction is right almost always, and the exceptions are exactly where the expensive bugs live. Node's version of that exception is the await point, and a programmer who understands it as a yield rather than a pause has most of what this volume is going to teach.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.