What concurrency is: one thread vs. a million processes

LLM-authored, human-reviewed

Concurrency & the BEAM

Every article on this site about JavaScript and Elixir eventually reaches the same word - concurrency - and moves on. This one stops there. If you’ve read that “Node is single-threaded” or “the BEAM runs millions of processes” without knowing what to picture, this article builds that picture: what a thread of execution is, what the CPU does while your code runs, and how JavaScript and Elixir made opposite choices about sharing one machine among many jobs.

If you only want the two-minute version, the BEAM explainer covers the headline. This is the longer, mechanics-first version that the others lean on.

Concurrency is not parallelism (and the difference is load-bearing)

People use the two words interchangeably. That causes trouble here because the JavaScript/Elixir contrast is exactly where they come apart.

A useful working definition: things are concurrent if nothing forces them to happen in a specific order. Sorting two decks of cards is concurrent - you can sort one and then the other, jump back and forth, or, with extra hands, sort both at once. Parallelism is only the last option: doing two things at the same instant. Concurrency describes how the work is structured; parallelism describes how it is executed. You can have concurrency without parallelism (interleaving, one-at-a-time), and you can run concurrent work in parallel if the hardware allows it.

The distinction matters because a single CPU core executes one instruction at a time. It cannot physically do two things at the same instant. It switches between them so quickly that they appear simultaneous. True parallelism needs more than one core (or hyperthread, or CPU), with each doing its own instruction at its own instant. So “how many things can my program do at once?” has two answers: how many things it is juggling (concurrency), and how many things the hardware is actually running simultaneously (parallelism, capped by your core count).

JavaScript and Elixir answer both questions differently. That difference drives the rest of this article.

What a thread of execution is

A thread of execution is one sequence of instructions that the CPU walks through, one after another. It has a program counter, a call stack, and a set of registers. It is the smallest unit the operating system can schedule onto a core. Run two threads and the OS can put them on two cores (parallel), or time-slice them onto one core (concurrent but not parallel), switching between them fast enough that both appear to make progress.

An OS thread is the thread most programmers mean by default. The problem is that OS threads are heavy. Each reserves a chunk of memory for its stack

  • typically a couple of megabytes - and creating or switching between them goes through the kernel, which costs time and memory. There is a limit: a few thousand is a lot, tens of thousands starts to hurt, and a web server that gave every incoming connection its own OS thread would exhaust memory long before it exhausted bandwidth. Threads also (usually) share memory with other threads in the same process. That is where the trouble starts.

Shared memory is “the GOTO of our time”

When threads share memory and coordinate, they need locks - constructs that let only one thread touch a piece of data at a time while the others block. As one Erlang book puts it, shared-memory-with-locks “could reasonably be called the GOTO of our time: it’s the current mainstream technique… and just like programming with GOTO, there are numerous ways to shoot yourself in the foot.” Locks add overhead even when collisions are rare. They create points of contention, may remain locked by a crashed process, and can produce deadlocks that are “extraordinarily hard to debug.” This has “imbued generations of engineers with a deep fear of concurrency.”

The designers of JavaScript and Elixir looked at this expensive, lock-riddled world and wanted a better way to share one machine among many jobs. They chose opposite solutions. JavaScript avoids the problem with one thread and no sharing. Elixir avoids it with cheap threads and no sharing. Everything else follows from those choices.

How JavaScript uses the CPU: one thread, one event loop

When you run Node, your JavaScript executes on a single thread. One program counter walks through your code, and one call stack grows and shrinks. The event loop decides what that thread does next: it pulls a callback from a queue, runs it to completion, then pulls the next one. While a callback runs, nothing else on that thread runs. There is no other thread to take over.

That buys something real. Because only one piece of your code runs at a time, you cannot get a data race on shared JavaScript state within synchronous code. An entire class of locking bugs - the ones in the “GOTO of our time” passage - simply cannot occur. Two callbacks cannot interleave their reads and writes of a variable; one always finishes before the other starts. This is a genuine simplification. Anyone who has debugged shared-memory races in another language knows what it removes.

The cost: “Don’t Block the Event Loop”

