Async & Event Loops

Event Loop or Threads?

An honest decision, with real costs on both sides. The loop wins on many waiting tasks, cheap per-task memory and a simpler shared-state model. Threads win on blocking libraries, parallel CPU work and code that is easier to read and debug. Most production systems end up hybrid, and that is not a failure of the choice.

▶ Run the lab

The question this answers

The question

For this workload, does an event loop or a thread-per-task model actually cost less — in latency, memory, and engineer-hours?

The work

An API service handling 5,000 concurrent connections, each of which makes two downstream HTTP calls and one database query, plus a 12% minority of requests that render a PDF.

What is shared

A rate-limit counter per API key and a warm in-process cache. On a loop they are plain objects with no lock and interleaving hazards at every await; with threads they are the same objects behind a mutex, safe across a whole function body and contended under load.

The invariant — what must stay true under every interleaving

Every accepted request eventually gets a response or an explicit error within its deadline, and the rate-limit counter equals the number of requests actually admitted for that key.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The two columns, with the costs kept in

The comparison is usually presented as "async scales, threads are heavy", which is half of one row. The full picture has trade-offs pointing in both directions, and the direction that decides most real projects is not performance at all — it is what the ecosystem's libraries do when they wait.

The single most decisive question: does anything on the request path block? One synchronous database driver, one native module without an async path, one fs.readFileSync in a config reload, and the loop model collapses for every concurrent request. Threads absorb a blocking call as a matter of course; that is what they are for. OS-side mechanics live in Threads versus Async versus Processes and Blocking, Non-blocking, Multiplexed, Asynchronous.

The second most decisive question is what your team can reason about. Threads let you hold a lock across an entire function and know nothing interleaves. Loops let you skip locking on your own heap but demand that you audit every await point (Await Is a Yield Point). Both are learnable; neither is free.

DimensionEvent loopThread per taskWhich wins
10,000 mostly-idle connectionsA closure and a promise each — a few hundred bytesA stack each — commonly 512 KB to 8 MB of reserved address spaceLoop, decisively
A blocking library on the pathStalls every concurrent request in the processStalls one thread; the rest keep servingThreads, decisively
Parallel CPU workNone from async; needs workers or processesNative — the OS schedules threads across coresThreads
Shared mutable stateNo data races on the loop's heap; race conditions across every awaitData races are real; a mutex covers a whole function bodyLoop for data races, threads for reasoning span
DebuggabilityStack traces cut at await boundaries; async task dumps are immatureThread dumps, breakpoints and traces that show the whole call pathThreads
Tail latency under a slow handlerOne slow synchronous task delays every pending taskOne slow thread delays only its own requestThreads
Cost per additional concurrent taskAn allocationA thread creation, or a slot in a pool that must be sizedLoop
Context-switch overhead at high concurrencyNone between tasks — the loop just calls the next callbackKernel switches, scheduler pressure, cache pollutionLoop
BackpressureMust be built: the loop happily accepts more than it can serveEmergent: the pool is a natural bound, and the queue is visibleThreads
CancellationA promise cannot be cancelled; needs AbortController plumbingAlso awkward — interruption is cooperative in every sane designNeither
Trade-offs in both directions. No column wins a majority of rows.

Where the memory and the switching actually go

The loop's structural advantage is that an idle task costs almost nothing. Five thousand connections waiting on a downstream response are five thousand heap objects; the process is blocked in one readiness syscall and using no CPU. The equivalent thread-per-connection design reserves five thousand stacks and puts five thousand threads on the scheduler's runqueues, which is why that model is usually replaced by a bounded pool (Thread per Connection, The Thread Pool Server).

But the bounded pool is where the story turns. With a pool of N threads and blocking calls, in-flight requests are capped at N — request 201 waits for a thread even though the machine is idle, because all 200 threads are parked in recv(). The loop has no such cap, which is simultaneously its advantage and the reason it needs explicit Bounding Concurrency: it will happily accept 20,000 in-flight requests and turn a capacity problem into a memory problem.

