Concurrency in Real Systems

Event-Driven Servers: Many Connections, One Loop

Forty thousand sockets, one thread, and a readiness mechanism that says which of them can be served without blocking. The cost per idle connection drops to a buffer and a descriptor — and in exchange, every handler on that thread is now responsible for the latency of every other connection.

The question this answers

The question

How does one thread serve forty thousand connections, and what does that thread now owe everyone?

The work

A chat gateway holding 40,000 long-lived websocket connections, of which perhaps 300 have data to move in any given millisecond.

What is shared

Per-connection buffers and the connection registry, plus any application state handlers touch. Because handlers run one at a time on the loop thread, none of it is concurrently accessed — which removes an entire class of bug.

The invariant — what must stay true under every interleaving

Every socket with data ready is eventually serviced, no handler runs while another is running on the same loop, and no handler occupies the loop long enough to starve the others.

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?

Readiness, not blocking

The mechanism is a readiness query: hand the kernel a set of file descriptors, ask which ones can be read or written without blocking, and act only on those. That is I/O Multiplexing: select, poll, epoll, kqueue, IOCP and Blocking, Non-blocking, Multiplexed, Asynchronous in Operating Systems, and The Event Loop for the loop itself. The move that makes it scale is that the *cost is proportional to ready connections, not to total connections* — 40,000 idle sockets cost a descriptor and a buffer each, and contribute nothing to any iteration of the loop.

Compare that against Thread per Connection, where 40,000 connections means 40,000 stacks whether or not any of them have data. That is the whole C10K: Ten Thousand Connections, Then a Million argument, and the reason event-driven servers dominate at high connection counts: the resource that scales with connections drops from a megabyte to a few kilobytes.

A property that gets less attention than it deserves: because only one handler runs at a time on the loop, application state touched by handlers is not concurrently accessed. No mutex, no data race, no memory-model reasoning. That is a genuine simplification, and it is why event-driven code is often easier to get *correct* than threaded code even though it is harder to get *responsive*.

One iteration of the loop
kernel tracks readinessreturns only ready fds — cost ~ ready, not totaldispatch, sequentiallynever blocks: partial reads are normalsame thread, same queuehandler MUST return quickly40,000 sockets registeredExpired timers + completed callbacksReadiness query "who can move data?"Ready list ~300 this tickRun handler (one at a time)Issue non-blocking read / writeNext iteration
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The debt: every handler owes the loop its promptness

The bargain is explicit. A handler runs to completion on the only thread there is, so its duration is added to the latency of every other connection waiting for that iteration. A 200ms JSON serialization is not a slow endpoint; it is a 200ms outage for 40,000 connections, and the health-check endpoint is among them — which is how an event-driven server gets removed from a load balancer for being CPU-busy. Blocking the Event Loop is the full treatment; the systems-level consequence is what matters here.

This is also why *accidentally* blocking is so dangerous. A synchronous file read, a DNS lookup that falls back to a blocking resolver, a regular expression with catastrophic backtracking, a synchronous crypto operation on a large payload — each looks like a normal function call and each stops the world. The discipline "never block the loop" has to hold for every dependency, not just your own code, and there is no compiler enforcing it.

The timeline below shows the same loop with and without one long handler. Note that the damage is not proportional to how many connections the slow handler serves — it is proportional to how many connections exist. That non-locality is the defining hazard of the model.

Two loop iterations: a healthy one, then one containing a 200ms synchronous handler.ILLUSTRATIVE
Healthy loop thread
poll: 300 ready
300 handlers, ~2µs each
poll again
280 handlers
idle — waiting for readiness
Loop thread with one slow handler
poll: 300 ready
handler #47: synchronous compress
remaining 253 handlers
poll
Connection #9,412 (unrelated)
data arrived at the socket
served
Health check from the load balancer
waiting on the same loop
too late
↑ slow handler begins↑ loop free again
runningreadywaitingblockedidle1 tick ≈ 20ms

Where the correctness surprises actually are

People arrive expecting race conditions and find none, because handlers do not overlap. Then they hit the real hazard, which is that an *await point is a scheduling point*. Between two lines of an async handler, arbitrary other handlers may have run and mutated the state you read before the await. The code looks sequential and is not; this is a race condition without a data race, and no detector will find it — see Reasoning About Races: A Method, Not an Instinct and Data Race Is Not Race Condition for why those are different things.