The simplification has a price: the single most-repeated phrase in the Node documentation is don’t block the event loop. Node’s own docs state the failure mode plainly: while the event-loop thread is blocked executing a callback on behalf of one client - parsing a huge JSON payload, running a pathological regular expression, doing a synchronous computation - “it cannot handle requests from any other clients.” One slow synchronous task stalls every queued request in the entire process.

This is called head-of-line blocking, and it is the central structural fact of single-threaded JavaScript servers. That is why JSON.parse of a large body is dangerous, why a naive regex can take a service down, and why p50 latency can look fine while p99 blows up: most requests are fast, but when one request hogs the thread, every other in-flight request waits behind it and the tail inflates. The official remedies are manual - chunk the work with setImmediate, or offload it to a worker pool. They exist because the problem is structural, not a bug to fix.

What about I/O? The thread pool you don’t control

Here is the nuance that “Node is single-threaded” hides. Your JavaScript runs on one thread, but Node is not one thread. Underneath sits libuv, which runs a thread pool (four threads by default) for operations that would block if done synchronously - file system calls, DNS, some crypto work. When you call fs.readFile, the request goes to a thread-pool thread, the event loop keeps going, and a callback is queued when the read finishes. Node does use multiple cores for I/O. You just don’t write code against those threads, you can’t share JavaScript objects with them (anything passed to a Worker must be serialized and deserialized), and the pool is fixed-size and shared across all blocking work.

The precise picture is this: one thread executes your JavaScript; blocking I/O is offloaded to a fixed thread pool; everything is coordinated by a single event loop; and the moment any of your JavaScript hogs the main thread, every request waits. The concurrency model is cooperative. Your code must voluntarily yield (by await-ing, by returning from a callback) for anything else to get a turn. Nothing can interrupt a runaway computation because there is no separate unit of execution to do it.

This is why, as the comparison work puts it, “await is a syntax tax for a missing runtime feature.” JavaScript has no concurrency primitive of its own, so every function that might yield the event loop must be marked async, and the marking propagates through the whole call graph. The annotation tells the runtime where it may switch. You carry that burden because the runtime does not switch for you.

How Elixir uses the CPU: one process per job, preempted

Elixir’s model starts at the hardware and goes in the other direction. When you run a BEAM VM (the virtual machine Elixir compiles to), it starts one OS process that internally runs one scheduler thread per CPU core - four cores, four schedulers, by default. Onto those few schedulers it maps a genuinely enormous number of processes: lightweight and isolated, each with its own memory and mailbox. The theoretical limit is roughly 134 million; a few hundred thousand is unremarkable. This is called M:N threading - many (M) logical processes scheduled onto few (N) OS threads.

What a BEAM process is (and isn’t)

A BEAM process is not an OS process and not an OS thread. It is a green thread managed entirely by the VM. It takes a couple of microseconds to create and starts at around 2 KB of memory - versus a couple of megabytes just for an OS thread’s stack. That three-orders-of-magnitude difference makes the model work: you can give every connection, every request, and every background job its own process because processes are cheap the way objects are cheap in Java. As one Erlang book puts it: “If your program needs 10,000 processes running simultaneously to accomplish a job, it can easily be done. No unintuitive event loops, no thread pooling, none of those pesky implementation details.”

Crucially, processes share no memory. Each has its own heap. If one process wants another to know something, it sends a message - and the message is copied, not shared. This is the BEAM’s answer to “the GOTO of our time”: remove shared mutable memory, and you remove the class of lock and race bugs. “Nothing is shared. Everything is a message.” Two processes cannot step on each other’s data because there is no data they can both touch.

The scheduler: preemptive, not cooperative

This is where Elixir’s concurrency handles load differently from JavaScript. Pay attention to the scheduling rule.

Each BEAM scheduler is an OS thread that picks a process, runs it for a bit, and then picks another. The “for a bit” is precise: a process gets an execution window of roughly 2,000 reductions, where a reduction is approximately one function call. After its budget is up, the scheduler preempts it - forcibly deschedules it mid-computation and hands the core to the next process in the run queue. The process does not have to cooperate. It does not have to await. It does not even have to know that scheduling exists.

The contrast with JavaScript is total. JavaScript’s event loop is cooperative

  • a callback runs until it returns, and nothing can interrupt it. The BEAM’s scheduler is preemptive - a process runs until its reduction budget is spent, and then it is interrupted whether it likes it or not. On the BEAM, a CPU-bound request handler computing pi to a billion digits burns its own time-slices and degrades its own throughput. Its neighbors, running on the same schedulers, keep responding. Fairness is built into the BEAM. You engineer around it when necessary.