The timeline makes the difference concrete for the interesting case — three requests where the work is mostly waiting, on a pool of two threads.

Three I/O-bound requests: a two-thread blocking pool versus one event loop. Spans are shape, not measurement.ILLUSTRATIVE
Pool thread 1 (blocking)
R1: parse
R1: blocked in recv()
R1: respond
Pool thread 2 (blocking)
R2: parse
R2: blocked in recv()
R2: respond
Request 3 (pool exhausted)
queued — no thread free
parse + wait + respond
One event loop (all three)
R1 R2 R3: parse, start I/O
idle in epoll_wait — no CPU used
R1 R2 R3: resume and respond
↑ all three waiting on I/O↑ loop: all done — pool: R3 has not started
runningreadywaitingblockedidle1 tick ≈ 20 ms

The failure that decides it: one blocking call

Everything above is a performance argument, and performance arguments can be argued with. The blocking-call failure cannot. It converts a per-request problem into a per-process one, and it does so silently — the code looks correct, the tests pass, and the symptom in production is that unrelated endpoints got slow.

The schedule below is the whole case. Note what is *not* happening: no lock, no shared data, no race condition. Three independent requests, and one of them takes the only executor there is.

The practical answer when this is unavoidable is the hybrid: keep the loop for I/O and hand blocking or CPU-bound work to a real thread pool — worker_threads in Node (Worker Threads), loop.run_in_executor in asyncio, a dedicated pool in a C++ reactor. That is what Hybrid Runtimes: It Was Never Threads Versus Async is about, and it is where most mature services land.

One synchronous library call on an event loop. Three requests, one executor.ILLUSTRATIVE
Invariant · Every accepted request gets a response within its 100 ms deadline
#Request 1 — /report (sync PDF lib)Request 2 — /healthRequest 3 — /users/42Event loopState
1···accepts R1, R2, R3; all three handlers are readypending=3 elapsed=0 ms
2enters renderPdfSync() — a synchronous native call···pending=3 elapsed=0 ms loop=held by R1
3·ready to run, cannot: the loop is inside R1··pending=3 elapsed=40 ms loop=held by R1
4··database response arrives on the socket; the continuation is enqueued and waits·pending=3 elapsed=80 ms loop=held by R1
5renderPdfSync() returns after 900 ms; R1 responds···pending=2 elapsed=900 ms loop=free
6·health check finally runs and responds··pending=1 elapsed=901 ms
✕ A 1 ms health check took 901 ms. The load balancer marked the instance unhealthy and removed it, so the remaining instances now carry its traffic and start doing the same thing.
7··responds with data that was ready 820 ms ago·pending=0 elapsed=902 ms
✕ The 100 ms deadline was missed by work R3 had nothing to do with. The trace blames the database, which answered in 8 ms.
With threads this schedule is impossible: R1 occupies one thread and R2 and R3 answer in single-digit milliseconds. The loop model requires that *nothing* on the path blocks, and that is a property of every dependency you have, forever — not a property you can assert once. When you cannot guarantee it, go hybrid rather than pretending.

Key points

  • The loop wins on many concurrent waiting tasks, per-task memory and the absence of context switching between tasks.
  • Threads win on blocking libraries, parallel CPU work, debuggability, and tail latency isolation between requests.
  • The decisive question is usually not throughput but whether anything on the path blocks — one synchronous call stalls every concurrent request on a loop.
  • A bounded blocking pool caps in-flight requests at the pool size; a loop caps them at nothing, which is why it needs explicit concurrency limits.
  • Threads have data races and need locks; loops have race conditions across every await and need discipline. Neither model removes concurrency bugs.
  • Hybrid — loop for I/O, real thread pool for blocking and CPU work — is where most mature systems land, and it is a design, not a compromise.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Event loop: register interest, return to the scheduler, the kernel reports readiness, the continuation is enqueued and runs on the one executor.
  • Thread per task: the task owns an executor for its whole life, blocks inside syscalls, and the kernel scheduler switches it out while it waits.
  • The loop's per-task cost is an allocation; the thread's per-task cost is a stack plus a scheduler entity.
  • The loop has no preemption between tasks; the OS preempts threads on a quantum, so a runaway thread cannot starve its peers the way a runaway callback can.
  • A blocking call on the loop occupies the single executor; a blocking call on a thread occupies one of many.
  • Hybrid runtimes keep a loop for readiness-driven I/O and dispatch blocking or CPU work onto a bounded pool, paying a handoff to keep the loop free.
