at accepts an executor function. The executor runs synchronously and receives two control functions: resolve for successful completion and reject for failure states.
interface TransactionResult {
transactionId: string;
status: 'completed' | 'failed';
timestamp: number;
}
function initiatePaymentGateway(merchantId: string): Promise<TransactionResult> {
return new Promise((resolve, reject) => {
console.log(`[Gateway] Initializing session for merchant ${merchantId}`);
setTimeout(() => {
if (merchantId.startsWith('M-')) {
resolve({
transactionId: `TXN-${Date.now()}`,
status: 'completed',
timestamp: Date.now()
});
} else {
reject(new Error(`[Gateway] Invalid merchant identifier: ${merchantId}`));
}
}, 800);
});
}
Architecture Decision: The constructor executes synchronously. This means logging, validation, and timer registration happen immediately on the call stack. Only the resolve/reject callbacks are deferred to the microtask queue. This separation guarantees that promise initialization never blocks the main thread while still providing immediate feedback.
Step 2: Chain Values with .then()
Each .then() invocation returns a new promise. To maintain data flow, you must explicitly return a value or another promise from the callback. Returning a promise automatically unwraps it and passes the resolved value to the next handler.
function validateInventory(productId: string): Promise<number> {
return new Promise((resolve) => {
setTimeout(() => resolve(Math.floor(Math.random() * 50) + 1), 600);
});
}
function processPayment(amount: number, gatewayResult: TransactionResult): Promise<boolean> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (amount > 0 && gatewayResult.status === 'completed') {
resolve(true);
} else {
reject(new Error('[Payment] Insufficient funds or invalid gateway state'));
}
}, 1000);
});
}
function generateReceipt(paymentSuccess: boolean, txnId: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Receipt #${txnId} generated at ${new Date().toISOString()}`);
}, 400);
});
}
Step 3: Execute the Linear Chain
The chain demonstrates how values flow downward while errors bubble upward to a single handler.
initiatePaymentGateway('M-7842')
.then((gatewayData) => {
console.log(`[Step 1] Gateway initialized: ${gatewayData.transactionId}`);
return validateInventory('PROD-991');
})
.then((stockCount) => {
console.log(`[Step 2] Inventory available: ${stockCount} units`);
return processPayment(149.99, { transactionId: 'TXN-INIT', status: 'completed', timestamp: Date.now() });
})
.then((paymentConfirmed) => {
console.log(`[Step 3] Payment processed: ${paymentConfirmed}`);
return generateReceipt(paymentConfirmed, 'TXN-7842-001');
})
.then((receipt) => {
console.log(`[Step 4] ${receipt}`);
})
.catch((error) => {
console.error(`[Pipeline Failure] ${error.message}`);
})
.finally(() => {
console.log('[Cleanup] Releasing gateway lock and clearing UI indicators');
});
Architecture Rationale:
.then() handlers are strictly for value transformation. Each returns a promise or primitive that feeds the next step.
.catch() intercepts any rejection from the entire chain. This eliminates repetitive error checks and centralizes failure logging.
.finally() executes regardless of resolution state. It is reserved exclusively for side-effect cleanup (UI state resets, connection pool releases, telemetry flushes) and must never alter the chain's resolved value.
Pitfall Guide
1. Dropped Return Values in Chains
Explanation: Forgetting to return inside a .then() callback breaks value propagation. The next handler receives undefined, causing downstream logic to fail silently or throw type errors.
Fix: Always explicitly return the next promise or transformed value. Use arrow functions with implicit returns for single-expression handlers.
2. Silent Error Swallowing
Explanation: Omitting .catch() or placing it incorrectly allows rejections to go unhandled. In modern runtimes, this triggers unhandledrejection events and can crash Node.js processes or leave browser tabs in inconsistent states.
Fix: Attach .catch() to the terminal of every chain. In library code, consider wrapping chains in a utility that logs and re-throws or returns a standardized error object.
3. Mixing Callbacks and Promises
Explanation: Nesting callback-based APIs inside promise chains creates hybrid control flow that defeats the purpose of promises. Error handling becomes fragmented, and stack traces lose context.
Fix: Wrap legacy callback APIs using new Promise() or util.promisify before chaining. Never mix callback(err, data) patterns directly inside .then() blocks.
4. Misunderstanding Constructor Synchronicity
Explanation: Developers often assume the promise executor runs asynchronously. In reality, the code inside new Promise((resolve, reject) => { ... }) executes immediately on the call stack. Only resolve/reject invocations schedule microtasks.
Fix: Treat the executor as synchronous setup code. Defer I/O, timers, or network calls explicitly. Do not rely on the constructor to pause execution.
5. Overloading .finally() with Business Logic
Explanation: .finally() does not receive the resolved value or rejection reason. Attempting to perform data transformations or conditional logic inside it breaks the chain's data flow and introduces unpredictable state.
Fix: Restrict .finally() to cleanup operations. If you need to inspect values before cleanup, handle them in .then() and .catch(), then call a shared cleanup function.
6. Unhandled Rejections in Loops
Explanation: Creating promises inside for or forEach loops without awaiting or collecting them results in fire-and-forget execution. Errors surface asynchronously and are difficult to trace back to the originating iteration.
Fix: Use Promise.all() for parallel execution or for...of with await for sequential processing. Always attach error handlers to loop-generated promises.
7. Chaining Without Propagating Errors
Explanation: Returning a rejected promise inside a .then() block without a subsequent .catch() causes the rejection to propagate, but developers sometimes accidentally return a resolved promise that masks the error.
Fix: Use .catch() to transform errors into fallback values only when explicitly required. Otherwise, let rejections bubble naturally to the terminal handler.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Independent I/O operations (e.g., fetch user, fetch settings) | Promise.all() | Executes concurrently, reduces total latency to max(single) | Lower latency, higher memory overhead |
| Sequential dependent operations (e.g., auth β fetch β process) | .then() chain or async/await | Guarantees execution order, propagates values safely | Predictable memory, linear latency |
| Fallback or retry logic | .catch() with conditional re-throw | Centralizes error recovery without duplicating control flow | Minimal overhead, improves resilience |
| Legacy callback integration | util.promisify or manual wrapper | Standardizes interface, prevents hybrid control flow bugs | One-time migration cost, long-term maintainability |
| High-volume parallel requests | Promise.allSettled() | Prevents single failure from aborting entire batch | Slightly higher memory, guarantees completion visibility |
Configuration Template
// promise-pipeline.ts
// Production-ready promise utility with timeout, standardized errors, and cleanup hooks
interface PipelineOptions {
timeoutMs?: number;
onError?: (error: Error) => void;
onFinally?: () => void;
}
class AsyncPipeline {
private static timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Pipeline timeout after ${ms}ms`)), ms);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
static execute<T>(
steps: Array<() => Promise<T>>,
options: PipelineOptions = {}
): Promise<T> {
const { timeoutMs = 5000, onError, onFinally } = options;
let chain = Promise.resolve() as Promise<any>;
for (const step of steps) {
chain = chain.then(step);
}
const finalChain = timeoutMs > 0 ? AsyncPipeline.timeout(chain, timeoutMs) : chain;
return finalChain
.catch((error) => {
onError?.(error);
throw error;
})
.finally(() => {
onFinally?.();
});
}
}
export default AsyncPipeline;
Quick Start Guide
- Define your async steps as promise-returning functions. Each function should accept required inputs and return a
Promise that resolves with the next step's input or rejects with a descriptive Error.
- Chain the steps using
.then(). Ensure each handler returns the next promise. Avoid nesting; keep the chain flat and linear.
- Attach a terminal
.catch() to handle failures from any step. Log contextual data and map internal errors to user-facing messages if necessary.
- Add
.finally() for cleanup. Use it to reset UI states, release locks, or flush metrics. Do not return values or alter chain state here.
- Test with controlled delays and forced rejections. Verify that success paths propagate values correctly and failure paths route to
.catch() without leaving dangling promises or unhandled rejections.