The concrete shape is the check-then-act across an await: read a balance, await a network call, then write based on the value you read. Two concurrent tasks both pass the check. Every access was on one thread; nothing was concurrent in the memory sense; the invariant is still broken.

The second surprise is resource unboundedness. Because a pending task is cheap, nothing naturally stops you from having a million of them, and a slow downstream converts directly into memory growth — Unbounded Concurrency and Bounding Concurrency. In a thread-based server, thread creation cost provides accidental backpressure; an event-driven server has no such accident and needs an explicit limit.

1// One thread. Handlers never run simultaneously. This is still broken.
2
3const balances = new Map<string, number>()
4
5async function withdraw(user: string, amount: number) {
6 const balance = balances.get(user)! // (1) read
7 if (balance < amount) return 'INSUFFICIENT'
8
9 await auditLog.record(user, amount) // (2) AWAIT = scheduling point
10 // ^ the loop is free here. Another withdraw() for the same user
11 // can run (1) and (2) and (3) completely, before we resume.
12
13 balances.set(user, balance - amount) // (3) write, using the STALE read
14 return 'OK'
15}
16
17// Two concurrent calls, balance = 100, amount = 100 each:
18// A(1) reads 100 -> passes guard -> awaits
19// B(1) reads 100 -> passes guard -> awaits
20// A(3) writes 0
21// B(3) writes 0 <-- 200 withdrawn from a 100 balance
22//
23// A race detector finds nothing: one thread, no unsynchronized memory access.
24// The fix is an invariant-level one - hold the decision and the write together:
25
26const inFlight = new Set<string>()
27
28async function withdrawSafe(user: string, amount: number) {
29 if (inFlight.has(user)) return 'RETRY' // a per-key gate, not a mutex
30 inFlight.add(user)
31 try {
32 const balance = balances.get(user)!
33 if (balance < amount) return 'INSUFFICIENT'
34 balances.set(user, balance - amount) // decide and write with NO await between
35 await auditLog.record(user, amount) // awaiting after the state change is fine
36 return 'OK'
37 } finally {
38 inFlight.delete(user)
39 }
40}
A race condition on a single thread. No data race, no lock would have been reported missing.

Key points

  • The loop asks the kernel which descriptors are ready and services only those, so cost scales with active connections rather than total ones.
  • Forty thousand idle sockets cost a descriptor and a buffer each, against a stack each under thread-per-connection — that is the whole c10k argument.
  • Handlers do not overlap, so application state touched by handlers is not concurrently accessed. That removes data races entirely.
  • In exchange, every handler owes the loop its promptness: a 200ms handler adds 200ms to every connection, including the health check.
  • The real correctness hazard is that an await point is a scheduling point — check-then-act across an await is a race condition with no data race.
  • Pending tasks are cheap, so nothing provides accidental backpressure; concurrency must be bounded explicitly or a downstream outage becomes an OOM.

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
  • Register every connection's descriptor with the kernel's readiness mechanism, along with which events you care about.
  • Call the readiness primitive; it returns only the descriptors that can now be read or written without blocking, at a cost proportional to that set.
  • For each ready descriptor, run its handler to completion on the loop thread. Partial reads and writes are normal and must be handled — there is no "read exactly N bytes" here.
  • Interleave expired timers and completed callbacks into the same queue, since they contend for the same single thread.
  • Return to the readiness call. The loop's health is measured by how long an iteration takes and how late timers fire — that is event-loop lag.
Interleavings that matter
  • 300 of 40,000 sockets are ready. The loop services 300 handlers at a few microseconds each and returns to poll within a millisecond. The other 39,700 connections cost nothing this iteration.
  • Handler #47 compresses synchronously for 200ms. Connection #9,412 had bytes waiting in the kernel the whole time and is served 200ms late, as are 39,999 others. Nothing about #9,412 was slow.
  • Task A reads balance 100, awaits an audit write; task B runs entirely — reads 100, passes the guard, writes 0; A resumes and writes 0 from its stale read. One thread, two withdrawals, one balance.
  • A downstream stalls for 40 seconds; 200,000 tasks accumulate, each holding its request buffer, and the process is killed for memory long before any timeout fires.