Interleavings that matter
  • Loop: R1 enters a 900 ms synchronous call; R2 (a 1 ms health check) and R3 (whose data arrived at 80 ms) both respond at ~900 ms; the instance is pulled from the load balancer.
  • Threads: the same R1 occupies one pool thread; R2 and R3 respond in single-digit milliseconds on other threads. The identical code has a completely different blast radius.
  • Bounded pool of 200 with blocking I/O: request 201 waits for a thread while every CPU is idle — latency created purely by the concurrency bound.
  • Loop with no limit: 20,000 requests accepted, all in flight, heap climbing, the downstream database at 20,000 concurrent queries. The absence of a bound became the incident (Parallelism Moves the Load Downstream).
  • Threads: two requests read the rate-limit counter simultaneously on two cores and both write the same value — a genuine data race that a mutex fixes and a loop never has.
  • Loop: two requests read the same counter around an await and both write the same value — a race condition with no data race, that a mutex would not have helped with anyway.
What it guarantees — and does not
  • Event loop guarantees: no data race on that loop's heap; no context switch between tasks; very low per-task memory.
  • Event loop does NOT guarantee: isolation between requests, fairness, parallel CPU execution, or any protection across an await.
  • Threads guarantee: one slow or blocked task cannot occupy the whole process; preemption, so no task starves its peers; real parallelism across cores.
  • Threads do NOT guarantee: safety of shared state, a useful pool size (there is no universal formula — see Sizing a Thread Pool), or that the memory cost is acceptable at high concurrency.
  • Neither guarantees: freedom from concurrency bugs, working cancellation, or backpressure. All three are design work in both models.
Where contention appears
  • Loop: the executor is the contended resource, and the queue delay is the contention metric — event-loop lag.
  • Threads: locks are the contended resource, plus the scheduler itself once runnable threads exceed cores.
  • Pool: the pool queue is the contention point, and pool saturation is the signal that in-flight work exceeds the bound.
  • Hybrid: the handoff between loop and pool is a queue too, and it is the one nobody instruments until it backs up.
How it fails
  • Loop: one synchronous handler stalls every pending task, and the metrics blame whichever dependency was in flight at the time.
  • Loop: unbounded acceptance turns a traffic spike into memory exhaustion and downstream overload.
  • Threads: data races on shared state, deadlock from lock ordering, and convoying under contention.
  • Threads: pool exhaustion, where every thread is parked in a blocking call and new work queues behind them.
  • Threads: oversubscription, where more runnable threads than cores makes every request slower without raising throughput.
  • Hybrid: work dispatched to the pool while holding a loop-side claim, so a saturated pool wedges the loop-side state as well.
When it helps
  • Choose the loop when the work is dominated by waiting, concurrency is high, the whole dependency stack is genuinely async, and per-connection memory matters.
  • Choose threads when libraries block, when CPU work is on the request path, when requests must be isolated from each other's latency, or when the team needs ordinary stack traces to operate the system.
  • Choose hybrid when both are true, which is most of the time: loop at the edge for connection handling, pool behind it for anything that blocks or computes.
  • Choose neither when the concurrency is low: a simple sequential process is cheaper to run and far cheaper to debug (Concurrency Is Always Bought With Complexity).
When it hurts
  • The loop hurts the moment one dependency blocks, and you will not always control when that becomes true — a dependency upgrade can introduce it.
  • The loop hurts teams that read "single-threaded" as "no concurrency bugs" and stop auditing await points.
  • Threads hurt at very high connection counts, where stacks and scheduler pressure dominate before any real work does.
  • Threads hurt when shared state is pervasive and the locking discipline is not, which produces bugs that vanish under a debugger (Heisenbugs: The Bug That Leaves When You Look at It).
  • Hybrid hurts by doubling the operational surface: two saturation points, two queues, two sets of metrics.
