Appearance
9.5.6 — Concurrency in JavaScript and Node
JavaScript hands you one guarantee for free, and it is a big one: your code runs to completion. Once a function starts, no other JavaScript runs until it returns. There is one call stack and one thread executing your code, so two functions cannot be halfway through at the same moment.
That single property deletes an entire category of bug. counter += 1 cannot lose an update. An object cannot be observed half-updated. You will never write a mutex to protect a plain JavaScript object, and you have probably never wondered why.
It also means the whole of 9.5.2 looked, on first reading, like it was about somebody else's language. This page is about which parts of it come back, and when.
1. The unit of atomicity is not the function
The guarantee is precise, and the precision is where people get caught. A synchronous stretch of code is atomic. A function containing await is not.
Every await is a return. Your function hands control back to the event loop, other work runs to completion, and only later does your function resume. So the atomic unit is the stretch between awaits, not the function as a whole.
typescript
async function useCoupon(code: string): Promise<void> {
const coupon = await db.getCoupon(code); // (1) ← other requests run here
if (coupon.usesLeft <= 0) throw new Expired(); // (2)
await db.setUses(code, coupon.usesLeft - 1); // (3) ← and here
}(1) and (3) are gaps. (2) is a decision made using data read before a gap and acted on after one. That is check-then-act (9.5.1) with no threads anywhere, and it produces the same lost update.
The rule to carry: any invariant you check before an await must be re-established after it, or enforced somewhere your process does not control. In practice that means the conditional write — UPDATE coupons SET uses_left = uses_left - 1 WHERE code = ? AND uses_left > 0 — which is correct at any number of instances and needs no coordination at all.
2. When a lock in Node is worth having
Given that the real fix is usually at the database, when does an in-process lock earn its place?
When the state genuinely lives in your process. An in-memory cache, a rate-limit counter for one instance, a connection you are lazily opening. There is no database to push the guarantee into.
When the resource is external and cannot express the guarantee. A legacy API with no conditional update, a file you are rewriting, a piece of hardware.
When you want ordering or load-shaping rather than correctness. Serialising all writes for one hot key inside each instance converts a burst of conflicting database writes into an orderly stream. Correctness still comes from the conditional write; the lock is there to reduce contention and retries.
The tool is the per-key Mutex from 9.5.2, with one addition that matters in a long-running process:
typescript
class KeyedMutex {
#locks = new Map<string, { mutex: Mutex; users: number }>(); // (1)
async withKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
let entry = this.#locks.get(key);
if (!entry) { entry = { mutex: new Mutex(), users: 0 }; this.#locks.set(key, entry); }
entry.users++; // (2)
try {
return await entry.mutex.withLock(fn);
} finally {
if (--entry.users === 0) this.#locks.delete(key); // (3) no leak
}
}
}(1) One lock per key, so unrelated keys never wait for each other. (2) A reference count of everyone currently using or waiting on this lock. (3) The last one out removes the entry. Without this, the map grows by one small object for every key the process ever sees, which in a long-lived service with user-scoped keys is a slow memory leak that takes weeks to become visible. The fixed-size striped alternative from 9.5.2 avoids the problem differently, by never growing at all.
3. The failure mode that is unique to this model
Because there is one thread, any synchronous work you do blocks everything. Not just the current request — every request, every timer, every health check, and the garbage collector's opportunity to run.
typescript
app.post("/report", (req, res) => {
const csv = rows.map(formatRow).join("\n"); // 400 ms of pure CPU for a large report
res.send(csv); // during which the server answered nobody
});Four hundred milliseconds of CPU means every other request waits four hundred milliseconds. At a hundred requests per second, four hundred milliseconds of blocking means forty requests queue up behind it, each of which now sees added latency. This is why a Node service can have low CPU utilisation and terrible tail latency at the same time.
The metric that detects it is event loop lag: schedule a timer for 100 ms and measure how late it actually fires. If it fires at 340 ms, something held the thread for 240 ms.
typescript
let lag = 0;
setInterval(() => {
const expected = Date.now() + 100;
setTimeout(() => { lag = Math.max(0, Date.now() - expected); }, 100); // (1)
}, 1000);(1) The difference between when the timer should have fired and when it did is time the thread was busy elsewhere. Export this as a metric on every Node service you run. A healthy service sits near zero; anything consistently above about 50 ms means user-visible latency that no per-request timing will explain, because the delay happens before your handler starts.
The cures, in order: do less work; move the work to a worker_thread; move it out of the request path entirely onto a queue; or break it into chunks that yield to the loop between them.
4. Real threads: worker_threads
When the work is genuinely CPU-bound, no amount of async restructuring helps. Async is for waiting, and CPU work is not waiting. You need another thread.
typescript
// main.ts
import { Worker } from "node:worker_threads";
function hashInWorker(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const worker = new Worker("./hash-worker.js", { workerData: password }); // (1)
worker.once("message", resolve); // (2)
worker.once("error", reject);
});
}
// hash-worker.ts
import { parentPort, workerData } from "node:worker_threads";
parentPort!.postMessage(expensiveHash(workerData)); // (3)(1) A worker is a whole new JavaScript environment: its own V8 isolate, its own heap, its own event loop. (2) Communication is by message only. (3) The worker computes and posts back.
Three properties that shape every design using them.
Nothing is shared. The worker cannot see your variables, your module state, or your database connection. This is the actor model enforced by the runtime (9.5.4), and it is why worker_threads cannot produce a data race on ordinary objects.
Messages are copied, and the copy is not free. postMessage uses structured cloning, which walks the whole object graph. Sending a 50 MB array costs real time and doubles the memory while both copies exist. For a small result this is irrelevant; for bulk data it can cost more than the work you moved off the main thread, which is how people end up making things slower by parallelising them.
Starting one is expensive. A worker costs several milliseconds and a few megabytes to create, because a fresh V8 isolate has to be built. Creating one per request is a serious anti-pattern. Keep a pool of workers created at startup and hand tasks to them — which is precisely the worker pool from 9.5.4, sized to the core count because this is CPU-bound work.
Two ways to avoid the copy when it matters. Transferables move ownership of an ArrayBuffer instead of copying it — instant, but the sender loses access, which is exactly Rust's move semantics appearing in JavaScript (3.4). And SharedArrayBuffer, which is the next section, because it brings back everything this chapter has been protecting you from.
5. SharedArrayBuffer and Atomics: the guarantees come off
A SharedArrayBuffer is memory that two threads genuinely share. Both can read it and both can write it, simultaneously, on different cores. Everything in 9.5.1 now applies to your JavaScript: torn reads, lost updates, and the visibility problem where one thread's write sits in a core's cache and another thread never sees it.
This is why Atomics exists.
typescript
const shared = new SharedArrayBuffer(8);
const counter = new Int32Array(shared); // both threads hold a view of the same memory
counter[0]++; // read-modify-write: races
Atomics.add(counter, 0, 1); // one indivisible operationThe Atomics methods are the hardware instructions from 9.5.2 exposed directly: add, sub, load, store, exchange, and compareExchange, which is compare-and-swap by another name. They guarantee both properties you need — no interleaving, and the write becomes visible to other cores.
There are also two blocking operations, and they come with a hard restriction:
Atomics.wait(view, index, expectedValue)— if the slot still holds the expected value, block this thread until somebody notifies. This is a condition variable'swait, built into the language.Atomics.notify(view, index, count)— wake up tocountwaiters.
Atomics.wait cannot be called on the main thread, and the reason is worth understanding rather than memorising: blocking the main thread would freeze the event loop, so timers stop, I/O completions stop, and the process becomes unresponsive. The runtime forbids it because there is no correct way to use it there. On a worker thread it is fine and genuinely useful — a worker can block cheaply waiting for the next task instead of polling.
When you actually need any of this: shared numeric or binary data, updated frequently, where copying it between threads would dominate the work. Image and audio buffers, a shared counter across a worker pool, a large matrix being processed in regions, a lock-free ring buffer between threads. That is a short list, and if your data is objects rather than numbers, SharedArrayBuffer cannot hold it anyway — it is raw bytes, so you would be serialising into it by hand, and at that point message passing is simpler and nearly always fast enough.
The honest recommendation: reach for messages first. Reach for transferables when the copy shows up in a profile. Reach for SharedArrayBuffer only when you have measured that transfers are the bottleneck, and accept that you have re-entered the world where every rule in 9.5.2 and 9.5.3 applies to you again.
6. Multiple processes: cluster and replicas
The other way to use more cores is more processes. cluster forks several copies of your app that share one listening port, and a container orchestrator does the same thing across machines.
Processes share nothing — no heap, no module state, no locks. Three consequences follow, and every one of them has caused a production incident somewhere.
Every in-memory thing becomes per-process. An in-memory cache with four workers is four caches with four different views of the truth. An in-memory rate limiter of 100 requests per minute becomes 400 across four workers. A Map of sessions means a user's session exists on one worker and not the others, so their next request may land on a worker that has never heard of them.
Every in-memory lock becomes local. Four processes means four independent mutexes, so four requests can be inside the "exclusive" section at once. This is the failure that arrives on the day you scale up, which is the worst possible timing. It is the reason 9.5.2 insists that a process-local lock is a load-shaping tool and the database is where correctness lives.
Every scheduled job runs N times. A setInterval that sends a daily summary, running on six replicas with four workers each, sends twenty-four summaries. This is not a race; it is arithmetic, and it needs a single-owner mechanism — a leader election, a unique constraint on a job run row, or a scheduler that exists once (9.9.7).
The design rule that keeps this simple: keep application processes stateless, and put shared state in something built to be shared. Then the number of processes becomes a scaling dial rather than a correctness variable.
7. The pool you did not know you had
Node runs some operations on a background thread pool provided by libuv, and its default size is four (3.8.2). What uses it: file system operations, crypto.pbkdf2 and friends, zlib compression, and — the one that surprises everybody — dns.lookup.
The surprise matters. Every outgoing HTTP request to a hostname does a DNS lookup, and dns.lookup is a blocking call that occupies one of those four slots. Four slow lookups and your file reads stop. The symptom is bizarre: disk operations become slow because the network is slow, and nothing in your code connects the two.
It is a pool, so all of 9.5.4's pool rules apply, with one extra difficulty: you did not create it, so you cannot see its queue depth. Practical responses are to raise UV_THREADPOOL_SIZE when you know you do heavy file or crypto work, to prefer dns.resolve over dns.lookup where you can since it uses the network rather than the pool, and to remember it exists when a latency problem makes no sense.
8. Choosing the right mechanism
| Your work is | Use | Because |
|---|---|---|
| Waiting on I/O | Plain async/await | One thread handles thousands of waits |
| Waiting, but too much at once | Worker pool (9.5.4) | Bounds concurrency on a scarce resource |
| CPU, tens of milliseconds | worker_threads pool | Keeps the event loop free |
| CPU, seconds or minutes | A queue and a separate service | Requests should not wait for it |
| CPU on large binary data | Workers + SharedArrayBuffer | Avoids the copy cost |
| More traffic than one core handles | More processes | Uses all cores, stays stateless |
The decision that comes first is the one from 9.5.1: is this work waiting or computing? Async solves waiting and does nothing whatsoever for computing. Threads and processes solve computing and add cost to waiting. Almost every bad Node performance decision is that question answered wrongly, or never asked.
9. The practical checklist
Export event loop lag. It is the one metric that catches the failure mode unique to this runtime, and no per-request timing will reveal it.
Never do heavy synchronous work in a handler. JSON.parse on a large body, a synchronous crypto call, a big sort, readFileSync. Each one stops the whole service.
Put correctness in the database, not in a lock. Conditional writes and unique constraints survive scale-out; mutexes do not.
Bound everything. Every queue, every pool, every batch of promises. Promise.all over an unbounded list is an unbounded resource request.
Assume N processes from day one. Ask of every piece of in-memory state: what happens when there are six of these? If the answer is "the feature breaks", it belongs somewhere shared.
Prefer messages to shared memory. Copy costs are usually smaller than the cost of getting shared memory right, and you only find out you were wrong from a profile.
Next: 9.6 moves from the machinery inside one process to the contract it exposes: API design, from resource modelling to the checklist for an API people enjoy using.
Recall
- JavaScript runs to completion, so synchronous code is atomic. A function containing
awaitis not — the unit of atomicity is the stretch between awaits, and every database call is a gap. - Any invariant checked before an
awaitmust be re-established after it, or enforced where your process does not control it: a conditional write is the fix that survives scale-out. - In-process locks earn their place for genuinely in-memory state, for external systems with no atomic operation, and for load shaping — never as the correctness story once there are two instances.
- A keyed lock map leaks unless entries are reference-counted and removed, or unless you stripe onto a fixed number of locks.
- Synchronous CPU work blocks everything. Detect it with event loop lag, not with per-request timing.
worker_threadsare real threads with isolated heaps: nothing shared, messages copied by structured clone, and expensive to create — so pool them, sized to core count.SharedArrayBufferbrings back genuine data races;Atomicsis compare-and-swap and friends exposed directly.Atomics.waitis forbidden on the main thread because it would freeze the event loop.- Processes share nothing: caches, locks, limiters and schedulers all multiply by the process count. Keep app processes stateless.
- libuv's background pool defaults to four threads and is used by file I/O, crypto, zlib and
dns.lookup— which is why slow DNS can make disk reads slow.
Self-test: Why is single-threaded not the same as atomic? What does event loop lag detect that request timing cannot? Name the three costs of worker_threads. Why can Atomics.wait not run on the main thread? What breaks first when you go from one process to six?
Quiz Bank
FoundationalNode is single-threaded. Explain precisely what that guarantees and what it does not.
What it guarantees. There is one call stack running your JavaScript, and a function runs from its first line to its last without any other JavaScript interleaving. So a synchronous stretch of code is atomic. counter += 1 cannot lose an update, an object cannot be seen half-updated, and you never need a mutex around a plain JavaScript object. That is a real and substantial guarantee, and it is why the JavaScript standard library has no locking primitives at all.
What it does not guarantee. await is a return. Your function gives control back to the event loop, entirely different work runs to completion, and only later does your function resume. The atomic unit is therefore the stretch between two awaits, not the function.
Since every database query, every HTTP call and every file read is an await, any realistic handler is full of gaps, and any state you read before a gap may have changed by the time you act on it after one.
typescript
const coupon = await db.getCoupon(code); // gap
if (coupon.usesLeft <= 0) throw new Expired(); // decision from pre-gap data
await db.setUses(code, coupon.usesLeft - 1); // gapTwo requests both read usesLeft: 1, both pass the check, both write 0. The coupon is used twice. That is a lost update (9.5.1) with no threads involved.
The precise sentence: single-threaded means no two lines of your code run simultaneously. It says nothing about the world holding still across an await.
And the consequence for design: the fix is not a lock, it is to make the check and the act one operation where the state lives — UPDATE coupons SET uses_left = uses_left - 1 WHERE code = ? AND uses_left > 0. That is correct at one instance and at fifty, whereas an in-process lock stops being correct the moment you run a second copy.
FoundationalWhat is event loop lag, why does it matter more than request latency, and how do you fix a service that has it?
What it is. Schedule a timer for 100 ms and measure when it actually fires. The difference is time the single thread spent doing something else. If it fires at 340 ms, something held the thread for 240 ms and nothing else could run during that window.
Why it matters more than request latency. Per-request timing measures your handler from when it started. Lag measures the delay before it started. When one request spends 400 ms formatting a large report, every other request in that window waits, and their handlers each report a perfectly normal duration. Your latency dashboard looks fine and your users do not agree, because the queueing happened somewhere your instrumentation was not looking.
It is also the metric that explains a service with low CPU utilisation and terrible tail latency, which otherwise looks like a contradiction. Averaged over a second, the CPU was mostly idle. Concentrated into one 400 ms block, it stopped the world.
How to fix it, in order.
Find what is blocking. Common causes: JSON.parse or JSON.stringify on a large payload, synchronous crypto such as bcrypt without its async form, a big sort or map over tens of thousands of items, regular expressions with catastrophic backtracking (3.6.10), and any readFileSync that slipped into a request path.
Do less work. Often the report can be paginated, the parse avoided by streaming, or the sort done by the database, which is better at it anyway.
Move it off the thread. CPU work in the tens of milliseconds goes to a pooled worker_thread. The pool is created at startup because creating a worker per request costs several milliseconds and a few megabytes on its own.
Move it out of the request. Work taking seconds does not belong in a request at all. Accept the request, return 202, do the work on a queue, and let the client poll or receive a webhook.
Chunk and yield. If the work must stay inline, process it in batches with a yield to the loop between them, so other requests get served in between. This makes the job slower and the service responsive, which is usually the right trade.
And export the metric permanently. A single number that goes above 50 ms tells you more about a Node service's health than almost anything else you can measure.
AppliedAn image upload endpoint resizes to three sizes inline. Under load the whole API becomes unresponsive even though CPU sits at 60 percent. Diagnose it and design the fix.
The diagnosis. Image resizing is CPU work, and CPU work on the main thread blocks every other request. Each resize might take 200 ms, so three sizes is 600 ms during which the process serves nobody at all — not other uploads, not cheap GETs, not the health check.
The 60 percent CPU figure is the clue that makes this a good question. It looks like spare capacity and it is not, because the capacity is on other cores that Node is not using. One thread can saturate one core, and on a four-core machine that reads as 25 percent, so 60 percent means the machine has room while the process has none. Utilisation across cores is the wrong metric for a single-threaded runtime; event loop lag is the right one, and it would be showing hundreds of milliseconds here.
The unresponsive health check deserves separate attention, because it turns a slowdown into an outage. The orchestrator gets no response, marks the instance unhealthy, and restarts it — dropping every in-flight upload and sending that traffic to the remaining instances, which are already struggling. That is how a latency problem becomes a cascading failure.
The fix, in layers.
Get the CPU work off the main thread. A pool of worker_threads sized to the core count, created at startup. The handler sends the image to a worker and awaits the result, so the main thread is free to serve other requests while the resize happens on another core.
Do not copy the image if you can avoid it. postMessage structured-clones by default, so a 5 MB image is copied to the worker and the result copied back. Transfer the underlying ArrayBuffer instead, which moves ownership rather than duplicating the bytes. If the profile shows transfers still dominating, a SharedArrayBuffer lets the worker operate on the same memory — with the warning that you have re-entered genuine shared-memory territory.
Ask whether it should be in the request at all. This is the better answer for most products. Accept the upload, store the original, return 202 with the resource id, and queue the resize. The user gets an immediate response, the resizing capacity becomes independently scalable, a failed resize can retry without the user re-uploading, and adding a fourth size later becomes a background job rather than a latency regression. The cost is that the client has to handle "not ready yet", which is a small amount of product work in exchange for a large amount of resilience.
Bound it either way. Whichever route you take, cap how many resizes run at once. Unbounded parallel image processing exhausts memory faster than it exhausts CPU, because each in-flight image holds its decoded bitmap.
Separate the health check. It must not queue behind application work, or every incident gets amplified by the orchestrator.
The ordering to state clearly: move it off the request path if the product allows, and to a worker pool if it does not. Both are correct; only one of them also makes the resizing independently scalable.
InterviewCompare worker_threads, cluster, and separate services. When would you choose each, and what does each cost?
All three exist to use more than one core. They differ in what is shared and what is deployed.
worker_threads — threads inside one process. Each has its own V8 isolate, heap and event loop, so nothing is shared unless you deliberately use a SharedArrayBuffer. Communication is by message, copied via structured clone.
Choose it for CPU-bound work inside a request's lifetime: hashing, image processing, parsing, compression. It is the only one of the three that keeps the work inside the same request and the same deployment.
It costs several milliseconds and a few megabytes to start each worker, which forces you to pool them rather than create per request. It costs the copy on every message, which can dominate for large payloads. And the worker cannot share your database pool or your module state, so anything it needs must be passed or re-established.
cluster — several copies of the whole process on one machine, sharing a port. The operating system distributes connections across them.
Choose it for using all cores of one machine for ordinary I/O-bound request handling, with no code changes to the handlers themselves.
It costs the loss of every piece of in-process state. Your in-memory cache becomes N caches with N views. Your rate limiter of 100 per minute becomes 100×N. Your in-memory lock becomes N independent locks, so the exclusivity you thought you had is gone. Your setInterval job runs N times. Each worker also carries a full copy of your application's memory footprint. In modern deployments this is often skipped entirely in favour of running one process per container and letting the orchestrator provide the replicas, which gives the same parallelism plus independent restarts and rolling deploys.
Separate services — different deployables, communicating over a network or a queue.
Choose it for work with a genuinely different scaling profile or a different failure tolerance: a video encoder that needs enormous machines while the API needs many small ones, or a report generator that may take four minutes and must not be tied to a request.
It costs the most: a network hop and its latency, a queue or an API contract to maintain, separate deployment and monitoring, and partial failure as a permanent design concern — the whole of Part 10 exists because of that last one.
The decision rule. If the work is waiting, none of these is the answer; plain async already handles thousands of concurrent waits, and adding threads makes it worse. If the work is computing and finishes inside a request, use worker threads. If you simply need more request capacity, use more processes and keep them stateless. If the work has a different lifetime or a different scaling shape from your requests, make it a separate service.
The trap to name unprompted: reaching for cluster or workers to fix what is actually an I/O bottleneck. Four processes waiting on the same database is four times the connections and exactly the same throughput, plus four caches that now disagree with each other.
StaffA team migrates a single-instance Node service to six replicas. List everything that breaks, in the order they will discover it, and give the fix for each.
The pattern behind every item is the same: anything that lived in one process's memory was silently acting as a global guarantee, and there are now six of them.
Discovered within minutes: sessions and anything user-scoped in memory. A session stored in a Map exists on the replica that created it. The user's next request lands elsewhere and they are logged out, apparently at random. Fix: move sessions to Redis or to signed cookies. Sticky sessions at the load balancer appear to fix this and should be avoided — they make deploys drop sessions, they make traffic uneven, and they re-break the moment an instance restarts.
Discovered within hours: rate limits and quotas. A limiter allowing 100 requests per minute now allows 600 in aggregate. Fix: a shared counter in Redis with an atomic check-and-spend, ideally as one script so the read and the write cannot interleave (11.3).
Discovered within a day: scheduled jobs firing six times. Every setInterval and every cron inside the app runs on every replica. If one of them emails customers, this is a visible, embarrassing incident. Fix: a single owner — a leader election, a unique constraint on a job-run row keyed by job name and window, or an external scheduler that invokes one endpoint. The unique-constraint approach is usually the cheapest and needs no coordination service (9.9.7).
Discovered within a week: caches that disagree. Six in-memory caches with six independent expiry timers means a user refreshing a page sees the value flip between old and new depending on which replica answers. Fix: either accept it and make the staleness window short and documented, or move to a shared cache. Deciding consciously is what matters; the bug reports are unfalsifiable until somebody does.
Discovered under load, and the most dangerous: in-process locks that no longer exclude anything. Six replicas means six mutexes, so six requests can be inside the "exclusive" section simultaneously. Whatever the lock protected — an oversell, a double charge, a duplicate record — now happens at exactly the traffic level where it costs most. Fix: move the invariant to the state, as conditional writes and unique constraints. Keep the in-process lock if it is reducing contention, but stop calling it the guarantee.
Discovered during the first incident: connection pool arithmetic. Each replica has its own database pool. Six replicas with a pool of twenty is a hundred and twenty connections, which may be more than the database accepts, and the failure appears as connection errors under load rather than as anything pointing at the migration. Fix: divide the per-instance pool size by the replica count, and size the total against the database's actual limit with headroom for migrations and admin tools.
Discovered eventually: anything using in-memory deduplication or idempotency. A Set of processed message ids only deduplicates within one replica. Fix: a unique constraint in the database, which is where idempotency belongs anyway (9.6.3).
The reviewable rule to leave behind, and the thing that prevents all of this next time: for every piece of state in the process, ask what happens when there are six of these? If the answer is anything other than "nothing", the state belongs somewhere shared or the guarantee belongs somewhere durable. Applying that question once at design time is far cheaper than discovering the list above one incident at a time.
Flashcards
FlashAtomicity in Node
Synchronous code is atomic; a function with await is not. The unit is the stretch between awaits, and every I/O call is a gap. Re-check or push the guarantee to the database.
FlashEvent loop lag
Timer scheduled for 100 ms fires at 340 ms: something held the thread for 240 ms. Detects blocking that per-request timing cannot see. Alert above ~50 ms.
Flashworker_threads costs
Isolated heap, nothing shared. Messages structured-cloned, so large payloads are expensive. Milliseconds and megabytes to start, so pool them at core count. Transfer buffers to skip the copy.
FlashAtomics.wait
Blocks until notified, on a SharedArrayBuffer slot. Forbidden on the main thread because it would freeze the event loop. Fine on workers.
FlashWhat multiplies by N processes
Caches, rate limiters, locks, scheduled jobs, deduplication sets, connection pools. Ask of every piece of in-memory state: what happens when there are six?
Flashlibuv thread pool
Four threads by default. Used by file I/O, crypto, zlib and dns.lookup. Slow DNS can therefore make disk reads slow, and you cannot see its queue depth.