The question this answers
Why did wrapping this CPU-heavy function in async and awaiting them all together change nothing?
Ten images to resize, roughly 300 ms of pure CPU each, in a Node handler that already had async on every function in the call chain.
Nothing between the resize tasks — each owns its own pixel buffer. Which is exactly the point: this workload has no synchronization problem at all, only an execution problem, and async solves the wrong one.
Total wall-clock for the batch is at least the total CPU time divided by the number of execution contexts actually available. On one loop that number is one, and no amount of async changes it.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Suspension is the mechanism, and CPU work never suspends
The reason async helps I/O is precise: an awaited I/O operation *gives back the executor* while it waits. Ten concurrent HTTP calls overlap because during the 200 ms each spends on the network, the executor is free to start the next one. Ten concurrent resizes do not overlap, because during the 300 ms each spends in a pixel loop the executor is not free — it is running that loop.
Promise.all([r(a), r(b), r(c)]) on CPU-bound functions does not run them together. It starts the first one, which runs to completion because it never yields, then the second, then the third. The total is the sum, plus the cost of the promises. The word "concurrent" is doing no work here at all — see Which One Does This Workload Need? for the distinction being applied.
The honest framing: async is a scheduling tool for tasks that wait; parallelism is an execution tool for tasks that compute. Reaching for the first when you needed the second is one of the most common performance mistakes in JavaScript and Python codebases, and it is expensive because the code looks like it should have worked.
The code that looks parallel and is not
Three versions below. The first is what people write; the second is the yielding version that fixes *responsiveness* and not *duration*; the third is the one that actually uses more cores. The middle one is worth dwelling on, because it is the fix people reach for and it does something different from what they expect.
Yielding to the loop between chunks lets other pending callbacks run, so the health check answers and frames get painted. It does not make the batch finish sooner — in fact it makes it slightly slower, because you have added scheduling to the same total CPU. If your problem is "the server stopped responding", yielding is the fix (Blocking the Event Loop). If your problem is "the batch takes three seconds", only more execution contexts will help (Worker Threads).
Note the third version does not claim a speedup number. Actual scaling depends on cores available to the process, whether the resize library releases anything while it works, memory bandwidth for the pixel data, and the transfer cost of the buffers. Measure it; do not assume it.
1// 1. Looks parallel. Is strictly sequential, plus promise overhead.2async function resizeAll(images: ArrayBuffer[]) {3 return Promise.all(images.map((img) => resizeCpuBound(img)))4 // resizeCpuBound never awaits anything, so it runs to completion5 // the moment it is called. Ten of them = ten in a row.6 // Total: ~3000 ms. Loop lag during it: ~3000 ms.7}8 9// 2. Yields between chunks. Fixes responsiveness, NOT duration.10async function resizeAllYielding(images: ArrayBuffer[]) {11 const out: ArrayBuffer[] = []12 for (const img of images) {13 out.push(resizeCpuBound(img))14 await new Promise((r) => setImmediate(r)) // let the loop breathe15 }16 return out17 // Total: ~3000 ms or slightly more. Loop lag during it: ~300 ms per chunk.18 // Other requests now get served. The batch is not one millisecond faster.19}20 21// 3. Adds execution contexts. This is the one that reduces wall-clock.22async function resizeAllParallel(images: ArrayBuffer[], pool: RenderPool) {23 return Promise.all(images.map((img) => pool.run(img)))24 // Now Promise.all is doing what it looked like it was doing all along:25 // the tasks suspend (waiting on a worker) so the executor IS free.26 // Wall-clock depends on cores available, transfer cost and memory27 // bandwidth. Measure it. Do not assume images.length / workers.28}What the speedup curve actually looks like
Adding "async" to CPU-bound work produces a speedup of exactly 1.0 no matter how much you add — the curve is a flat line, and it is flat for a structural reason rather than an overhead reason. Adding real execution contexts produces a curve that rises and then bends, and the bend is the interesting part: transfer costs, memory bandwidth for large pixel buffers, and the serial fraction of the handler all pull it away from linear (Amdahl's Law, Why Eight Cores Give You Four and a Half).
The curve below is modelled, not measured, and that matters: the position of the bend is a property of your data size, your library and your machine. What is *not* machine-dependent is the flat line. That one is arithmetic.
async functions, a hundred, or a thousand all give a speedup of 1.0 on one loop, because none of them suspends. The plotted curve is the worker-pool variant, and it bends for the ordinary reasons — serial fraction, transfer cost and memory bandwidth — before turning down at oversubscription.Key points
- Async exploits *suspension*. Work that never suspends gets no benefit, and
Promise.allover CPU-bound functions runs them one after another. - The word "concurrent" describes overlapping progress, not simultaneous execution — see Which One Does This Workload Need?.
- Yielding to the loop between chunks fixes responsiveness for other tasks and does not shorten the batch by a single millisecond.
- Only additional execution contexts — worker threads, processes, native threads — reduce wall-clock for CPU-bound work.
- The speedup from adding
asyncto CPU work is 1.0 regardless of how much you add. That is arithmetic, not a benchmark result. - Real parallel speedup bends away from linear because of serial fractions, transfer cost and memory bandwidth, and turns down at oversubscription.
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.
- • An
asyncfunction returns a promise and installs a state machine; that is the entire runtime effect of the keyword. - • The state machine only splits execution at
awaitpoints, and only if the awaited thing is not already settled. - • A function body with no
awaitinside a hot loop has no split points, so it runs to completion the first time it is called. - •
Promise.allstarts each function in order; a function that never yields finishes before the next one starts. - • The executor — one event loop — can only run one of those bodies at a time, so total wall-clock is the sum of the bodies.
- • Handing the body to a worker changes this because the calling task now *does* suspend: it waits on a message, freeing the executor, while an OS thread runs the work on another core.
- • Promise.all over ten CPU tasks: task 1 runs 0–300 ms, task 2 runs 300–600 ms, and so on. There is no interleaving at all — that is the finding.
- • Promise.all over ten I/O tasks: all ten start within a millisecond, all ten suspend, the executor idles, and all ten resume as responses arrive. Wall-clock is roughly the slowest one.
- • Mixed: one CPU task and nine I/O tasks; the nine I/O responses arrive during the CPU task's 300 ms and every one of their continuations queues behind it, so all nine appear to take 300 ms longer than they did.
- • Chunked with a yield: task 1 runs 0–300 ms, the loop serves a health check at 300 ms, task 2 runs 301–601 ms. Other work is served; the batch still ends at ~3 s.
- • Worker pool of four: four tasks run simultaneously on four cores, the loop is free the whole time, and the batch ends in roughly a quarter of the time minus transfer overhead.
- • Guaranteed:
asyncgives you a promise and the ability to suspend atawait. Nothing else. - • Guaranteed: on one loop, exactly one task body executes at a time, so CPU time is strictly additive across tasks.
- • Guaranteed: yielding between chunks lets other pending callbacks run.
- • NOT guaranteed: any overlap. Overlap requires suspension, and suspension requires something to wait for.
- • NOT guaranteed: that
Promise.alldoes anything concurrently. It waits on a collection; whether the members overlap is a property of the members. - • NOT guaranteed: that adding workers gives linear speedup — that depends on cores, transfer cost, memory bandwidth and the serial fraction.
- • NOT guaranteed (CPython): that threads give you CPU parallelism for pure-Python bytecode; that is what multiprocessing and native extensions are for. See Python: Threads, Processes and the GIL.
- • The executor is the contended resource, and a CPU-bound task holds it for its entire duration with no preemption.
- • Every other pending task on the loop is queued behind it, so the contention shows up as latency on endpoints that share nothing with the slow one.
- • With a worker pool the contention moves to the pool queue and, past the core count, to the CPU scheduler itself.
- • Large pixel buffers add memory-bandwidth contention between cores, which is why eight workers rarely give eight times the throughput on memory-heavy work (Memory Bandwidth: More Cores, Same Bus).
- • False parallelism: a batch that was "made concurrent" and takes exactly as long as before, with nobody able to explain why.
- • Event-loop starvation: the CPU batch blocks health checks and unrelated endpoints, and the instance is pulled from rotation.
- • The wrong fix applied: chunked yielding shipped to solve a duration problem, restoring responsiveness while the batch stays slow and everyone believes it is fixed.
- • Oversubscription after over-correcting: a worker per image, more runnable threads than cores, and per-job latency doubles while throughput stays flat.
- • Benchmark illusion: measuring the batch on an idle laptop with 10 cores and shipping it to a container limited to 0.5 CPU.
- • Async helps when tasks genuinely wait: network calls, disk, database queries, sleeping, waiting on a worker or another process.
- • Async helps when per-task memory matters and the tasks are numerous and mostly idle.
- • Parallelism helps when the work is CPU-bound, divisible, and large enough that the handoff cost is small relative to the compute.
- • Both help together in the hybrid shape: async at the edge, real threads or processes for the compute (Hybrid Runtimes: It Was Never Threads Versus Async).
- • Async on CPU-bound work adds a state machine, promise allocations and microtask scheduling for zero overlap — a small, measurable regression.
- • Parallelising work smaller than the handoff cost is slower than doing it inline (Parallel Overhead).
- • Parallelising memory-bound work scales far worse than core count suggests, because the bottleneck is bandwidth, not compute.
- • Adding workers past the cores the process can actually use degrades latency for everyone.
- • Compare wall-clock against total CPU time for the batch. If wall-clock ≈ sum of the parts, nothing overlapped, whatever the code says.
- • Event-loop lag during the batch: high lag means the work is on the loop; near-zero lag with a long batch means it is genuinely elsewhere.
- • Per-task start and end timestamps. Printing them is the fastest way to prove that "concurrent" tasks ran back-to-back.
- • CPU utilisation across cores: one core at 100% and seven idle is the signature of async applied to a parallelism problem.
- • For the worker version, measure speedup at 1, 2, 4 and 8 workers on the target hardware — including inside the container CPU limit, which is where the surprise is.
- • Marking things
asyncpropagates through the call graph and is hard to undo, so a change that bought nothing still costs you readability forever. - • Chunked yielding introduces a chunk size that must be tuned, and interleaving points where state can change — reintroducing the await-point reasoning of Await Is a Yield Point for no throughput gain.
- • Real parallelism adds a pool, a serialisation boundary, a queue bound and a failure path for dead workers.
- • The diagnosis itself is the hidden cost: teams often spend weeks on the wrong axis because the code reads as though it should be parallel.
- • Make the work smaller: resize on upload rather than on request, cache the result, or use a smaller source image. The fastest parallel batch is the one you do not run.
- • Move it off the request path entirely: enqueue and return
202, then process on a dedicated worker fleet. Seeasync-job-pattern. - • Use a native library that releases the interpreter lock or runs its own threads, so one call uses several cores with no pool of your own.
- • Scale out processes instead of threads — one process per core — when shared memory is unnecessary and per-process overhead is acceptable.
- • CPython specifically:
multiprocessingor a native extension for CPU work; threads there overlap I/O well and do not run pure-Python bytecode in parallel (Python: Threads, Processes and the GIL).
CPU parallelism simulator
Amdahl’s term, a synchronisation term, an oversubscription term and a bandwidth ceiling, each one a knob you can switch off. Real curves have more causes than four and are rarely this smooth. There is no ideal core count to read off this chart.
Why is 8 cores only 4.5×?
| workers | ideal | Amdahl only | realistic | limited by |
|---|---|---|---|---|
| 1 | 1.0× | 1.00× | 1.00× | none |
| 2 | 2.0× | 1.90× | 1.85× | serial |
| 4 | 4.0× | 3.48× | 3.19× | serial |
| 6 | 6.0× | 4.80× | 4.17× | serial |
| 8 | 8.0× | 5.93× | 4.17× | bandwidth |
| 10 | 10.0× | 6.90× | 4.17× | bandwidth |
| 12 | 12.0× | 7.74× | 4.17× | bandwidth |
| 14 | 14.0× | 8.48× | 4.17× | bandwidth |
| 16 | 16.0× | 9.14× | 4.17× | bandwidth |
Amdahl's law: the serial ceiling
s = 0.10 n = 32
Amdahl S(n) = 1 / (s + (1 − s)/n) = 7.805× ← fixed problem, more machine
S(1 000 000) = 10.000× ← a million cores, and still under 10×
Gustafson S(n) = s + n(1 − s) = 28.900× ← fixed time, bigger problemWhat people believe, and what is true
I used Promise.all, so the work runs in parallel.
Promise.all waits on a collection. Whether its members overlap depends entirely on whether they suspend. Ten CPU-bound functions run strictly one after another.
Making the function async lets the runtime move it to another thread.
No runtime does that. async installs a state machine on the same execution context; moving work to another thread is an explicit act.
Chunking with setImmediate made it faster.
It made the *rest of the system* responsive. The batch takes the same time or marginally longer, because you added scheduling to the same CPU work.
Go deeper
Overview
Async overlaps waiting. If the task never waits, there is nothing to overlap and nothing changes.
Practical
Compare wall-clock with total CPU. If they match, the work is serial. Yield to fix responsiveness; add workers or processes to fix duration; do not confuse the two.
Advanced
Real speedup is bounded by the serial fraction, the handoff cost and memory bandwidth. Measure at 1, 2, 4, 8 contexts on the target hardware — including the container CPU limit, which is where the model usually breaks.
Internals
An async function compiles to a resumable state machine with suspension points only at await. With no suspension point the compiled body is an ordinary function that happens to return a promise, and the scheduler never gets a chance to run anything else.