As Elixir in Action demonstrates, you can spawn an infinite CPU-bound loop under a single-scheduler VM and the shell still responds: the runaway process gets its 2,000 reductions and is yanked off the core over and over, while everything else gets its turn. On Node, the same infinite loop pins the thread and the process is gone.

How it maps to cores (and what “dirty” schedulers are for)

By default, BEAM runs as many schedulers as there are logical cores, so your processes spread across the hardware automatically - you don’t write parallel code, you write concurrent code and the runtime parallelizes it. Processes can migrate between schedulers to balance the load, and in recent releases the scheduler can pin work to cores to respect the CPU’s cache layout. You don’t think about core count; the program “just runs more efficiently if there are more CPUs.”

One wrinkle remains: genuinely long-running native work (a CPU-heavy NIF, a long garbage collection) can be handed to a separate dirty scheduler so it does not stall the normal ones. The model handles these cases; it does not pretend they do not exist.

The contrast, made concrete: an infinite loop

Here is the cleanest illustration. Imagine a learner submits code with an infinite loop.

In JavaScript, that loop runs on the one event-loop thread. It never returns, so no callback runs again. Every other in-flight request waits forever. The CPU core is pinned. The process either hangs indefinitely or, if the runtime is enforcing a timeout, has to be killed externally - and killing it loses every in-flight request because they all live in the same process.

In Elixir, that loop runs in one process. It burns through 2,000 reductions and gets preempted. Other processes on the same schedulers keep serving requests uninterrupted. If something decides the loop has run too long, the runtime kills that one process - and only that one. Its supervisor could restart it with clean state. Every other connection, every other request, and every other process is untouched.

That is why this site’s own code runners enforce strict wall-clock timeouts on every submission: the languages they run do not all self-limit the way the BEAM does, so the timeout must come from outside. On the BEAM, the scheduler budget is the self-limit. Same problem, two different places for the safety net.

What the difference buys (and what it costs)

The scale numbers make the trade-offs concrete.

Phoenix Channels, in a famous November 2015 benchmark, held two million simultaneous WebSocket connections on a single 40-core/128 GB server - one BEAM process per connection, limited only by OS file-descriptor limits, using about 84 GB at peak (roughly 64 KB per connection including kernel buffers). That required real kernel tuning and PubSub sharding; it is a ceiling demonstration, not a default. WhatsApp famously ran roughly two million concurrent connections per Erlang server, serving around 450 million users in 2014 with about 50 engineers and 550 servers. Practitioner guidance for a single Node process, by contrast, puts comfortable territory at 10,000 to 100,000 concurrent connections - a rule of thumb driven by per-connection memory (socket objects, buffers, closures) that is far heavier than a BEAM process’s 2 KB footprint.

The fairness result matters more than the raw count. A published Stressgrid study (February 2019) ran 100,000 connections, each doing a 100 ms database call. Node peaked at around 60,000 connections and 25,000 requests per second, with a sharp jump in latency and latency deviation at around 5,750 requests per second. Elixir stayed nearly flat while consuming significantly more CPU. The mechanism is head-of-line blocking: on Node’s single thread, one slow operation delays every queued request, so p50 looks fine while p99 inflates. The BEAM’s preemptive schedulers and per-process heaps make that structurally impossible. You buy the flat tail; you do not tune it into existence.

The qualifier matters: the BEAM used more CPU for the same work. That is the trade, and it is the same one the performance article covers in depth. The BEAM trades instruction-level efficiency for fairness: each process pays a reduction-budget check, each message pays a copy, and each process pays for its own heap. In return, no process can starve the others. Port a tight CPU-bound loop from Node to single-threaded Elixir and benchmark it; you will measure a regression. V8’s optimizing JIT is better at raw single-threaded instruction throughput than the reduction-budgeted BEAM. The BEAM wins under concurrent load, where the goal is not to finish one request as fast as possible but to keep the thousandth request moving.