How you would know
  • Loop: event-loop lag percentiles and the count of synchronous tasks over a threshold. If lag rises with load, the model is the bottleneck.
  • Threads: pool queue depth, threads blocked versus runnable, and lock wait time at p99. See worker-pool-saturation and lock-contention.
  • Both: memory per concurrent request, measured under a load test at the concurrency you actually expect, not at your laptop's.
  • Both: tail latency of a trivial endpoint under load. A health check that degrades with unrelated traffic is the loop-blocking signature.
  • Run the decision as an experiment where you can: the same handler behind a loop and behind a pool, under load-test-shapes that match production arrival patterns.
Complexity it introduces
  • The loop adds await-point auditing, bespoke mutual exclusion, and stack traces that omit the interesting frames.
  • Threads add locking discipline, lock ordering, a pool size to justify, and the whole memory-model conversation.
  • Hybrid adds both, plus the handoff — and the handoff is where the subtle failures live, because it is a queue that nobody drew on the architecture diagram.
  • Whichever you pick, the choice propagates into every library selection you make afterwards; it is one of the least reversible decisions in a service.
Simpler alternatives
  • One process per core with an event loop in each (the Node cluster / nginx worker shape): parallelism without shared memory, at the cost of per-process caches and connection pools.
  • Separate processes by workload: a loop-based API service and a thread-based rendering service. The blocking work cannot hurt the fast path because it is not in it.
  • A runtime that hides the choice — Go's goroutines, Java's virtual threads, Rust's async runtimes — where blocking code is written plainly and the scheduler multiplexes it. Genuinely the best of both, if you can choose the language.
  • Do less concurrently: a queue plus a single consumer removes the question and is correct by construction when throughput allows it.

Server model lab

Three server models under the same load
Thread per request, a bounded pool and an event loop, all fed the same requests by the same model.
SIMULATEDOne model, three shapes — not a benchmark of any framework.

Each model is the same simulator given a different worker shape: a thread per in-flight request, a fixed pool, or one task per core where waiting does not occupy a worker. Memory is a per-thread stack estimate. Real servers differ by orders of magnitude in all of these, and every runtime has its own hybrids. Concurrency in flight is the knob; offered load is derived from it as concurrency ÷ service time.

Thread per requestunstableworkers=60
One OS thread per in-flight request. Blocking code is allowed to block.
throughput824.7/s
memory124 MB
latencyunbounded
service + queueing
switches/req57
124 MB resident
▲ 60 runnable threads on 4 cores: the scheduler now spends real time moving threads instead of running them, and every thread costs about a megabyte of stack whether or not it is doing anything.
Bounded thread poolunstableworkers=32
A fixed number of threads pull from a queue. Overload becomes queueing, not thread creation.
throughput761.9/s
memory97.2 MB
latencyunbounded
service + queueing
switches/req29
97 MB resident
Event loophealthyworkers=4
One task per core, thousands of tasks in flight. Waiting costs a callback, not a thread.
throughput1428.6/s
memory68.5 MB
latency43 ms
includes I/O wait held off the loop
switches/req0
68 MB resident
At these settings — 60 in flight, 2 ms of CPU, 40 ms of waiting, a 0 ms blocking section — Event loop retires the most work. Change one number and the ranking moves: raise the blocking section and the event loop’s tail explodes while the threads keep being preempted; raise the concurrency and thread-per-request drowns in stacks and switches; drop the concurrency to a handful and all three are indistinguishable, at which point the simplest one wins on the only axis left, which is how hard it is to debug at 3 a.m. No model wins everywhere, and every real runtime you will use is a hybrid of at least two of them.
offered 1,429/s from 60 in flightSIMULATED

Thread pool: utilization and queue

