Node.js Async Patterns
When to Use
Use this skill when the user asks about:
async/await syntax, patterns, and anti-patterns in Node.js -- including sequential vs. parallel execution, top-level await, and async IIFE patterns
- Promise composition --
Promise.all, Promise.allSettled, Promise.race, Promise.any, chaining, and custom combinators
- The Node.js event loop model -- phases (timers, pending callbacks, idle/prepare, poll, check, close), microtask queues,
process.nextTick vs queueMicrotask, and tick starvation
- Node.js Streams -- Readable, Writable, Duplex, Transform streams; backpressure;
pipeline; stream composition; async iteration over streams
- Worker threads --
worker_threads module, SharedArrayBuffer, Atomics, MessageChannel, thread pool sizing
AbortController and AbortSignal -- cooperative cancellation, signal propagation, integration with fetch, fs, and third-party async APIs
- Async iterators and generators --
Symbol.asyncIterator, for await...of, async generator functions, composing async iterables
- Concurrency coordination -- queues, semaphores, rate limiting, batching, fan-out/fan-in patterns
AsyncLocalStorage and async context propagation
Do NOT use this skill when the user asks about:
- General JavaScript language features not specific to Node.js async -- use
javascript-idioms instead (covers closures, prototypes, destructuring, optional chaining)
- Handling async errors,
unhandledRejection, domain modules, or structured error propagation patterns -- use nodejs-error-handling instead
- CPU profiling, flame graphs, memory leak detection, garbage collection tuning, or benchmarking async code -- use
nodejs-performance instead
- Frontend browser async patterns (Web Workers, Service Workers, browser fetch) -- this skill covers Node.js server-side only
- Database query patterns, ORM async usage, or connection pool management -- use the relevant database skill
- HTTP server frameworks (Express, Fastify, Koa) async middleware specifics -- use the relevant framework skill
Process
1. Classify the Async Problem
Before writing any code, determine which category the user's problem falls into. Misclassifying leads to choosing the wrong primitive entirely.
- I/O-bound, sequential -- one async operation depends on the result of a previous one. The correct tool is
async/await with await in sequence. Common mistake: wrapping multiple independent operations in sequential await when they could be parallel.
- I/O-bound, parallel -- multiple async operations are independent and can proceed simultaneously. Use
Promise.all for fail-fast behavior or Promise.allSettled when partial failure is acceptable.
- CPU-bound -- any synchronous computation exceeding ~1ms that would block the event loop. Use
worker_threads. Do NOT use child_process.fork for CPU tasks unless you need process isolation or legacy CommonJS module compatibility.
- Streaming data -- data arrives or must be produced incrementally (large file reads, network responses, ETL pipelines). Use Node.js Streams or async generators, NOT buffering everything into memory.
- Cancellable operations -- the user needs to abort an in-flight async operation (HTTP request timeout, user-initiated cancel, race between abort and completion). Use
AbortController/AbortSignal.
- Context propagation -- you need request-scoped data (request ID, user identity, trace context) to flow through async call chains without threading it through every function argument. Use
AsyncLocalStorage.
- Rate-limited or concurrency-capped -- you have N items to process but can only run K at a time. Use a semaphore pattern or a library like
p-limit (concurrency cap) or p-throttle (time-rate cap).
2. Apply the Event Loop Mental Model
Every async pattern decision in Node.js requires understanding the event loop's phase ordering. Get this wrong and you introduce subtle timing bugs.
- The event loop processes phases in this order: timers (
setTimeout/setInterval callbacks) → pending I/O callbacks (deferred I/O errors) → idle/prepare (internal) → poll (new I/O events) → check (setImmediate callbacks) → close callbacks (socket close events).
- Microtask queues run after every phase completes --
Promise resolution callbacks and queueMicrotask callbacks run here. process.nextTick callbacks run in a separate queue that drains before microtasks.
- Priority ordering (highest to lowest):
process.nextTick → Promise microtasks → setImmediate → setTimeout(fn, 0).
- Use
process.nextTick sparingly -- it can starve the event loop if called recursively. Prefer queueMicrotask for Promise-adjacent scheduling.
- Use
setImmediate to yield control back to the event loop to allow I/O callbacks to fire before your next chunk of synchronous work.
- CPU-bound synchronous work blocking longer than 10ms will cause measurable latency on concurrent requests. The threshold for "acceptable" synchronous blocking is typically ≤1ms on a heavily loaded server.
3. Design Promise Composition
Choose the right combinator based on the failure semantics needed.
Promise.all(promises) -- runs all concurrently, resolves when all resolve, rejects immediately when any reject (fail-fast). Use for parallel independent operations where you need all results and any failure is fatal.
Promise.allSettled(promises) -- runs all concurrently, always resolves with an array of {status, value} or {status, reason} objects. Use when partial success is acceptable and you need to inspect every outcome.
Promise.race(promises) -- resolves or rejects as soon as the first promise settles. Classic use: implementing timeouts by racing a real operation against a setTimeout-based rejection.
Promise.any(promises) -- resolves as soon as any promise resolves (ignores rejections), rejects with AggregateError only if all reject. Use for "first successful response wins" patterns like hedged requests.
- Sequential with
reduce -- when you need to chain an array of async operations sequentially: array.reduce(async (prev, item) => { await prev; return processItem(item); }, Promise.resolve()). Use sparingly -- it defeats the benefit of concurrency.
- Concurrency-capped fan-out -- when processing N items with max K concurrent operations, chunk with
p-limit or implement a semaphore using a Promise counter pattern. Rule of thumb: cap concurrent outbound HTTP requests at 10--50 depending on the target server's limits; cap database queries at your connection pool size (typically 5--20).
4. Implement async/await Correctly
async/await is syntactic sugar over Promises, but the sugar introduces its own failure modes.
- Avoid the sequential trap --
await a(); await b(); when a and b are independent takes time(a) + time(b). Use const [ra, rb] = await Promise.all([a(), b()]); to take max(time(a), time(b)).
- Avoid
async functions inside forEach -- array.forEach(async (item) => { ... }) does not await the callbacks; the loop exits before any async work completes. Use for...of with await, or Promise.all(array.map(async (item) => ...)) for parallel execution.
- Avoid
new Promise wrapping an async function -- the Promise constructor executor is synchronous and wrapping async code inside it hides the inner async errors. Use async functions directly.
- Return vs.
return await -- inside a try/catch, write return await promise not return promise. Without await, the function returns before the promise settles, so any rejection escapes the try/catch block. Outside a try/catch, bare return promise is fine and saves a microtask tick.
- Top-level
await -- available in ES modules (.mjs or "type": "module" in package.json). Blocks module initialization, which is the intended behavior for connecting to databases before serving requests. Do NOT use it inside shared utility modules that many files import -- it serializes module loading.
- Async IIFE pattern -- for CommonJS files that can't use top-level await:
(async () => { ... })().catch(console.error). Always attach a .catch handler to prevent unhandled rejection.
5. Implement Streams and Async Iteration
Streams are the correct answer whenever data size is unbounded or latency-to-first-byte matters more than total throughput.
- Use
stream.pipeline (or its stream/promises variant) instead of .pipe() for production code. pipeline automatically handles error cleanup and stream destruction. .pipe() does not propagate errors and leaves streams open on failure.
- Backpressure -- Writable streams signal backpressure via the return value of
.write() returning false. When false, stop writing and wait for the drain event. Failing to respect backpressure causes unbounded memory growth. pipeline handles this automatically.
- Transform stream pattern -- subclass
Transform and implement _transform(chunk, encoding, callback). Call callback(null, transformedData) to push data downstream. Call callback(error) to signal an error. Keep transforms stateless where possible; accumulate state only when absolutely necessary (e.g., decompression, protocol framing).
- Async iteration over streams --
for await (const chunk of readable) works on any Readable stream in Node.js 12+. This is the idiomatic way to consume streams in modern code. Remember to destroy the stream on early exit: use break inside a try/finally block with stream.destroy() in the finally clause.
- Object mode streams -- setting
objectMode: true allows streams to carry arbitrary JavaScript objects, not just Buffer and strings. Use for in-process data pipelines. Do NOT use object-mode streams for network I/O -- serialization must happen at the boundary.
- Readable.from --
Readable.from(asyncIterable) converts any async iterable (including async generator functions) into a Readable stream. This bridges the async generator and stream worlds.
- highWaterMark tuning -- default is 16KB for byte streams, 16 objects for object mode. For large file processing, increase to 64KB--1MB. For high-throughput object pipelines, tune based on the average object size and desired latency.
6. Implement Cancellation with AbortController
Cancellation is cooperative -- the callee must check for cancellation; the caller signals it.
- Create a signal:
const controller = new AbortController(); const { signal } = controller;. Pass signal into cancelable operations. Call controller.abort() to cancel.
- Integrate with native Node.js APIs:
fs.readFile, fs.writeFile, http.request (via fetch), setTimeout (via timers/promises), and EventEmitter.once all accept an { signal } option. When the signal fires, they reject with AbortError (error.name === 'AbortError').
- Implement a timeout shorthand:
AbortSignal.timeout(5000) (Node.js 17.3+) creates a signal that automatically aborts after 5000ms. Combine multiple signals with AbortSignal.any([sig1, sig2]) (Node.js 20.3+).
- Propagate signals through your own async functions: Accept
signal as a parameter and check signal.throwIfAborted() at each async boundary. Listen for signal.addEventListener('abort', cleanup) to release resources (close sockets, cancel timers) when abort fires.
- Race pattern for timeout: For older Node.js versions without
AbortSignal.timeout: Promise.race([operation(signal), new Promise((_, reject) => setTimeout(() => { controller.abort(); reject(new Error('Timeout')); }, 5000))]).
- Distinguish cancellation from errors: Always check
error.name === 'AbortError' before treating a rejection as an unexpected error. Cancellation is not an error -- it is a normal control flow outcome. Log it differently and do not trigger alerts.
7. Implement Worker Threads
Use worker threads when profiling confirms the event loop is blocked by CPU-bound work, not for I/O.
- Determine if worker threads are needed -- profile first. Only introduce workers if a specific computation exceeds ~10ms consistently. Common legitimate uses: image processing, cryptographic operations (beyond what
crypto offloads natively), JSON parsing of very large payloads (>500KB), or custom compression.
- Communication pattern -- use
worker.postMessage(data) and worker.on('message', handler). For structured data, postMessage uses the structured clone algorithm (no functions, no prototypes beyond built-ins). Transfer ownership of ArrayBuffer with the transfer list to avoid copying: worker.postMessage(buffer, [buffer]).
- SharedArrayBuffer + Atomics -- use for high-frequency data sharing between the main thread and workers (e.g., a shared ring buffer). Requires
--experimental-shared-memory flag (Node.js <14) or Cross-Origin-Isolation headers in some environments. Use Atomics.wait for worker synchronization; never use it on the main thread (it blocks).
- Worker pool pattern -- maintain a pool of N workers where N ≈
os.cpus().length - 1 (leave one CPU for the event loop). Implement a task queue in the main thread; dispatch tasks to idle workers. Track in-flight work with a Map<workerId, {resolve, reject}>.
workerData for initialization -- pass static configuration to the worker at startup via workerData in the Worker constructor options. This is cloned once at creation; do not pass large mutable state here.
- Error handling -- listen for
worker.on('error', handler) and worker.on('exit', code => { if (code !== 0) ... }). A worker that crashes does not crash the main process, but you must detect and replace it.
8. Use AsyncLocalStorage for Context Propagation
Request-scoped context should flow through async call chains without manual threading.
- Create a store per request -- instantiate
AsyncLocalStorage once at module level, then call storage.run({ requestId, userId, traceId }, asyncHandler) at the beginning of each request. All async operations called within that callback inherit the store automatically.
- Access context anywhere -- call
storage.getStore() inside any async function in the call chain. Returns undefined if called outside a run() context; always guard with a null check.
- Performance cost --
AsyncLocalStorage adds ~10--20% overhead to async context switches in high-throughput scenarios (>100k req/s). For most services this is negligible. Benchmark with your actual workload before optimizing.
- Combining with Promises --
AsyncLocalStorage correctly propagates through Promise.all, async/await, and setTimeout. It does NOT propagate across worker_threads message boundaries -- you must manually serialize and deserialize context when crossing thread boundaries.
- Common use cases -- distributed tracing (propagate trace ID to all log statements and downstream calls), per-request caching, audit logging, multi-tenant data isolation.
Output Format
When responding to a user's async pattern question, produce output in this structure:
## Async Pattern Analysis
### Problem Classification
- **Category:** [I/O-bound sequential | I/O-bound parallel | CPU-bound | Streaming | Cancellable | Context propagation | Rate-limited]
- **Node.js Versions Supported:** [minimum version for the recommended approach]
- **Key Constraint:** [the primary constraint driving the pattern choice]
### Pattern Decision Matrix
| Pattern | Use When | Avoid When | Overhead |
|---------|----------|------------|----------|
| async/await sequential | operations depend on prior results | operations are independent | minimal |
| Promise.all | parallel independent I/O, fail-fast needed | partial failure is acceptable | minimal |
| Promise.allSettled | parallel I/O, partial failure acceptable | all must succeed | minimal |
| Stream pipeline | unbounded data, backpressure needed | data fits in memory, latency irrelevant | low |
| Worker threads | CPU >10ms blocks event loop | I/O-bound work | medium (thread overhead) |
| AbortController | timeout or user-initiated cancel needed | fire-and-forget operations | minimal |
| AsyncLocalStorage | request context propagation | single-request linear code | low (~10-20% async overhead) |
| Concurrency limiter | N items, max K concurrent | single item, no rate limit | minimal |
### Recommended Implementation
[Pattern name and rationale in 2-3 sentences]
**Key decision:** [Why this pattern over the alternatives]
### Code Implementation
```javascript
// [Label the code with what it demonstrates]
// Node.js [version]+ required
[Complete, production-ready code example with error handling]
Pitfalls to Avoid
- [Specific anti-pattern relevant to this solution]
- [Specific anti-pattern relevant to this solution]
- [Specific anti-pattern relevant to this solution]
Testing Approach
- [How to test this specific pattern]
- [What edge cases to test]
---
## Rules
1. **NEVER use `Promise.all` when partial failure is acceptable.** `Promise.all` rejects on the first failure, canceling the wait for all other promises. If some failures are tolerable (e.g., enriching a response from multiple optional data sources), use `Promise.allSettled` and filter the results.
2. **NEVER use `array.forEach` with async callbacks.** `forEach` ignores the returned Promise and exits the loop synchronously before any async work completes. Use `for...of` for sequential execution or `Promise.all(array.map(...))` for parallel execution.
3. **NEVER block the event loop with synchronous work exceeding 1ms in a server context.** JSON parsing of large payloads, synchronous file reads (`fs.readFileSync`), and CPU-intensive crypto operations block ALL concurrent requests. Profile first; offload to worker threads if confirmed.
4. **ALWAYS use `stream.pipeline` from `stream/promises` instead of `.pipe()` in production code.** `.pipe()` does not propagate errors and does not destroy the source stream when the destination errors, causing memory leaks and resource exhaustion.
5. **ALWAYS write `return await promise` inside `try/catch` blocks.** Without `await`, the function returns before the promise settles, and rejections escape the `try/catch`. This is one of the most common and subtle bugs in async/await code.
6. **NEVER check `error.message` to detect `AbortError`.** The message string is not guaranteed. Always check `error.name === 'AbortError'` or `error instanceof DOMException` (in environments where `DOMException` is available). In Node.js 18+, also check `error.code === 'ABORT_ERR'` for Node-native AbortErrors.
7. **ALWAYS cap concurrency when processing arrays of I/O operations.** `Promise.all(largeArray.map(fetchFromDB))` with 10,000 items opens 10,000 simultaneous connections. Use `p-limit` with a concurrency of 5--50 (tuned to your connection pool size and target server limits) or implement a semaphore pattern.
8. **NEVER use `process.nextTick` in application code for control flow.** It was designed for internal Node.js library use. Recursive `nextTick` calls starve the event loop, preventing I/O callbacks from running. Use `setImmediate` or `queueMicrotask` for yielding in application code.
9. **ALWAYS pass `AbortSignal` through your async call stack.** Cancellation must be propagated to every async operation in the chain. Catching an `AbortError` and swallowing it without re-throwing or re-aborting breaks cancellation semantics -- the upstream caller's abort becomes ineffective.
10. **NEVER create Worker threads on a per-request basis.** Thread creation and teardown is expensive (10--100ms). Workers must be pooled. If you find yourself constructing `new Worker(...)` inside a request handler, refactor to use a pre-initialized thread pool.
11. **ALWAYS handle the `drain` event when manually writing to a Writable stream.** If `writable.write(chunk)` returns `false`, stop writing and resume only on `drain`. Ignoring this causes unbounded memory growth as Node.js buffers all the undelivered chunks.
12. **NEVER assume `async/await` adds parallelism.** `await` suspends the current async function at that point -- it does NOT run operations in parallel. Parallelism requires either `Promise.all` or separate invocations that are not awaited sequentially.
---
## Edge Cases
### Legacy Callback-Based Code Integration
When integrating with Node.js core APIs or third-party libraries that use the `(err, result)` callback style, use `util.promisify`. For APIs with non-standard callback signatures (e.g., callbacks with multiple success arguments, or `(result, err)` reversed order), create a manual wrapper with `new Promise`. Never use `util.promisify` on callback APIs that emit multiple values over time (like `EventEmitter`) -- that pattern requires stream or async iterator conversion, not promisification.
For `EventEmitter` patterns, use `events.once(emitter, 'eventName')` to await a single event, or `events.on(emitter, 'eventName')` to create an async iterable over all events. Always set a timeout or hook into the emitter's error event to avoid hanging forever if the expected event never fires.
### Unintended Promise Floating (Fire and Forget)
When you intentionally fire and forget (e.g., logging to an external service asynchronously without blocking the response), you MUST attach an error handler. `asyncOperation().catch(err => logger.error('background task failed', err))`. Without the `.catch`, a rejection becomes an unhandled rejection warning (Node.js 15+ throws by default, crashing the process). Document clearly in code comments that the fire-and-forget is intentional and that the error handler is the safety net.
### Async Generator Cleanup and Early Exit
When consuming an async generator with `for await...of` and breaking early, the generator's `finally` block executes -- but only if the generator object is properly returned. When the `for...of` loop breaks, JavaScript calls `generator.return()`, which triggers cleanup. However, if you convert the generator to an array (`Array.from`) or pass it to `Promise.all`, early exit cleanup does not happen automatically. In these cases, wrap the generator in a `try/finally` and manually call `generator.return()` on early exit.
For async generators wrapped as streams via `Readable.from`, stream destruction (`stream.destroy()`) correctly propagates back and triggers the generator's `finally` block.
### Combining AbortSignal with Promise.all
`Promise.all` is not abort-aware by default. If one operation aborts, `Promise.all` rejects, but the other in-flight operations continue running until they naturally complete or fail. To truly cancel all operations in a `Promise.all` group on first failure or external abort, create a single `AbortController`, pass its signal to all operations, and call `controller.abort()` in a `.catch()` handler on the `Promise.all`. This implements "cancel all on first error" semantics similar to structured concurrency.
```javascript
const controller = new AbortController();
const { signal } = controller;
try {
await Promise.all(
operations.map(op => op(signal))
);
} catch (err) {
controller.abort(); // cancel remaining operations
throw err;
}
Worker Thread Module Loading and Warm-Up
Worker threads run in a fresh V8 context and must re-load and re-JIT all required modules. For workers that import large dependencies (e.g., image processing libraries, ML inference runtimes), the cold start can be 200--2000ms. Mitigate by:
- Pre-creating the worker pool at application startup, not at first use
- Sending a warm-up message that forces JIT compilation of hot code paths
- Using
workerData to pass initialization config rather than sending it as the first task message
If your Node.js version supports it (18.7+), use --experimental-vm-modules and the Worker eval option to share already-parsed module code.
Tick Starvation from Recursive Microtasks
A recursive async pattern that schedules new microtasks before completing (e.g., async function loop() { await processItem(); return loop(); }) can starve the event loop of I/O callbacks because the microtask queue drains completely before each event loop phase. The event loop never reaches the poll phase to pick up new I/O events. Fix by yielding to the event loop periodically with await new Promise(resolve => setImmediate(resolve)) every N iterations, where N is tuned to keep I/O latency acceptable (typically every 100--1000 iterations).
AsyncLocalStorage Across Worker Thread Boundaries
AsyncLocalStorage context does NOT automatically propagate into worker_threads. Worker threads have their own independent async context. If you need request context (trace ID, request ID) inside a worker, serialize the relevant fields from storage.getStore() and pass them via workerData or postMessage. Inside the worker, reconstruct the context manually by storing the values in module-level variables or creating a new AsyncLocalStorage store with the received data.
Example
Input from user: "I need to process 50,000 user records from a database, enrich each one by calling an external REST API, and write the results to a file. The API has a rate limit of 100 requests per second and allows up to 20 concurrent connections. How do I structure this in Node.js?"
Async Pattern Analysis
Problem Classification
- Category: I/O-bound parallel with concurrency cap + streaming output
- Node.js Versions Supported: 18+ (for
stream/promises, AbortSignal.timeout, async iteration)
- Key Constraint: API rate limit (100 req/s) and connection limit (20 concurrent) prevent naive
Promise.all; data volume (50k records) prevents full in-memory buffering
Pattern Decision Matrix
| Pattern |
Use When |
Avoid When |
Overhead |
Promise.all over all 50k |
all could run concurrently |
rate/connection limited |
catastrophic here |
Sequential for...of |
simplest, no concurrency |
throughput too low (1 req at a time) |
none but slow |
p-limit (concurrency cap) |
cap at 20 concurrent |
time-rate limiting also needed |
minimal |
p-throttle (rate cap) |
cap at 100/s |
connection limit also needed |
minimal |
| Combined semaphore + throttle |
both connection and rate limits apply |
neither applies |
minimal |
| Readable stream → Transform → Writable |
50k rows, can't buffer all in memory |
small dataset, buffering fine |
low |
Recommended Implementation
Use a streaming pipeline to read database records without buffering all 50k in memory, process each record through a Transform stream that applies concurrency and rate limiting, and write results to a file using stream.pipeline. The combination of a concurrency limiter (max 20 concurrent API calls) with a time-rate throttle (max 100/s) matches the API's constraints exactly.
Key decision: Streaming pipeline over Promise.all because 50,000 records cannot be loaded into memory simultaneously (typical record + enriched result ≈ 2--10KB each = 100--500MB). Streaming keeps memory bounded to highWaterMark × record size ≈ a few MB.
Code Implementation
// Streaming enrichment pipeline with concurrency + rate limiting
// Node.js 18+ required
// Dependencies: p-limit, p-throttle (npm install p-limit p-throttle)
import { pipeline, Transform } from 'node:stream';
import { pipeline as asyncPipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import pLimit from 'p-limit';
import pThrottle from 'p-throttle';
const CONCURRENCY_LIMIT = 20; // max simultaneous open API connections
const RATE_LIMIT = 100; // max API calls per second
const RATE_INTERVAL = 1000; // 1 second window in ms
// Compose both limits: a call must acquire a concurrency slot AND
// pass the throttle before firing.
const limit = pLimit(CONCURRENCY_LIMIT);
const throttledFetch = pThrottle({
limit: RATE_LIMIT,
interval: RATE_INTERVAL,
})(async (userId, signal) => {
const response = await fetch(
`https://api.example.com/enrich/${userId}`,
{ signal }
);
if (!response.ok) {
throw new Error(`API error ${response.status} for user ${userId}`);
}
return response.json();
});
// Wrap with concurrency limiter so both constraints apply
async function enrichUser(record, signal) {
return limit(() => throttledFetch(record.id, signal));
}
// Transform stream: receives user record objects, outputs enriched JSON lines
class EnrichmentTransform extends Transform {
constructor(signal) {
super({ objectMode: true, highWaterMark: 50 }); // buffer max 50 objects
this.signal = signal;
this.pending = new Set();
}
_transform(record, _encoding, callback) {
// Check for cancellation at each record boundary
if (this.signal.aborted) {
callback(new Error('Pipeline aborted'));
return;
}
const enrichPromise = enrichUser(record, this.signal)
.then(enriched => {
const result = { ...record, ...enriched, enrichedAt: new Date().toISOString() };
// Push as newline-delimited JSON
if (!this.push(JSON.stringify(result) + '\n')) {
// Backpressure: downstream is full, but Transform handles this via objectMode
}
})
.catch(err => {
// Decide: fail the whole pipeline, or log and skip?
// Here: log and skip to maximize partial success
console.error(`Failed to enrich user ${record.id}:`, err.message);
this.push(JSON.stringify({ ...record, enrichmentError: err.message }) + '\n');
})
.finally(() => {
this.pending.delete(enrichPromise);
});
this.pending.add(enrichPromise);
// Do not await -- allow up to highWaterMark records to be in-flight
// Signal ready for next record immediately (backpressure is via p-limit)
callback();
}
_flush(callback) {
// Wait for all in-flight enrichments to complete before closing
Promise.all(this.pending)
.then(() => callback())
.catch(callback);
}
}
// Database cursor as an async iterable (pseudo-code for your DB driver)
// Replace with your actual driver's streaming/cursor API:
// - pg: client.query(new Cursor('SELECT ...'))
// - MySQL2: connection.query('SELECT ...').stream()
// - MongoDB: collection.find({}).stream()
async function* databaseCursor(db) {
const cursor = db.collection('users').find({}).batchSize(500);
try {
for await (const doc of cursor) {
yield doc;
}
} finally {
await cursor.close();
}
}
// Main pipeline
async function runEnrichmentPipeline(db) {
// 5-minute overall timeout for the entire pipeline
const signal = AbortSignal.timeout(5 * 60 * 1000);
const sourceStream = Readable.from(databaseCursor(db), {
objectMode: true,
highWaterMark: 100, // read ahead 100 records from DB cursor
});
const enrichTransform = new EnrichmentTransform(signal);
const outputStream = createWriteStream('./enriched-users.ndjson', {
flags: 'w',
encoding: 'utf8',
});
console.time('enrichment-pipeline');
try {
await asyncPipeline(
sourceStream,
enrichTransform,
outputStream
);
console.timeEnd('enrichment-pipeline');
console.log('Pipeline complete');
} catch (err) {
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
console.error('Pipeline timed out after 5 minutes');
} else {
console.error('Pipeline failed:', err);
}
throw err;
}
}
Pitfalls to Avoid
- Do NOT use
Promise.all(allRecords.map(enrichUser)) -- this fires 50,000 API requests simultaneously, saturates the connection pool, likely triggers API bans, and buffers all results in memory.
- Do NOT use sequential
for await without the concurrency limiter -- it processes 1 record at a time, achieving roughly 1 req/s instead of the allowed 100 req/s, making the job ~100x slower than necessary.
- Do NOT call
callback() inside _transform after callback(error) -- calling the Transform callback twice corrupts the stream state. Use early return after callback(error).
- Do NOT forget
_flush -- without it, in-flight enrichments that haven't pushed their results yet are dropped when the source stream ends.
- Do NOT ignore backpressure signals from the output file write stream --
asyncPipeline handles this, but if you replace it with manual .pipe() calls, you must handle drain events.
Testing Approach
- Unit test
enrichUser with a mocked fetch that records call timing. Assert that no more than 20 calls are in-flight simultaneously (use a counter incremented on call start, decremented on completion).
- Test rate limiting by recording timestamps of all mock API calls and asserting no 1-second window contains more than 100 calls.
- Test the timeout by injecting an
AbortSignal that is pre-aborted (AbortSignal.abort()) and asserting the pipeline rejects with an AbortError rather than hanging.
- Test
_flush completion by using a slow mock API (delayed with setTimeout) and asserting the output file contains results for all records that were started before the source stream ended.
- Test partial failure handling by having the mock API reject for specific user IDs and asserting those records appear in the output with
enrichmentError set rather than crashing the pipeline.
- Integration test against a real or Docker-based instance of your database and a local mock API server (
nock or msw in server mode) to validate end-to-end throughput approaches 100 req/s.
1---2name: nodejs-async-patterns3description: Guides expert-level Node.js asynchronous programming: Promise composition, async/await patterns, event loop model, stream processing, worker threads, and AbortController for cancellation. Use when the user asks about Node.js async/await, Promise patterns, event loop, streams, worker threads, AbortController, async iterators. Do NOT use when the user asks about JavaScript idioms (use `javascript-idioms`), Node.js error handling (use `nodejs-error-handling`), Node.js performance (use `nodejs-performance`).4license: Apache-2.05---6# Node.js Async Patterns78## When to Use910**Use this skill when the user asks about:**11- `async/await` syntax, patterns, and anti-patterns in Node.js -- including sequential vs. parallel execution, top-level await, and async IIFE patterns12- Promise composition -- `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.any`, chaining, and custom combinators13- The Node.js event loop model -- phases (timers, pending callbacks, idle/prepare, poll, check, close), microtask queues, `process.nextTick` vs `queueMicrotask`, and tick starvation14- Node.js Streams -- Readable, Writable, Duplex, Transform streams; backpressure; `pipeline`; stream composition; async iteration over streams15- Worker threads -- `worker_threads` module, `SharedArrayBuffer`, `Atomics`, `MessageChannel`, thread pool sizing16- `AbortController` and `AbortSignal` -- cooperative cancellation, signal propagation, integration with `fetch`, `fs`, and third-party async APIs17- Async iterators and generators -- `Symbol.asyncIterator`, `for await...of`, async generator functions, composing async iterables18- Concurrency coordination -- queues, semaphores, rate limiting, batching, fan-out/fan-in patterns19- `AsyncLocalStorage` and async context propagation2021**Do NOT use this skill when the user asks about:**22- General JavaScript language features not specific to Node.js async -- use `javascript-idioms` instead (covers closures, prototypes, destructuring, optional chaining)23- Handling async errors, `unhandledRejection`, domain modules, or structured error propagation patterns -- use `nodejs-error-handling` instead24- CPU profiling, flame graphs, memory leak detection, garbage collection tuning, or benchmarking async code -- use `nodejs-performance` instead25- Frontend browser async patterns (Web Workers, Service Workers, browser fetch) -- this skill covers Node.js server-side only26- Database query patterns, ORM async usage, or connection pool management -- use the relevant database skill27- HTTP server frameworks (Express, Fastify, Koa) async middleware specifics -- use the relevant framework skill2829---3031## Process3233### 1. Classify the Async Problem3435Before writing any code, determine which category the user's problem falls into. Misclassifying leads to choosing the wrong primitive entirely.3637- **I/O-bound, sequential** -- one async operation depends on the result of a previous one. The correct tool is `async/await` with `await` in sequence. Common mistake: wrapping multiple independent operations in sequential `await` when they could be parallel.38- **I/O-bound, parallel** -- multiple async operations are independent and can proceed simultaneously. Use `Promise.all` for fail-fast behavior or `Promise.allSettled` when partial failure is acceptable.39- **CPU-bound** -- any synchronous computation exceeding ~1ms that would block the event loop. Use `worker_threads`. Do NOT use `child_process.fork` for CPU tasks unless you need process isolation or legacy CommonJS module compatibility.40- **Streaming data** -- data arrives or must be produced incrementally (large file reads, network responses, ETL pipelines). Use Node.js Streams or async generators, NOT buffering everything into memory.41- **Cancellable operations** -- the user needs to abort an in-flight async operation (HTTP request timeout, user-initiated cancel, race between abort and completion). Use `AbortController`/`AbortSignal`.42- **Context propagation** -- you need request-scoped data (request ID, user identity, trace context) to flow through async call chains without threading it through every function argument. Use `AsyncLocalStorage`.43- **Rate-limited or concurrency-capped** -- you have N items to process but can only run K at a time. Use a semaphore pattern or a library like `p-limit` (concurrency cap) or `p-throttle` (time-rate cap).4445### 2. Apply the Event Loop Mental Model4647Every async pattern decision in Node.js requires understanding the event loop's phase ordering. Get this wrong and you introduce subtle timing bugs.4849- The event loop processes phases in this order: **timers** (`setTimeout`/`setInterval` callbacks) → **pending I/O callbacks** (deferred I/O errors) → **idle/prepare** (internal) → **poll** (new I/O events) → **check** (`setImmediate` callbacks) → **close callbacks** (socket close events).50- **Microtask queues** run after every phase completes -- `Promise` resolution callbacks and `queueMicrotask` callbacks run here. `process.nextTick` callbacks run in a separate queue that drains before microtasks.51- Priority ordering (highest to lowest): `process.nextTick` → `Promise` microtasks → `setImmediate` → `setTimeout(fn, 0)`.52- Use `process.nextTick` sparingly -- it can starve the event loop if called recursively. Prefer `queueMicrotask` for Promise-adjacent scheduling.53- Use `setImmediate` to yield control back to the event loop to allow I/O callbacks to fire before your next chunk of synchronous work.54- CPU-bound synchronous work blocking longer than 10ms will cause measurable latency on concurrent requests. The threshold for "acceptable" synchronous blocking is typically ≤1ms on a heavily loaded server.5556### 3. Design Promise Composition5758Choose the right combinator based on the failure semantics needed.5960- **`Promise.all(promises)`** -- runs all concurrently, resolves when all resolve, rejects immediately when any reject (fail-fast). Use for parallel independent operations where you need all results and any failure is fatal.61- **`Promise.allSettled(promises)`** -- runs all concurrently, always resolves with an array of `{status, value}` or `{status, reason}` objects. Use when partial success is acceptable and you need to inspect every outcome.62- **`Promise.race(promises)`** -- resolves or rejects as soon as the first promise settles. Classic use: implementing timeouts by racing a real operation against a `setTimeout`-based rejection.63- **`Promise.any(promises)`** -- resolves as soon as any promise resolves (ignores rejections), rejects with `AggregateError` only if all reject. Use for "first successful response wins" patterns like hedged requests.64- **Sequential with `reduce`** -- when you need to chain an array of async operations sequentially: `array.reduce(async (prev, item) => { await prev; return processItem(item); }, Promise.resolve())`. Use sparingly -- it defeats the benefit of concurrency.65- **Concurrency-capped fan-out** -- when processing N items with max K concurrent operations, chunk with `p-limit` or implement a semaphore using a Promise counter pattern. Rule of thumb: cap concurrent outbound HTTP requests at 10--50 depending on the target server's limits; cap database queries at your connection pool size (typically 5--20).6667### 4. Implement `async/await` Correctly6869`async/await` is syntactic sugar over Promises, but the sugar introduces its own failure modes.7071- **Avoid the sequential trap** -- `await a(); await b();` when `a` and `b` are independent takes `time(a) + time(b)`. Use `const [ra, rb] = await Promise.all([a(), b()]);` to take `max(time(a), time(b))`.72- **Avoid `async` functions inside `forEach`** -- `array.forEach(async (item) => { ... })` does not await the callbacks; the loop exits before any async work completes. Use `for...of` with `await`, or `Promise.all(array.map(async (item) => ...))` for parallel execution.73- **Avoid `new Promise` wrapping an `async` function** -- the `Promise` constructor executor is synchronous and wrapping `async` code inside it hides the inner async errors. Use `async` functions directly.74- **Return vs. `return await`** -- inside a `try/catch`, write `return await promise` not `return promise`. Without `await`, the function returns before the promise settles, so any rejection escapes the `try/catch` block. Outside a `try/catch`, bare `return promise` is fine and saves a microtask tick.75- **Top-level `await`** -- available in ES modules (`.mjs` or `"type": "module"` in `package.json`). Blocks module initialization, which is the intended behavior for connecting to databases before serving requests. Do NOT use it inside shared utility modules that many files import -- it serializes module loading.76- **Async IIFE pattern** -- for CommonJS files that can't use top-level await: `(async () => { ... })().catch(console.error)`. Always attach a `.catch` handler to prevent unhandled rejection.7778### 5. Implement Streams and Async Iteration7980Streams are the correct answer whenever data size is unbounded or latency-to-first-byte matters more than total throughput.8182- **Use `stream.pipeline`** (or its `stream/promises` variant) instead of `.pipe()` for production code. `pipeline` automatically handles error cleanup and stream destruction. `.pipe()` does not propagate errors and leaves streams open on failure.83- **Backpressure** -- Writable streams signal backpressure via the return value of `.write()` returning `false`. When `false`, stop writing and wait for the `drain` event. Failing to respect backpressure causes unbounded memory growth. `pipeline` handles this automatically.84- **Transform stream pattern** -- subclass `Transform` and implement `_transform(chunk, encoding, callback)`. Call `callback(null, transformedData)` to push data downstream. Call `callback(error)` to signal an error. Keep transforms stateless where possible; accumulate state only when absolutely necessary (e.g., decompression, protocol framing).85- **Async iteration over streams** -- `for await (const chunk of readable)` works on any Readable stream in Node.js 12+. This is the idiomatic way to consume streams in modern code. Remember to destroy the stream on early exit: use `break` inside a `try/finally` block with `stream.destroy()` in the `finally` clause.86- **Object mode streams** -- setting `objectMode: true` allows streams to carry arbitrary JavaScript objects, not just `Buffer` and strings. Use for in-process data pipelines. Do NOT use object-mode streams for network I/O -- serialization must happen at the boundary.87- **Readable.from** -- `Readable.from(asyncIterable)` converts any async iterable (including async generator functions) into a Readable stream. This bridges the async generator and stream worlds.88- **highWaterMark tuning** -- default is 16KB for byte streams, 16 objects for object mode. For large file processing, increase to 64KB--1MB. For high-throughput object pipelines, tune based on the average object size and desired latency.8990### 6. Implement Cancellation with AbortController9192Cancellation is cooperative -- the callee must check for cancellation; the caller signals it.9394- **Create a signal**: `const controller = new AbortController(); const { signal } = controller;`. Pass `signal` into cancelable operations. Call `controller.abort()` to cancel.95- **Integrate with native Node.js APIs**: `fs.readFile`, `fs.writeFile`, `http.request` (via `fetch`), `setTimeout` (via `timers/promises`), and `EventEmitter.once` all accept an `{ signal }` option. When the signal fires, they reject with `AbortError` (`error.name === 'AbortError'`).96- **Implement a timeout shorthand**: `AbortSignal.timeout(5000)` (Node.js 17.3+) creates a signal that automatically aborts after 5000ms. Combine multiple signals with `AbortSignal.any([sig1, sig2])` (Node.js 20.3+).97- **Propagate signals through your own async functions**: Accept `signal` as a parameter and check `signal.throwIfAborted()` at each async boundary. Listen for `signal.addEventListener('abort', cleanup)` to release resources (close sockets, cancel timers) when abort fires.98- **Race pattern for timeout**: For older Node.js versions without `AbortSignal.timeout`: `Promise.race([operation(signal), new Promise((_, reject) => setTimeout(() => { controller.abort(); reject(new Error('Timeout')); }, 5000))])`.99- **Distinguish cancellation from errors**: Always check `error.name === 'AbortError'` before treating a rejection as an unexpected error. Cancellation is not an error -- it is a normal control flow outcome. Log it differently and do not trigger alerts.100101### 7. Implement Worker Threads102103Use worker threads when profiling confirms the event loop is blocked by CPU-bound work, not for I/O.104105- **Determine if worker threads are needed** -- profile first. Only introduce workers if a specific computation exceeds ~10ms consistently. Common legitimate uses: image processing, cryptographic operations (beyond what `crypto` offloads natively), JSON parsing of very large payloads (>500KB), or custom compression.106- **Communication pattern** -- use `worker.postMessage(data)` and `worker.on('message', handler)`. For structured data, postMessage uses the structured clone algorithm (no functions, no prototypes beyond built-ins). Transfer ownership of `ArrayBuffer` with the transfer list to avoid copying: `worker.postMessage(buffer, [buffer])`.107- **SharedArrayBuffer + Atomics** -- use for high-frequency data sharing between the main thread and workers (e.g., a shared ring buffer). Requires `--experimental-shared-memory` flag (Node.js <14) or `Cross-Origin-Isolation` headers in some environments. Use `Atomics.wait` for worker synchronization; never use it on the main thread (it blocks).108- **Worker pool pattern** -- maintain a pool of N workers where N ≈ `os.cpus().length - 1` (leave one CPU for the event loop). Implement a task queue in the main thread; dispatch tasks to idle workers. Track in-flight work with a `Map<workerId, {resolve, reject}>`.109- **`workerData` for initialization** -- pass static configuration to the worker at startup via `workerData` in the `Worker` constructor options. This is cloned once at creation; do not pass large mutable state here.110- **Error handling** -- listen for `worker.on('error', handler)` and `worker.on('exit', code => { if (code !== 0) ... })`. A worker that crashes does not crash the main process, but you must detect and replace it.111112### 8. Use AsyncLocalStorage for Context Propagation113114Request-scoped context should flow through async call chains without manual threading.115116- **Create a store per request** -- instantiate `AsyncLocalStorage` once at module level, then call `storage.run({ requestId, userId, traceId }, asyncHandler)` at the beginning of each request. All async operations called within that callback inherit the store automatically.117- **Access context anywhere** -- call `storage.getStore()` inside any async function in the call chain. Returns `undefined` if called outside a `run()` context; always guard with a null check.118- **Performance cost** -- `AsyncLocalStorage` adds ~10--20% overhead to async context switches in high-throughput scenarios (>100k req/s). For most services this is negligible. Benchmark with your actual workload before optimizing.119- **Combining with Promises** -- `AsyncLocalStorage` correctly propagates through `Promise.all`, `async/await`, and `setTimeout`. It does NOT propagate across `worker_threads` message boundaries -- you must manually serialize and deserialize context when crossing thread boundaries.120- **Common use cases** -- distributed tracing (propagate trace ID to all log statements and downstream calls), per-request caching, audit logging, multi-tenant data isolation.121122---123124## Output Format125126When responding to a user's async pattern question, produce output in this structure:127128```129## Async Pattern Analysis130131### Problem Classification132- **Category:** [I/O-bound sequential | I/O-bound parallel | CPU-bound | Streaming | Cancellable | Context propagation | Rate-limited]133- **Node.js Versions Supported:** [minimum version for the recommended approach]134- **Key Constraint:** [the primary constraint driving the pattern choice]135136### Pattern Decision Matrix137138| Pattern | Use When | Avoid When | Overhead |139|---------|----------|------------|----------|140| async/await sequential | operations depend on prior results | operations are independent | minimal |141| Promise.all | parallel independent I/O, fail-fast needed | partial failure is acceptable | minimal |142| Promise.allSettled | parallel I/O, partial failure acceptable | all must succeed | minimal |143| Stream pipeline | unbounded data, backpressure needed | data fits in memory, latency irrelevant | low |144| Worker threads | CPU >10ms blocks event loop | I/O-bound work | medium (thread overhead) |145| AbortController | timeout or user-initiated cancel needed | fire-and-forget operations | minimal |146| AsyncLocalStorage | request context propagation | single-request linear code | low (~10-20% async overhead) |147| Concurrency limiter | N items, max K concurrent | single item, no rate limit | minimal |148149### Recommended Implementation150151[Pattern name and rationale in 2-3 sentences]152153**Key decision:** [Why this pattern over the alternatives]154155### Code Implementation156157```javascript158// [Label the code with what it demonstrates]159// Node.js [version]+ required160161[Complete, production-ready code example with error handling]162```163164### Pitfalls to Avoid165166- [Specific anti-pattern relevant to this solution]167- [Specific anti-pattern relevant to this solution]168- [Specific anti-pattern relevant to this solution]169170### Testing Approach171172- [How to test this specific pattern]173- [What edge cases to test]174```175176---177178## Rules1791801. **NEVER use `Promise.all` when partial failure is acceptable.** `Promise.all` rejects on the first failure, canceling the wait for all other promises. If some failures are tolerable (e.g., enriching a response from multiple optional data sources), use `Promise.allSettled` and filter the results.1811822. **NEVER use `array.forEach` with async callbacks.** `forEach` ignores the returned Promise and exits the loop synchronously before any async work completes. Use `for...of` for sequential execution or `Promise.all(array.map(...))` for parallel execution.1831843. **NEVER block the event loop with synchronous work exceeding 1ms in a server context.** JSON parsing of large payloads, synchronous file reads (`fs.readFileSync`), and CPU-intensive crypto operations block ALL concurrent requests. Profile first; offload to worker threads if confirmed.1851864. **ALWAYS use `stream.pipeline` from `stream/promises` instead of `.pipe()` in production code.** `.pipe()` does not propagate errors and does not destroy the source stream when the destination errors, causing memory leaks and resource exhaustion.1871885. **ALWAYS write `return await promise` inside `try/catch` blocks.** Without `await`, the function returns before the promise settles, and rejections escape the `try/catch`. This is one of the most common and subtle bugs in async/await code.1891906. **NEVER check `error.message` to detect `AbortError`.** The message string is not guaranteed. Always check `error.name === 'AbortError'` or `error instanceof DOMException` (in environments where `DOMException` is available). In Node.js 18+, also check `error.code === 'ABORT_ERR'` for Node-native AbortErrors.1911927. **ALWAYS cap concurrency when processing arrays of I/O operations.** `Promise.all(largeArray.map(fetchFromDB))` with 10,000 items opens 10,000 simultaneous connections. Use `p-limit` with a concurrency of 5--50 (tuned to your connection pool size and target server limits) or implement a semaphore pattern.1931948. **NEVER use `process.nextTick` in application code for control flow.** It was designed for internal Node.js library use. Recursive `nextTick` calls starve the event loop, preventing I/O callbacks from running. Use `setImmediate` or `queueMicrotask` for yielding in application code.1951969. **ALWAYS pass `AbortSignal` through your async call stack.** Cancellation must be propagated to every async operation in the chain. Catching an `AbortError` and swallowing it without re-throwing or re-aborting breaks cancellation semantics -- the upstream caller's abort becomes ineffective.19719810. **NEVER create Worker threads on a per-request basis.** Thread creation and teardown is expensive (10--100ms). Workers must be pooled. If you find yourself constructing `new Worker(...)` inside a request handler, refactor to use a pre-initialized thread pool.19920011. **ALWAYS handle the `drain` event when manually writing to a Writable stream.** If `writable.write(chunk)` returns `false`, stop writing and resume only on `drain`. Ignoring this causes unbounded memory growth as Node.js buffers all the undelivered chunks.20120212. **NEVER assume `async/await` adds parallelism.** `await` suspends the current async function at that point -- it does NOT run operations in parallel. Parallelism requires either `Promise.all` or separate invocations that are not awaited sequentially.203204---205206## Edge Cases207208### Legacy Callback-Based Code Integration209210When integrating with Node.js core APIs or third-party libraries that use the `(err, result)` callback style, use `util.promisify`. For APIs with non-standard callback signatures (e.g., callbacks with multiple success arguments, or `(result, err)` reversed order), create a manual wrapper with `new Promise`. Never use `util.promisify` on callback APIs that emit multiple values over time (like `EventEmitter`) -- that pattern requires stream or async iterator conversion, not promisification.211212For `EventEmitter` patterns, use `events.once(emitter, 'eventName')` to await a single event, or `events.on(emitter, 'eventName')` to create an async iterable over all events. Always set a timeout or hook into the emitter's error event to avoid hanging forever if the expected event never fires.213214### Unintended Promise Floating (Fire and Forget)215216When you intentionally fire and forget (e.g., logging to an external service asynchronously without blocking the response), you MUST attach an error handler. `asyncOperation().catch(err => logger.error('background task failed', err))`. Without the `.catch`, a rejection becomes an unhandled rejection warning (Node.js 15+ throws by default, crashing the process). Document clearly in code comments that the fire-and-forget is intentional and that the error handler is the safety net.217218### Async Generator Cleanup and Early Exit219220When consuming an async generator with `for await...of` and breaking early, the generator's `finally` block executes -- but only if the generator object is properly returned. When the `for...of` loop breaks, JavaScript calls `generator.return()`, which triggers cleanup. However, if you convert the generator to an array (`Array.from`) or pass it to `Promise.all`, early exit cleanup does not happen automatically. In these cases, wrap the generator in a `try/finally` and manually call `generator.return()` on early exit.221222For async generators wrapped as streams via `Readable.from`, stream destruction (`stream.destroy()`) correctly propagates back and triggers the generator's `finally` block.223224### Combining AbortSignal with Promise.all225226`Promise.all` is not abort-aware by default. If one operation aborts, `Promise.all` rejects, but the other in-flight operations continue running until they naturally complete or fail. To truly cancel all operations in a `Promise.all` group on first failure or external abort, create a single `AbortController`, pass its signal to all operations, and call `controller.abort()` in a `.catch()` handler on the `Promise.all`. This implements "cancel all on first error" semantics similar to structured concurrency.227228```javascript229const controller = new AbortController();230const { signal } = controller;231232try {233 await Promise.all(234 operations.map(op => op(signal))235 );236} catch (err) {237 controller.abort(); // cancel remaining operations238 throw err;239}240```241242### Worker Thread Module Loading and Warm-Up243244Worker threads run in a fresh V8 context and must re-load and re-JIT all required modules. For workers that import large dependencies (e.g., image processing libraries, ML inference runtimes), the cold start can be 200--2000ms. Mitigate by:245- Pre-creating the worker pool at application startup, not at first use246- Sending a warm-up message that forces JIT compilation of hot code paths247- Using `workerData` to pass initialization config rather than sending it as the first task message248249If your Node.js version supports it (18.7+), use `--experimental-vm-modules` and the `Worker` `eval` option to share already-parsed module code.250251### Tick Starvation from Recursive Microtasks252253A recursive async pattern that schedules new microtasks before completing (e.g., `async function loop() { await processItem(); return loop(); }`) can starve the event loop of I/O callbacks because the microtask queue drains completely before each event loop phase. The event loop never reaches the poll phase to pick up new I/O events. Fix by yielding to the event loop periodically with `await new Promise(resolve => setImmediate(resolve))` every N iterations, where N is tuned to keep I/O latency acceptable (typically every 100--1000 iterations).254255### AsyncLocalStorage Across Worker Thread Boundaries256257`AsyncLocalStorage` context does NOT automatically propagate into `worker_threads`. Worker threads have their own independent async context. If you need request context (trace ID, request ID) inside a worker, serialize the relevant fields from `storage.getStore()` and pass them via `workerData` or `postMessage`. Inside the worker, reconstruct the context manually by storing the values in module-level variables or creating a new `AsyncLocalStorage` store with the received data.258259---260261## Example262263**Input from user:** "I need to process 50,000 user records from a database, enrich each one by calling an external REST API, and write the results to a file. The API has a rate limit of 100 requests per second and allows up to 20 concurrent connections. How do I structure this in Node.js?"264265---266267## Async Pattern Analysis268269### Problem Classification270- **Category:** I/O-bound parallel with concurrency cap + streaming output271- **Node.js Versions Supported:** 18+ (for `stream/promises`, `AbortSignal.timeout`, async iteration)272- **Key Constraint:** API rate limit (100 req/s) and connection limit (20 concurrent) prevent naive `Promise.all`; data volume (50k records) prevents full in-memory buffering273274### Pattern Decision Matrix275276| Pattern | Use When | Avoid When | Overhead |277|---------|----------|------------|----------|278| `Promise.all` over all 50k | all could run concurrently | rate/connection limited | catastrophic here |279| Sequential `for...of` | simplest, no concurrency | throughput too low (1 req at a time) | none but slow |280| `p-limit` (concurrency cap) | cap at 20 concurrent | time-rate limiting also needed | minimal |281| `p-throttle` (rate cap) | cap at 100/s | connection limit also needed | minimal |282| Combined semaphore + throttle | both connection and rate limits apply | neither applies | minimal |283| Readable stream → Transform → Writable | 50k rows, can't buffer all in memory | small dataset, buffering fine | low |284285### Recommended Implementation286287Use a streaming pipeline to read database records without buffering all 50k in memory, process each record through a Transform stream that applies concurrency and rate limiting, and write results to a file using `stream.pipeline`. The combination of a concurrency limiter (max 20 concurrent API calls) with a time-rate throttle (max 100/s) matches the API's constraints exactly.288289**Key decision:** Streaming pipeline over `Promise.all` because 50,000 records cannot be loaded into memory simultaneously (typical record + enriched result ≈ 2--10KB each = 100--500MB). Streaming keeps memory bounded to `highWaterMark × record size` ≈ a few MB.290291### Code Implementation292293```javascript294// Streaming enrichment pipeline with concurrency + rate limiting295// Node.js 18+ required296// Dependencies: p-limit, p-throttle (npm install p-limit p-throttle)297298import { pipeline, Transform } from 'node:stream';299import { pipeline as asyncPipeline } from 'node:stream/promises';300import { createWriteStream } from 'node:fs';301import pLimit from 'p-limit';302import pThrottle from 'p-throttle';303304const CONCURRENCY_LIMIT = 20; // max simultaneous open API connections305const RATE_LIMIT = 100; // max API calls per second306const RATE_INTERVAL = 1000; // 1 second window in ms307308// Compose both limits: a call must acquire a concurrency slot AND309// pass the throttle before firing.310const limit = pLimit(CONCURRENCY_LIMIT);311312const throttledFetch = pThrottle({313 limit: RATE_LIMIT,314 interval: RATE_INTERVAL,315})(async (userId, signal) => {316 const response = await fetch(317 `https://api.example.com/enrich/${userId}`,318 { signal }319 );320 if (!response.ok) {321 throw new Error(`API error ${response.status} for user ${userId}`);322 }323 return response.json();324});325326// Wrap with concurrency limiter so both constraints apply327async function enrichUser(record, signal) {328 return limit(() => throttledFetch(record.id, signal));329}330331// Transform stream: receives user record objects, outputs enriched JSON lines332class EnrichmentTransform extends Transform {333 constructor(signal) {334 super({ objectMode: true, highWaterMark: 50 }); // buffer max 50 objects335 this.signal = signal;336 this.pending = new Set();337 }338339 _transform(record, _encoding, callback) {340 // Check for cancellation at each record boundary341 if (this.signal.aborted) {342 callback(new Error('Pipeline aborted'));343 return;344 }345346 const enrichPromise = enrichUser(record, this.signal)347 .then(enriched => {348 const result = { ...record, ...enriched, enrichedAt: new Date().toISOString() };349 // Push as newline-delimited JSON350 if (!this.push(JSON.stringify(result) + '\n')) {351 // Backpressure: downstream is full, but Transform handles this via objectMode352 }353 })354 .catch(err => {355 // Decide: fail the whole pipeline, or log and skip?356 // Here: log and skip to maximize partial success357 console.error(`Failed to enrich user ${record.id}:`, err.message);358 this.push(JSON.stringify({ ...record, enrichmentError: err.message }) + '\n');359 })360 .finally(() => {361 this.pending.delete(enrichPromise);362 });363364 this.pending.add(enrichPromise);365366 // Do not await -- allow up to highWaterMark records to be in-flight367 // Signal ready for next record immediately (backpressure is via p-limit)368 callback();369 }370371 _flush(callback) {372 // Wait for all in-flight enrichments to complete before closing373 Promise.all(this.pending)374 .then(() => callback())375 .catch(callback);376 }377}378379// Database cursor as an async iterable (pseudo-code for your DB driver)380// Replace with your actual driver's streaming/cursor API:381// - pg: client.query(new Cursor('SELECT ...'))382// - MySQL2: connection.query('SELECT ...').stream()383// - MongoDB: collection.find({}).stream()384async function* databaseCursor(db) {385 const cursor = db.collection('users').find({}).batchSize(500);386 try {387 for await (const doc of cursor) {388 yield doc;389 }390 } finally {391 await cursor.close();392 }393}394395// Main pipeline396async function runEnrichmentPipeline(db) {397 // 5-minute overall timeout for the entire pipeline398 const signal = AbortSignal.timeout(5 * 60 * 1000);399400 const sourceStream = Readable.from(databaseCursor(db), {401 objectMode: true,402 highWaterMark: 100, // read ahead 100 records from DB cursor403 });404405 const enrichTransform = new EnrichmentTransform(signal);406407 const outputStream = createWriteStream('./enriched-users.ndjson', {408 flags: 'w',409 encoding: 'utf8',410 });411412 console.time('enrichment-pipeline');413414 try {415 await asyncPipeline(416 sourceStream,417 enrichTransform,418 outputStream419 );420 console.timeEnd('enrichment-pipeline');421 console.log('Pipeline complete');422 } catch (err) {423 if (err.name === 'AbortError' || err.name === 'TimeoutError') {424 console.error('Pipeline timed out after 5 minutes');425 } else {426 console.error('Pipeline failed:', err);427 }428 throw err;429 }430}431```432433### Pitfalls to Avoid434435- **Do NOT** use `Promise.all(allRecords.map(enrichUser))` -- this fires 50,000 API requests simultaneously, saturates the connection pool, likely triggers API bans, and buffers all results in memory.436- **Do NOT** use sequential `for await` without the concurrency limiter -- it processes 1 record at a time, achieving roughly 1 req/s instead of the allowed 100 req/s, making the job ~100x slower than necessary.437- **Do NOT** call `callback()` inside `_transform` after `callback(error)` -- calling the Transform callback twice corrupts the stream state. Use early `return` after `callback(error)`.438- **Do NOT** forget `_flush` -- without it, in-flight enrichments that haven't pushed their results yet are dropped when the source stream ends.439- **Do NOT** ignore backpressure signals from the output file write stream -- `asyncPipeline` handles this, but if you replace it with manual `.pipe()` calls, you must handle `drain` events.440441### Testing Approach442443- **Unit test `enrichUser`** with a mocked `fetch` that records call timing. Assert that no more than 20 calls are in-flight simultaneously (use a counter incremented on call start, decremented on completion).444- **Test rate limiting** by recording timestamps of all mock API calls and asserting no 1-second window contains more than 100 calls.445- **Test the timeout** by injecting an `AbortSignal` that is pre-aborted (`AbortSignal.abort()`) and asserting the pipeline rejects with an `AbortError` rather than hanging.446- **Test `_flush` completion** by using a slow mock API (delayed with `setTimeout`) and asserting the output file contains results for all records that were started before the source stream ended.447- **Test partial failure handling** by having the mock API reject for specific user IDs and asserting those records appear in the output with `enrichmentError` set rather than crashing the pipeline.448- **Integration test** against a real or Docker-based instance of your database and a local mock API server (`nock` or `msw` in server mode) to validate end-to-end throughput approaches 100 req/s.