There is also a memory-model trade. Node uses one generational garbage collector for the entire process, behind a hard heap limit (roughly 2–4 GB on modern 64-bit Node). When live objects approach the limit, V8 crashes the process outright with “JavaScript heap out of memory” - a cliff, not graceful. GC pauses grow with live heap size, so a process holding many connections’ state in one shared heap hits its longest pauses when busiest, feeding the tail-latency problem. The BEAM gives every process its own tiny heap, collected independently and typically in microseconds, because pause time scales with one process’s live data, not the whole system’s. Idle processes can hibernate to near-zero. There is no global stop-the-world. (Honest caveat: binaries larger than 64 bytes live on a shared refcounted heap, and a single process with a huge mailbox can still cause a long local collection. Fine-grained, not magic.)

Where each model is genuinely the right call

The honest summary is not “Elixir is better at concurrency.” The models optimize for different definitions of “good,” and both definitions are legitimate.

JavaScript’s single-threaded model is the right call when the work is start-and-finish: handle a request, transform some JSON, render a page, return. For that shape, one fast thread with an event loop is hard to beat. V8’s JIT makes each individual request fast, the cooperative model means you never think about locks, and the ecosystem (npm’s millions of packages, full-stack TypeScript, Next.js and the SSR story) is the product. Node also slots into Kubernetes cleanly: stateless services behind a load balancer, scaled horizontally, is exactly the deployment model Node’s single-threadedness implies. Cold starts are fast too - Node Lambda cold starts measure around 315 ms, while Elixir-on-serverless reports roughly 1.2 seconds, and the community’s own consensus is that serverless “doesn’t leverage any of the strong points of BEAM.”

Elixir’s process-per-job model is the right call when the work is sustain-and-survive: many mostly-idle connections held open simultaneously, soft real-time responsiveness under load, and systems that must keep running through partial failures. The per-process memory model means connection density is set by RAM and kernel sockets, not one shared heap. The preemptive scheduler keeps tail latency flat as load climbs. Shared-nothing isolation plus supervision means a crash takes out one process, not the whole service, and a supervisor restarts that one. Resilience in Node is platform-provided (replicas, restarts, external state stores); in Elixir it is runtime-provided (processes, supervisors, hot code upgrades). Neither is free. The Node version costs external moving parts; the BEAM version means swimming against the grain of container-orchestration culture that assumes stateless processes.

The honest summary

A thread of execution is one sequence of instructions walking through your code. Every runtime must decide how many sequences it juggles and how. JavaScript and Elixir chose opposite ends of the design space.

JavaScript gives your code one thread and a cooperative event loop. One callback runs to completion before the next starts, so shared-state data races cannot occur (a real gift), but one slow synchronous task blocks everything (the price). I/O is offloaded to a thread pool you do not directly control; concurrency is opt-in, marked with async/await, and no authority can interrupt a runaway computation. It is fast on a single core, simple to reason about within a request, and it hits a wall when one job can stall the rest.

Elixir gives your code a preemptive scheduler running millions of isolated processes, one per job, mapped across all your cores. Each process gets a 2,000-reduction budget and is then forcibly preempted, so no process can starve the others. Processes share no memory and communicate by copying messages, which removes the class of lock and race bugs at the cost of paying for each message. It is less efficient per individual instruction - the reduction budgets and per-process heaps are overhead V8 does not carry - but it stays fair under load where the single-threaded model cannot.

The technique is the same on both sides: share one machine among many jobs. The difference is whether the runtime makes that safe with one thread and nothing to fight over (JavaScript), or with a million threads that cannot touch each other (Elixir). Know which choice your runtime made, and what it gave up. That is the difference between being surprised when a slow request stalls your server and knowing exactly why it did.

Where to go next

  • Process Ring - the Elixir half of this article, as an exercise: a million processes, and a ring of them passing a token.
  • Ping-Pong Counter - two processes bouncing a counter. Message passing, no shared memory.
  • Print in Order - the ordering question: given tasks and prerequisites, is a valid order even possible?
  • Print in Order (concurrent) - three processes that must fire in sequence while the harness scrambles the calls.
  • Dining Philosophers - deadlock as an exercise, the same table the dining-developers article names.
  • The dining developers - how concurrency fails, from the shared-memory world this article contrasts with the BEAM.
  • Publish-subscribe and The BEAM is the broker - the pattern that makes the million-process model usable, and the runtime that makes it cheap.

Related exercises

← Back to articles