Thread pool — utilization, queue depth, and the point where the numbers stop existing
A pool of workers serving a stream of requests. Sakasegawa's M/M/c approximation, with the honest answer above the knee.
utilization ρ75% · capacity 160/s
pool workers busy6 of 8
utilization
75.0%
mean queue depth
1.2
mean wait for a worker
9.8 ms
mean in flight (L = λW)
7.2
capacity  = workers / service = 8 / 50 ms = 160.0 req/s
ρ         = arrivals / capacity = 120 / 160.0 = 0.750
Little    L = λ × W  →  0.120/ms × 59.8 ms = 7.2 in flight
engine    status = healthy
ρ = 75.0%, mean wait 9.8 ms on top of 50 ms of service. Queueing is non-linear: the wait term carries 1/(1 − ρ), so the step from 80% to 90% utilization costs more than everything before it. Little's Law ties the three numbers together — L = λ × W, so 7.2 requests are inside the system at any moment. That is the number to size the pool against, and it is measurable in production; the pool size is not something to derive from a formula about core counts. Push arrivals past 160/s and watch the numbers refuse to answer.
SIMULATEDsmooth arrivals; real traffic is burstier and queues earlier

Concurrency lab

Concurrency lab
Six knobs, one model. Ask it the only question that matters: does more concurrency help this workload, and what stops it?
SIMULATEDThese numbers describe no real system.

They come from a queueing and contention model inside Engineer Atlas. What is faithful is the behaviour: work that waits benefits from more workers, work that computes does not, a wide critical section pins parallelism near 1 no matter how many cores you buy, and arrivals past capacity produce an unbounded queue rather than a large latency. Real arrivals are burstier than this model assumes, so real systems reach every one of these walls earlier than the sliders suggest. Do not quote a millisecond from this page.

Controls
Cores the process may actually run on. This is the parallelism ceiling.
Threads or tasks in flight. Not the same quantity as cores, and rarely the same number.
Time actually holding a core. This is the only part cores can parallelise.
Waiting while holding no core. This is the part concurrency can hide.
The slice of the CPU work only one task may execute at a time. Clamped to the CPU time.
Offered load. Past capacity the queue has no steady state at all.
Snapshot the current settings, then change one thing. The model is pure, so the “before” column costs nothing to keep.
throughput
150/s
offered 150/s
latency
31 ms
service 30 ms
effective parallelism
2.67
of 4 cores
lock wait
0.0 ms
no critical section
core wait
0.5 ms
queued for a core
switch overhead
0.3 ms
5 switches/task
CPU utilisation38%
Lock utilisation (no critical section)0%
healthy
Retiring 150/s at 38% CPU. Headroom remains; the next constraint appears at about 267/s.
Change one thing · each preset snapshots the current settings first
healthystatus comes from the model’s discriminated result, not from reading the sentence belowSIMULATED

What people believe, and what is true

Claim

Async is faster than threads.

Reality

Async is cheaper *per waiting task*. For CPU work it is slower — it adds a state machine and gives no parallelism. For blocking libraries it is catastrophic.

Claim

Threads do not scale.

Reality

Thread *per connection* does not scale to tens of thousands. A bounded pool scales fine and is the default for most of the industry; the real cost is stack memory at high concurrency.

Claim

Picking the loop means I never need a thread pool.

Reality

You will need one the first time you meet a blocking library, a CPU-bound handler, or a native module. Plan the hybrid boundary rather than discovering it.

Go deeper

Overview

Loop: many waiting tasks share one executor cheaply. Threads: each task owns an executor and can block or compute freely.

Practical

Ask whether anything on the path blocks. If yes, threads or a hybrid. If no, and concurrency is high and mostly waiting, the loop. If concurrency is low, neither — write it sequentially.

Advanced

The loop trades isolation for density: no per-task executor means no per-task protection either. Threads trade density for isolation. Hybrid buys both and pays with a queue at the boundary that must be bounded and instrumented.

Internals

Both models sit on the same kernel facilities. The loop uses readiness notification (epoll/kqueue) plus non-blocking sockets; the thread model uses blocking syscalls and the scheduler's runqueues. Modern completion-based interfaces such as io_uring blur the line by making the "loop" a completion queue rather than a readiness one.

Apply it