A server handling ten thousand simultaneous connections spends nearly all its time waiting — for network, for the database, for disk. The question is how to wait for ten thousand things at once without ten thousand of something expensive.
Two answers. Threads let the operating system handle it: each connection gets a thread, blocking calls block that thread, and the scheduler switches between them. Async uses one thread and an event loop: an operation that would block instead registers interest and yields, and the loop runs something else.
What changed in 2026
- Lightweight runtime threads spread. Several languages gained threads managed by the runtime rather than the OS, which narrowed the gap considerably.
- Async ecosystems matured. Blocking libraries in async ecosystems became rarer as ecosystems completed.
- The blocking-call problem stayed the dominant bug. Despite better tooling, one synchronous call in an async path remained the most common failure.
- Structured concurrency gained ground. Scoping concurrent tasks to a lifetime, with cancellation propagation, improved reliability in both models.
The comparison
|
Threads |
Async |
| Scheduling |
Operating system, preemptive |
Event loop, cooperative |
| Cost per concurrent task |
Memory for a stack |
Small object |
| Practical concurrency limit |
Thousands |
Hundreds of thousands |
| Blocking calls |
Block one thread |
Block everything |
| CPU-bound work |
Uses multiple cores |
Blocks the loop |
| Debugging |
Familiar stack traces |
Harder to follow |
| Data races |
Possible |
Fewer, within one loop |
Threads cost memory per thread, largely for the stack. That caps how many you can run, and OS context switching adds overhead as the count grows.
Async tasks are small objects, so hundreds of thousands are feasible. The price is that scheduling is cooperative — the loop can only switch when a task yields.
The blocking call problem
This is the one that matters, and it is the most common async bug in production.
In an async system, a task that performs a synchronous blocking operation — a filesystem read, a synchronous HTTP client, a CPU-heavy computation, a database driver that is not async-aware — does not yield. The event loop cannot switch. Every other task on that loop stops, including ones with nothing to do with the blocking work.
One synchronous call in one handler makes the whole server unresponsive for its duration. With threads, that call blocks one thread and everything else continues.
This is why async ecosystems need async-aware libraries throughout. A single synchronous dependency deep in a call chain undermines the model, and it is genuinely hard to spot — the code looks fine, and the symptom is intermittent latency across unrelated endpoints.
Runtimes provide escape hatches: an explicit way to run blocking work on a separate thread pool. Using them requires knowing the call blocks, which is the difficult part.
Choosing
I/O-bound work — waiting on network, database, or disk — suits async. Concurrency is high, per-task cost matters, and the work is waiting rather than computing.
CPU-bound work — computation, encoding, image processing — suits threads. Async provides no benefit, because there is nothing to yield during; the work must actually run, and running it on the event loop blocks everything.
Mixed workloads need both: async for the I/O, offloaded to a thread pool for the computation.
The lightweight-thread model that several runtimes now provide is worth noting because it largely dissolves the dilemma: you write straightforward blocking code, and the runtime yields the underlying OS thread when you block. That gets async-like scalability with threaded-style code, and where it is available it is frequently the right default.
Common mistakes
- A blocking call in an async handler. Stalls everything.
- Async for CPU-bound work. Complexity, no benefit.
- Unbounded task spawning. Cheap tasks are not free; thousands still consume memory and scheduler time.
- Thread pools sized to concurrency rather than cores. For CPU-bound work, more threads than cores adds switching overhead — see thread pools.
- Assuming a library is async because the ecosystem is. Check.
- No timeouts on async operations. A hung operation holds its slot indefinitely.
- Mixing models carelessly. Bridging between them has real pitfalls.
FAQ
Which is faster?
Neither, in general. Async handles more concurrent I/O-bound connections on the same hardware. Threads use multiple cores for computation. Speed depends entirely on the workload.
Do lightweight threads make async obsolete?
They remove much of the motivation in runtimes that have them, and async remains dominant in ecosystems built around it. Where both are available, lightweight threads are usually simpler.
How do I find a blocking call?
Profiling that shows event loop stalls, or runtime instrumentation warning when a task holds the loop too long. Some runtimes detect this automatically, which is worth enabling.
What about data races?
Threads share memory and need synchronisation. Tasks on a single event loop do not run simultaneously, which removes many races — though state shared across loops or with thread pools still needs care.
Where to go next
For sizing thread pools, read thread pools. For seeing where time goes in either model, flame graphs, and for the connection-level constraint, connection limits.