| name | async-flow-control |
| description | Async flow control patterns - AbortController cancellation, async iterators for large datasets, worker thread selection, Promise combinator choice, stream backpressure in async pipelines |
| keywords | AbortController, AbortSignal, cancellation, async iterator, async generator, cursor pagination, stream processing, worker_threads, child_process, cluster, Promise.allSettled, Promise.all, concurrency, backpressure, p-limit, p-queue, drain, pipeline |
| related | error-handling-patterns, process-lifecycle-patterns, state-machine-workflow-patterns |
Async Flow Control Patterns
Rules for writing async code that cancels cleanly, streams large datasets without OOM, and selects the right concurrency primitive.
Research: docs/deepresearch/reports/Async Patterns and Flow Control in Node.js.md
Non-Negotiable Rules
| # | Rule | Violation Example | Correct Pattern |
|---|
| 1 | AbortSignal on long-running ops | await fetch(url) with no timeout | await fetch(url, { signal }) |
| 2 | Cursor-stream large queries | await db.findMany() on 10K+ rows | Async generator with cursor pagination |
| 3 | allSettled for independent ops | Promise.all([fetchA(), fetchB()]) where partial OK | Promise.allSettled([fetchA(), fetchB()]) |
| 4 | Bound concurrency | Promise.all(items.map(fn)) unbounded | Promise.all(items.map(x => limit(() => fn(x)))) |
| 5 | pipeline() for streams | readable.pipe(writable) (no error propagation) | await pipeline(readable, transform, writable) |
| 6 | CPU work in worker threads | Heavy computation on main event loop | worker_threads pool for CPU, child_process for isolation |
| 7 | try/finally on async iterators | Async generator without cleanup | try { yield* } finally { cursor.close() } |
| 8 | Respect drain on writes | Ignore write() return value | Pause readable on false, resume on drain |
| 9 | Graceful shutdown on SIGTERM | Process exits mid-request | Drain in-flight, stop accepting, then exit |
| 10 | No .on() in request handlers | emitter.on('x', fn) per request | emitter.once('x', fn) or explicit .off() cleanup |
Promise Combinator Selection
const [user, posts, analytics] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchAnalytics(id),
]);
const results = await Promise.allSettled([
fetchUser(id),
fetchPosts(id),
fetchAnalytics(id),
]);
const [user, posts, analytics] = results.map((r, i) =>
r.status === 'fulfilled'
? r.value
: (() => { logger.warn({ source: sources[i], err: r.reason }, 'Partial fetch failed'); return null; })()
);
| Method | Use When | Failure Behavior |
|---|
Promise.all | All MUST succeed (transaction parts, required data) | First rejection cancels all |
Promise.allSettled | Independent tasks, partial success OK (dashboard, batch) | Collects all results |
Promise.any | First success wins (redundant services, failover) | Rejects only if ALL fail |
Promise.race | First to settle wins (timeout race) | Losers keep running (use AbortController to cancel) |
for...of sequential | Order matters, or need backpressure | Process one at a time |
Rule: Default to allSettled for multi-source fetches. Use all only when every result is required for correctness.
AbortController & Cancellation
const result = await Promise.race([
fetch(url),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)),
]);
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function processTask(taskId: string, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted();
const data = await fetch(`/api/tasks/${taskId}`, { signal });
signal?.throwIfAborted();
await db.query(sql`UPDATE tasks SET status = 'processing' WHERE id = ${taskId}`);
signal?.throwIfAborted();
await sendNotification(taskId, { signal });
}
const controller = new AbortController();
controller.signal.addEventListener('abort', () => {
tempFile.cleanup();
dbConnection.release();
});
Where to accept AbortSignal:
- HTTP requests (
fetch, axios, got)
- Database queries (via query timeout or connection pool)
- File system operations (
fs.readFile in Node 20+)
- Any function that does I/O or takes >100ms
Rule: Every long-running operation must accept an optional AbortSignal. Call signal.throwIfAborted() at safe points between steps. Clean up resources (temp files, connections) on abort.
Async Iterators for Large Datasets
const users = await prisma.user.findMany();
for (const user of users) {
await processUser(user);
}
async function* getUsersByCursor(batchSize = 100): AsyncGenerator<User> {
let cursor: string | undefined;
try {
while (true) {
const page = await prisma.user.findMany({
take: batchSize,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
orderBy: { id: 'asc' },
});
if (page.length === 0) break;
for (const user of page) yield user;
cursor = page[page.length - 1].id;
}
} finally {
logger.debug('User cursor iteration complete');
}
}
for await (const user of getUsersByCursor()) {
await processUser(user);
}
import QueryStream from 'pg-query-stream';
const stream = client.query(new QueryStream('SELECT * FROM users ORDER BY id'));
for await (const row of stream) {
await processRow(row);
}
When to use async iterators:
- Any query that may return >10K rows
- Processing files line by line
- Consuming paginated external APIs
- Redis SCAN operations
Rule: For any dataset of unknown or large size, use cursor pagination with an async generator or Node streams. Always wrap in try/finally for cleanup. Never call .findMany() or equivalent without a LIMIT on unbounded tables.
Worker Thread vs Child Process Selection
import { Worker } from 'worker_threads';
const worker = new Worker('./hash-worker.js', {
workerData: { items: largeArray },
});
worker.on('message', (result) => { });
worker.on('error', (err) => { logger.error({ err }, 'Worker failed'); });
import { fork } from 'child_process';
const child = fork('./plugin-runner.js', [], {
execArgv: ['--max-old-space-size=256'],
});
| Mechanism | Use Case | Memory Model | Overhead |
|---|
worker_threads | CPU-heavy (hashing, parsing, image) | Shared ArrayBuffer possible | Low (same process) |
child_process | Isolation, non-Node, untrusted plugins | Separate V8 heap | Medium (fork) |
cluster / PM2 | Horizontal I/O scaling | Separate processes | High (full copy) |
| Main thread | I/O-bound work (HTTP, DB, Redis) | Single V8 heap | None |
Rule: CPU-intensive work (>50ms of computation) must run in a worker_threads pool, not on the main event loop. Use child_process only when memory isolation is required. Keep the main thread for I/O coordination.
Graceful Shutdown
process.on('SIGTERM', () => process.exit(0));
let shuttingDown = false;
process.on('SIGTERM', async () => {
if (shuttingDown) return;
shuttingDown = true;
logger.info('Graceful shutdown initiated');
server.close();
const drainTimeout = setTimeout(() => {
logger.warn('Drain timeout reached, forcing exit');
process.exit(1);
}, 30_000);
drainTimeout.unref();
await Promise.allSettled(inFlightRequests);
clearTimeout(drainTimeout);
await db.end();
await redis.quit();
process.exit(0);
});
Shutdown order:
- Stop accepting new connections/work
- Drain in-flight requests (with timeout)
- Close database connections, Redis, external clients
- Exit cleanly
Rule: Every long-running process must handle SIGTERM with a graceful drain. Never call process.exit() without first draining in-flight work. Set a drain timeout (30s default) to prevent hanging. See [[process-lifecycle-patterns]] for production-grade shutdown with resource cleanup ordering and health probe integration.
Event Emitter Lifecycle
app.post('/events', (req, res) => {
bus.on('update', (data) => { });
});
app.post('/events', (req, res) => {
bus.once('update', (data) => { });
});
function subscribe(emitter: EventEmitter, event: string, handler: Function): () => void {
emitter.on(event, handler);
return () => emitter.off(event, handler);
}
const unsub = subscribe(bus, 'update', handler);
unsub();
Rule: Every .on() in a request handler or loop must have a corresponding .off() or use .once(). If MaxListenersExceededWarning appears, there is a leak -- fix it, do not raise setMaxListeners.
Stream Pipeline Discipline
readable.pipe(transform).pipe(writable);
import { pipeline } from 'stream/promises';
await pipeline(readable, transform, writable);
readable.on('data', (chunk) => {
const canWrite = writable.write(chunk);
if (!canWrite) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});
Rule: Never use .pipe() in production. Always use pipeline() from stream/promises which handles error propagation, backpressure, and cleanup automatically.
Common Anti-Patterns Checklist
Before submitting async code, verify:
See Also
[[error-handling-patterns]] - Error classification, retry discipline, fire-and-forget safety
nodejs-memory-management - Closure retention, buffer pooling, WeakRef caches, V8 heap tuning
resilience-patterns - Circuit breakers, auto-healing, TaskReaper recovery
race-conditions-atomicity - Distributed locking, SKIP LOCKED, atomic state transitions