What it guarantees — and does not
  • It guarantees mutual exclusion between handlers on the same loop for free — no two handlers execute simultaneously, so no data race exists between them.
  • It does not guarantee atomicity across an await. A suspended handler has published whatever partial state it wrote before suspending.
  • It guarantees that idle connections are nearly free. It guarantees nothing about how long a connection waits when the loop is busy.
  • It does not guarantee parallelism: one loop uses one core, so CPU-bound throughput does not improve with core count — see Async Is Not Parallelism.
  • Ordering between handlers is a scheduling detail of the runtime, not a contract; do not build invariants on which callback runs first.
Where contention appears
  • The contended resource is *the loop thread itself*. Every handler queues for the same time slice, and the queue is invisible unless loop lag is measured.
  • Timers, I/O callbacks and microtasks all compete for the same thread, and a flood of any one of them starves the others.
  • With multiple loops in one process (one per core), shared application state becomes genuinely concurrent again and the no-data-race property is lost.
How it fails
  • Loop stall: one CPU-bound or accidentally-synchronous handler makes the entire process unresponsive, presenting as a total outage rather than a slow endpoint.
  • Health-check eviction: the load balancer removes the instance because its probe timed out behind the stall, redistributing load onto the remaining instances.
  • Race condition across an await: check-then-act with a suspension in the middle, invisible to race detectors and to review that is looking for locks.
  • Unbounded pending tasks under downstream slowness, ending in memory exhaustion.
  • Head-of-line blocking within a connection when a single large message monopolizes the handler — see Head-of-Line Blocking.
  • Callback error swallowing: a rejected promise with no handler terminates the task silently, and the awaiting caller waits forever — Orphaned Tasks.
When it helps
  • Very high connection counts with low per-connection work: chat gateways, push, streaming, proxies, API gateways — see WebSockets and Polling vs Long Polling vs SSE vs WebSockets.
  • Proxying and fan-out, where the server is mostly moving bytes and almost never computing.
  • Workloads where the per-connection memory of a thread-based model is the binding constraint.
  • Codebases that benefit from the absence of data races between handlers, which is a real correctness advantage.
When it hurts
  • Any CPU-bound work in the request path, where the model converts a throughput problem into an availability problem.
  • Ecosystems where key libraries block, since a single blocking driver defeats the model entirely.
  • Deep debugging, where a suspended task frequently has no meaningful stack and the spawn site is all you get — Task Dumps: When the Threads Look Idle and Nothing Is Moving.
  • Small-scale services with a few hundred connections, where the discipline costs more than the memory it saves.
How you would know
  • Event-loop lag p99 — the delay between when a zero-delay callback should have run and when it did. This is the model's primary health signal.
  • Handler duration distribution, with anything above a few milliseconds treated as a defect rather than as slowness.
  • Pending task count and its growth rate, which is the unbounded-concurrency signal.
  • Ready-descriptor count per iteration against total registered, which shows whether the model is earning its keep.
  • Whether health-check latency tracks loop lag — if it does, the health check is measuring the loop, not the service.
Complexity it introduces
  • A discipline that must hold across every dependency forever: nothing may block, and there is no mechanism enforcing it.
  • Await points are scheduling points, which means invariants must be checked against suspension boundaries rather than against lock scopes.
  • Debugging is harder: no stack for suspended tasks, and causality has to be reconstructed from spawn sites and traces.
  • Explicit concurrency bounds are mandatory rather than optional, adding semaphores and queue limits that a thread-based server got for free from thread cost.
Simpler alternatives

What people believe, and what is true

Claim

Event-driven servers have no concurrency bugs.

Reality

They have no data races between handlers. They have plenty of race conditions, all of them across await points, and no detector will report them.

Claim

One loop can saturate a machine.

Reality

One loop uses one core. Saturating a multi-core machine needs one loop per core, at which point shared state is concurrent again.

Claim

Async means requests run in parallel.

Reality

They overlap. On one loop exactly one handler executes at a time; overlapping progress and simultaneous execution are different things.

Apply it