| name | nodejs-async-patterns |
| description | 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`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"javascript backend optimization","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
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
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;
const RATE_LIMIT = 100;
const RATE_INTERVAL = 1000;
const limit = pLimit(CONCURRENCY_LIMIT);
const throttledFetch = pThrottle({
limit: RATE_LIMIT,
interval: RATE_INTERVAL,
})(async (userId, signal) => {
const response = await (
,
{ signal }
);
(!response.) {
();
}
response.();
});
() {
( (record., signal));
}
{
() {
({ : , : });
. = signal;
. = ();
}
() {
(..) {
( ());
;
}
enrichPromise = (record, .)
.( {
result = { ...record, ...enriched, : ().() };
(!.(.(result) + )) {
}
})
.( {
.(, err.);
.(.({ ...record, : err. }) + );
})
.( {
..(enrichPromise);
});
..(enrichPromise);
();
}
() {
.(.)
.( ())
.(callback);
}
}
* () {
cursor = db.().({}).();
{
( doc cursor) {
doc;
}
} {
cursor.();
}
}
() {
signal = .( * * );
sourceStream = .((db), {
: ,
: ,
});
enrichTransform = (signal);
outputStream = (, {
: ,
: ,
});
.();
{
(
sourceStream,
enrichTransform,
outputStream
);
.();
.();
} (err) {
(err. === || err. === ) {
.();
} {
.(, err);
}
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.