Understanding JavaScript Promise Execution Order
Promise execution order is one of the most confusing aspects of JavaScript's asynchronous model. Even experienced engineers struggle to predict the exact sequence when multiple Promises, callbacks, and async operations interleave. This deep dive explores the internal mechanics that determine execution order, the spec-mandated algorithms that engines follow, and the production pitfalls that emerge when assumptions about ordering prove wrong.
Scale Context
| Dimension | Production Scale |
|---|---|
| Active Promise chains | 1,000-1,000,000 |
| Promise.all batch size | 10-10,000 |
| Nested .then() depth | 1-50 |
| Promise creations/second | 10,000-1,000,000 |
| Rejection rate | 0.1%-5% |
| Race conditions from ordering | 1-100/day |
| Unhandled rejection errors | 10-1,000/day |
| Promise memory overhead | 40-120 bytes each |
| Microtask queue depth | 100-100,000 |
| Promise resolution latency | <1ms-100ms |
GIF via GIPHY
High-Level Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Promise State Machine │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PENDING │ │
│ │ │ │
│ │ • Reactions queued but not executed │ │
│ │ • Value/reason not yet determined │ │
│ │ • Can transition to FULFILLED or REJECTED │ │
│ │ │ │
│ └───────────────────────┬───────────────────────┬─────────────────────┘ │
│ │ │ │
│ resolve(value)│ │reject(reason) │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ FULFILLED │ │ REJECTED │ │
│ │ │ │ │ │
│ │ • Value is settled │ │ • Reason is settled │ │
│ │ • .then() handlers queued │ │ • .catch() handlers queued │ │
│ │ as microtasks │ │ as microtasks │ │
│ │ • State is immutable │ │ • State is immutable │ │
│ │ │ │ │ │
│ └──────────────────────────────┘ └──────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Promise Reaction Job Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Promise A Promise B (.then result) │
│ ┌─────────┐ ┌─────────┐ │
│ │ settled │ │ pending │ │
│ │ value:5 │ │ │ │
│ └────┬────┘ └────▲────┘ │
│ │ │ │
│ │ A.then(handler) │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Microtask Queue │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ PromiseReactionJob │ │ │
│ │ │ │ │ │
│ │ │ 1. Get handler from reaction │ │ │
│ │ │ 2. Call handler with value (5) │ │ │
│ │ │ 3. If handler returns: resolve Promise B with return value │ │ │
│ │ │ 4. If handler throws: reject Promise B with error │ │ │
│ │ │ 5. If handler returns Promise: adopt its state │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
GIF via GIPHY
Promise Internal State
// Internal Promise representation (spec-aligned)
interface PromiseInternals<T> {
// [[PromiseState]]: "pending" | "fulfilled" | "rejected"
state: PromiseState;
// [[PromiseResult]]: the value or reason
result: T | unknown | undefined;
// [[PromiseFulfillReactions]]: list of reactions for fulfillment
fulfillReactions: PromiseReaction<T>[];
// [[PromiseRejectReactions]]: list of reactions for rejection
rejectReactions: PromiseReaction<T>[];
// [[PromiseIsHandled]]: whether rejection has been observed
isHandled: boolean;
}
type PromiseState = 'pending' | 'fulfilled' | 'rejected';
// Reaction records - queued handlers
interface PromiseReaction<T> {
// [[Capability]]: The promise capability for the derived promise
capability: PromiseCapability<unknown>;
// [[Type]]: "Fulfill" or "Reject"
type: 'fulfill' | 'reject';
// [[Handler]]: The handler function or undefined
handler: ((value: T) => unknown) | undefined;
}
// Promise capability - represents a deferred promise
interface PromiseCapability<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason: unknown) => void;
}
// Full Promise implementation following spec
class PromiseImpl<T> {
private internal: PromiseInternals<T>;
constructor(executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason: unknown) => void
) => void) {
// Initialize internal slots
this.internal = {
state: 'pending',
result: undefined,
fulfillReactions: [],
rejectReactions: [],
isHandled: false,
};
// Create resolving functions
const { resolve, reject } = this.createResolvingFunctions();
// Execute the executor synchronously
try {
executor(resolve, reject);
} catch (error) {
// If executor throws, reject the promise
reject(error);
}
}
private createResolvingFunctions(): {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason: unknown) => void;
} {
let alreadyResolved = false;
const resolve = (value: T | PromiseLike<T>): void => {
if (alreadyResolved) return;
alreadyResolved = true;
// If resolving with self, reject with TypeError
if (value === (this as unknown)) {
this.rejectPromise(new TypeError('Cannot resolve promise with itself'));
return;
}
// If value is a thenable, adopt its state
if (this.isThenable(value)) {
this.resolveWithThenable(value as PromiseLike<T>);
return;
}
// Otherwise, fulfill with the value
this.fulfillPromise(value as T);
};
const reject = (reason: unknown): void => {
if (alreadyResolved) return;
alreadyResolved = true;
this.rejectPromise(reason);
};
return { resolve, reject };
}
private isThenable(value: unknown): value is PromiseLike<unknown> {
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
return false;
}
return typeof (value as any).then === 'function';
}
private resolveWithThenable(thenable: PromiseLike<T>): void {
// Create a PromiseResolveThenableJob and enqueue it
const job = (): void => {
const { resolve, reject } = this.createResolvingFunctions();
try {
thenable.then(resolve, reject);
} catch (error) {
reject(error);
}
};
// IMPORTANT: This is a separate job, not a reaction job
// It goes to the microtask queue
queueMicrotask(job);
}
private fulfillPromise(value: T): void {
if (this.internal.state !== 'pending') return;
// Capture reactions before clearing
const reactions = this.internal.fulfillReactions;
// Update state
this.internal.state = 'fulfilled';
this.internal.result = value;
this.internal.fulfillReactions = [];
this.internal.rejectReactions = [];
// Trigger reactions
this.triggerReactions(reactions, value);
}
private rejectPromise(reason: unknown): void {
if (this.internal.state !== 'pending') return;
const reactions = this.internal.rejectReactions;
this.internal.state = 'rejected';
this.internal.result = reason;
this.internal.fulfillReactions = [];
this.internal.rejectReactions = [];
// Track unhandled rejection
if (!this.internal.isHandled) {
this.trackUnhandledRejection(reason);
}
this.triggerReactions(reactions, reason);
}
private triggerReactions(
reactions: PromiseReaction<T>[],
value: unknown
): void {
for (const reaction of reactions) {
// Queue each reaction as a microtask
const job = this.createReactionJob(reaction, value);
queueMicrotask(job);
}
}
private createReactionJob(
reaction: PromiseReaction<T>,
value: unknown
): () => void {
return () => {
const { capability, handler, type } = reaction;
if (handler === undefined) {
// No handler - propagate value/reason
if (type === 'fulfill') {
capability.resolve(value as any);
} else {
capability.reject(value);
}
return;
}
// Execute handler
let result: unknown;
try {
result = handler(value as T);
capability.resolve(result as any);
} catch (error) {
capability.reject(error);
}
};
}
then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null
): PromiseImpl<TResult1 | TResult2> {
// Mark as handled to prevent unhandled rejection warnings
this.internal.isHandled = true;
// Create capability for the derived promise
const capability = this.newPromiseCapability<TResult1 | TResult2>();
// Create fulfill reaction
const fulfillReaction: PromiseReaction<T> = {
capability,
type: 'fulfill',
handler: typeof onFulfilled === 'function' ? onFulfilled : undefined,
};
// Create reject reaction
const rejectReaction: PromiseReaction<T> = {
capability,
type: 'reject',
handler: typeof onRejected === 'function' ? onRejected : undefined,
};
// Handle based on current state
if (this.internal.state === 'pending') {
// Queue reactions for later
this.internal.fulfillReactions.push(fulfillReaction);
this.internal.rejectReactions.push(rejectReaction);
} else if (this.internal.state === 'fulfilled') {
// Queue fulfill reaction as microtask
const job = this.createReactionJob(fulfillReaction, this.internal.result);
queueMicrotask(job);
} else {
// Queue reject reaction as microtask
const job = this.createReactionJob(rejectReaction, this.internal.result);
queueMicrotask(job);
}
return capability.promise;
}
catch<TResult = never>(
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null
): PromiseImpl<T | TResult> {
return this.then(undefined, onRejected);
}
finally(onFinally?: (() => void) | null): PromiseImpl<T> {
const thenFinally = typeof onFinally === 'function'
? (value: T): T | PromiseLike<T> => {
const result = onFinally();
if (this.isThenable(result)) {
return (result as PromiseLike<unknown>).then(() => value);
}
return value;
}
: undefined;
const catchFinally = typeof onFinally === 'function'
? (reason: unknown): never => {
const result = onFinally();
if (this.isThenable(result)) {
return (result as PromiseLike<unknown>).then(() => {
throw reason;
}) as never;
}
throw reason;
}
: undefined;
return this.then(thenFinally, catchFinally) as PromiseImpl<T>;
}
private newPromiseCapability<U>(): PromiseCapability<U> {
let resolve!: (value: U | PromiseLike<U>) => void;
let reject!: (reason: unknown) => void;
const promise = new PromiseImpl<U>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
private trackUnhandledRejection(reason: unknown): void {
// Queue a check for unhandled rejection
queueMicrotask(() => {
if (!this.internal.isHandled) {
console.error('Unhandled promise rejection:', reason);
// In browsers: dispatch 'unhandledrejection' event
// In Node.js: emit 'unhandledRejection' event
}
});
}
// Static methods
static resolve<T>(value: T | PromiseLike<T>): PromiseImpl<T> {
if (value instanceof PromiseImpl) {
return value;
}
return new PromiseImpl(resolve => resolve(value));
}
static reject<T = never>(reason: unknown): PromiseImpl<T> {
return new PromiseImpl((_, reject) => reject(reason));
}
}
GIF via GIPHY
Execution Order Rules
// Rule-based execution order predictor
class ExecutionOrderPredictor {
private executionLog: string[] = [];
private ruleViolations: RuleViolation[] = [];
// Rule 1: Synchronous code runs first
demonstrateRule1(): void {
this.log('1. sync start');
Promise.resolve().then(() => {
this.log('3. promise then');
});
this.log('2. sync end');
// Output: 1, 2, 3
// Rule: All synchronous code completes before any microtask
}
// Rule 2: Promise.resolve().then() is always async
demonstrateRule2(): void {
this.log('1. sync start');
// Even though Promise.resolve() creates an already-fulfilled promise,
// .then() handler is ALWAYS queued as a microtask
Promise.resolve('immediate').then(value => {
this.log(`3. ${value}`);
});
this.log('2. sync end');
// Output: 1, 2, 3
// Rule: .then() handlers NEVER run synchronously
}
// Rule 3: Microtasks are FIFO within the same checkpoint
demonstrateRule3(): void {
this.log('1. sync');
Promise.resolve().then(() => this.log('2. first then'));
Promise.resolve().then(() => this.log('3. second then'));
Promise.resolve().then(() => this.log('4. third then'));
queueMicrotask(() => this.log('5. queueMicrotask'));
// Output: 1, 2, 3, 4, 5
// Rule: Microtasks execute in the order they were queued
}
// Rule 4: Chained .then() creates sequential microtasks
demonstrateRule4(): void {
this.log('1. sync');
Promise.resolve()
.then(() => this.log('3. first in chain'))
.then(() => this.log('5. second in chain'))
.then(() => this.log('6. third in chain'));
Promise.resolve()
.then(() => this.log('4. separate chain'));
this.log('2. sync end');
// Output: 1, 2, 3, 4, 5, 6
// Rule: Each .then() in a chain queues the NEXT one after executing
}
// Rule 5: Returning a Promise from .then() creates an extra microtask
demonstrateRule5(): void {
this.log('1. sync');
Promise.resolve()
.then(() => {
this.log('3. outer then');
return Promise.resolve('inner');
})
.then(value => {
this.log(`6. after inner: ${value}`);
});
Promise.resolve().then(() => this.log('4. parallel then 1'));
Promise.resolve().then(() => this.log('5. parallel then 2'));
this.log('2. sync end');
// Output: 1, 2, 3, 4, 5, 6
// Rule: Returning Promise causes PromiseResolveThenableJob (extra microtask)
}
// Rule 6: await creates microtask boundary
async demonstrateRule6(): Promise<void> {
this.log('1. async start');
const value = await Promise.resolve('awaited');
this.log(`3. after await: ${value}`);
this.log('4. async end');
}
// Call site:
// this.log('0. before call');
// this.demonstrateRule6();
// this.log('2. after call');
// Output: 0, 1, 2, 3, 4
// Rule: await suspends and queues continuation as microtask
// Rule 7: Multiple awaits create sequential microtasks
async demonstrateRule7(): Promise<void> {
this.log('start');
await Promise.resolve(); // microtask 1
this.log('after first await');
await Promise.resolve(); // microtask 2
this.log('after second await');
await Promise.resolve(); // microtask 3
this.log('after third await');
}
// Rule 8: Promise.all resolves when all promises resolve
async demonstrateRule8(): Promise<void> {
this.log('1. sync');
Promise.all([
Promise.resolve().then(() => this.log('3. all[0]')),
Promise.resolve().then(() => this.log('4. all[1]')),
Promise.resolve().then(() => this.log('5. all[2]')),
]).then(() => this.log('7. all complete'));
Promise.resolve().then(() => this.log('6. parallel'));
this.log('2. sync end');
// Output: 1, 2, 3, 4, 5, 6, 7
// Rule: Promise.all completion queued after all input promises resolve
}
// Rule 9: Promise.race resolves with first settled promise
async demonstrateRule9(): Promise<void> {
this.log('1. sync');
const p1 = new Promise<string>(resolve =>
setTimeout(() => resolve('slow'), 100)
);
const p2 = Promise.resolve('fast');
Promise.race([p1, p2]).then(value => {
this.log(`3. race winner: ${value}`);
});
this.log('2. sync end');
// Output: 1, 2, 3 (with value 'fast')
// Rule: First settled promise triggers race resolution
}
// Rule 10: Rejection propagates until caught
demonstrateRule10(): void {
this.log('1. sync');
Promise.reject(new Error('failure'))
.then(() => this.log('never runs'))
.then(() => this.log('also never runs'))
.catch(err => this.log(`3. caught: ${err.message}`))
.then(() => this.log('4. after catch'));
this.log('2. sync end');
// Output: 1, 2, 3, 4
// Rule: Rejection skips .then() handlers until .catch()
}
private log(message: string): void {
this.executionLog.push(message);
console.log(message);
}
}
interface RuleViolation {
rule: number;
expected: string;
actual: string;
}
GIF via GIPHY
Complex Ordering Scenarios
// Advanced scenarios that commonly cause confusion
class ComplexOrderingScenarios {
// Scenario 1: Nested Promise resolution timing
async nestedPromiseTiming(): Promise<void> {
console.log('A');
new Promise<void>(resolve => {
console.log('B');
resolve();
}).then(() => {
console.log('C');
});
new Promise<void>(resolve => {
console.log('D');
resolve();
console.log('E');
}).then(() => {
console.log('F');
});
console.log('G');
// Output: A, B, D, E, G, C, F
// Why: Executor runs sync, .then() is async
}
// Scenario 2: Promise returning Promise
async promiseReturningPromise(): Promise<void> {
console.log('1');
Promise.resolve()
.then(() => {
console.log('2');
return Promise.resolve();
})
.then(() => {
console.log('5');
});
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
// Output: 1, 2, 3, 4, 5
// Why: Returning Promise.resolve() adds extra microtask tick
}
// Scenario 3: await vs .then() interleaving
async awaitVsThenInterleaving(): Promise<void> {
const asyncFn = async () => {
console.log('A1');
await Promise.resolve();
console.log('A2');
await Promise.resolve();
console.log('A3');
};
console.log('S1');
asyncFn();
Promise.resolve()
.then(() => console.log('P1'))
.then(() => console.log('P2'))
.then(() => console.log('P3'));
console.log('S2');
// Output: S1, A1, S2, A2, P1, A3, P2, P3
// Why: await and .then() chains interleave in microtask queue
}
// Scenario 4: Promise.all with async mapping
async promiseAllAsyncMap(): Promise<void> {
const items = [1, 2, 3];
console.log('start');
await Promise.all(
items.map(async (item) => {
console.log(`begin ${item}`);
await Promise.resolve();
console.log(`end ${item}`);
})
);
console.log('done');
// Output: start, begin 1, begin 2, begin 3, end 1, end 2, end 3, done
// Why: All async functions start sync until first await, then interleave
}
// Scenario 5: Mixed setTimeout and Promise
mixedMacroMicro(): void {
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
console.log('4');
}, 0);
Promise.resolve().then(() => {
console.log('5');
setTimeout(() => console.log('6'), 0);
console.log('7');
});
console.log('8');
// Output: 1, 8, 5, 7, 2, 4, 3, 6
// Why: Microtasks drain before macrotasks; new microtasks in same checkpoint
}
// Scenario 6: Promise.resolve vs new Promise
resolveVsNew(): void {
console.log('1');
Promise.resolve().then(() => console.log('2'));
new Promise<void>(resolve => {
console.log('3');
resolve();
}).then(() => console.log('4'));
Promise.resolve().then(() => console.log('5'));
console.log('6');
// Output: 1, 3, 6, 2, 4, 5
// Why: new Promise executor runs sync; all .then() handlers queue in order
}
// Scenario 7: Multiple await in sequence vs parallel
async sequentialVsParallel(): Promise<void> {
const delay = (ms: number, label: string) =>
new Promise<string>(resolve =>
setTimeout(() => {
console.log(`resolved: ${label}`);
resolve(label);
}, ms)
);
// Sequential - each await waits for previous
console.log('sequential start');
const s1 = await delay(100, 's1');
const s2 = await delay(100, 's2');
console.log(`sequential done: ${s1}, ${s2}`);
// Parallel - all start immediately
console.log('parallel start');
const [p1, p2] = await Promise.all([
delay(100, 'p1'),
delay(100, 'p2'),
]);
console.log(`parallel done: ${p1}, ${p2}`);
// Sequential: ~200ms total
// Parallel: ~100ms total
}
// Scenario 8: Error propagation timing
errorTiming(): void {
console.log('1');
Promise.resolve()
.then(() => {
console.log('2');
throw new Error('fail');
})
.then(() => console.log('3 - skipped'))
.catch(() => console.log('4 - caught'))
.then(() => console.log('5 - recovery'));
Promise.resolve()
.then(() => console.log('6'))
.then(() => console.log('7'));
console.log('8');
// Output: 1, 8, 2, 6, 4, 7, 5
// Why: Error skips .then(), catch handles, interleaves with parallel chain
}
// Scenario 9: Dynamic Promise creation
dynamicPromises(): void {
console.log('1');
const createChain = (depth: number, label: string): Promise<void> => {
if (depth === 0) return Promise.resolve();
return Promise.resolve().then(() => {
console.log(`${label}-${depth}`);
return createChain(depth - 1, label);
});
};
createChain(3, 'A');
createChain(3, 'B');
console.log('2');
// Output: 1, 2, A-3, B-3, A-2, B-2, A-1, B-1
// Why: Each depth level interleaves between chains
}
// Scenario 10: finally() timing
finallyTiming(): void {
console.log('1');
Promise.resolve('value')
.then(v => {
console.log(`2: ${v}`);
return 'modified';
})
.finally(() => {
console.log('3: finally');
return 'ignored'; // Return value ignored
})
.then(v => {
console.log(`4: ${v}`); // Gets 'modified', not 'ignored'
});
Promise.resolve().then(() => console.log('5'));
console.log('6');
// Output: 1, 6, 2: value, 5, 3: finally, 4: modified
// Why: finally() creates additional microtask tick
}
}
GIF via GIPHY
Promise Combinator Internals
// Internal implementation of Promise combinators
class PromiseCombinators {
// Promise.all implementation
static all<T>(promises: Iterable<T | PromiseLike<T>>): Promise<T[]> {
return new Promise((resolve, reject) => {
const promiseArray = Array.from(promises);
const results: T[] = new Array(promiseArray.length);
let remainingCount = promiseArray.length;
let rejected = false;
if (remainingCount === 0) {
resolve(results);
return;
}
promiseArray.forEach((promise, index) => {
// Wrap in Promise.resolve to handle non-Promise values
Promise.resolve(promise).then(
value => {
if (rejected) return;
results[index] = value;
remainingCount--;
if (remainingCount === 0) {
resolve(results);
}
},
reason => {
if (rejected) return;
rejected = true;
reject(reason);
}
);
});
});
}
// Promise.allSettled implementation
static allSettled<T>(
promises: Iterable<T | PromiseLike<T>>
): Promise<PromiseSettledResult<T>[]> {
return new Promise(resolve => {
const promiseArray = Array.from(promises);
const results: PromiseSettledResult<T>[] = new Array(promiseArray.length);
let remainingCount = promiseArray.length;
if (remainingCount === 0) {
resolve(results);
return;
}
promiseArray.forEach((promise, index) => {
Promise.resolve(promise).then(
value => {
results[index] = { status: 'fulfilled', value };
if (--remainingCount === 0) resolve(results);
},
reason => {
results[index] = { status: 'rejected', reason };
if (--remainingCount === 0) resolve(results);
}
);
});
});
}
// Promise.race implementation
static race<T>(promises: Iterable<T | PromiseLike<T>>): Promise<T> {
return new Promise((resolve, reject) => {
for (const promise of promises) {
// First one to settle wins
Promise.resolve(promise).then(resolve, reject);
}
});
}
// Promise.any implementation (ES2021)
static any<T>(promises: Iterable<T | PromiseLike<T>>): Promise<T> {
return new Promise((resolve, reject) => {
const promiseArray = Array.from(promises);
const errors: unknown[] = new Array(promiseArray.length);
let remainingCount = promiseArray.length;
let resolved = false;
if (remainingCount === 0) {
reject(new AggregateError([], 'All promises were rejected'));
return;
}
promiseArray.forEach((promise, index) => {
Promise.resolve(promise).then(
value => {
if (resolved) return;
resolved = true;
resolve(value);
},
reason => {
if (resolved) return;
errors[index] = reason;
if (--remainingCount === 0) {
reject(new AggregateError(errors, 'All promises were rejected'));
}
}
);
});
});
}
// Promise.withResolvers (ES2024)
static withResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
}
// Execution order with combinators
class CombinatorOrderingDemos {
// Promise.all ordering
async allOrdering(): Promise<void> {
console.log('1. start');
const results = await Promise.all([
Promise.resolve().then(() => {
console.log('3. first promise');
return 'a';
}),
Promise.resolve().then(() => {
console.log('4. second promise');
return 'b';
}),
Promise.resolve().then(() => {
console.log('5. third promise');
return 'c';
}),
]);
console.log(`6. results: ${results.join(', ')}`);
// Results are in original order: a, b, c
console.log('2. after Promise.all call (sync)');
// Full output:
// 1. start
// 2. after Promise.all call (sync)
// 3. first promise
// 4. second promise
// 5. third promise
// 6. results: a, b, c
}
// Promise.race edge case
async raceEdgeCase(): Promise<void> {
// What if race contains already-resolved promises?
const winner = await Promise.race([
Promise.resolve('first'),
Promise.resolve('second'),
Promise.resolve('third'),
]);
console.log(winner); // 'first' - first in array wins ties
}
// Promise.allSettled with mixed results
async allSettledMixed(): Promise<void> {
const results = await Promise.allSettled([
Promise.resolve('success'),
Promise.reject(new Error('fail')),
Promise.resolve('another success'),
]);
// All promises are processed regardless of rejection
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`${i}: fulfilled with ${result.value}`);
} else {
console.log(`${i}: rejected with ${result.reason.message}`);
}
});
}
}
GIF via GIPHY
Debugging Promise Execution Order
// Tools for debugging Promise execution order
class PromiseDebugger {
private static idCounter = 0;
private static timeline: TimelineEntry[] = [];
// Wrap a promise for tracing
static trace<T>(
promise: Promise<T>,
label: string
): Promise<T> {
const id = ++this.idCounter;
this.timeline.push({
id,
label,
event: 'created',
time: performance.now(),
stack: new Error().stack,
});
return promise.then(
value => {
this.timeline.push({
id,
label,
event: 'fulfilled',
time: performance.now(),
value,
});
return value;
},
reason => {
this.timeline.push({
id,
label,
event: 'rejected',
time: performance.now(),
reason,
});
throw reason;
}
);
}
// Create a traced Promise.resolve
static resolveTraced<T>(value: T, label: string): Promise<T> {
return this.trace(Promise.resolve(value), label);
}
// Trace an async function
static traceAsync<T>(
fn: () => Promise<T>,
label: string
): Promise<T> {
this.timeline.push({
id: ++this.idCounter,
label,
event: 'async-start',
time: performance.now(),
});
return fn().then(
value => {
this.timeline.push({
id: this.idCounter,
label,
event: 'async-complete',
time: performance.now(),
value,
});
return value;
},
reason => {
this.timeline.push({
id: this.idCounter,
label,
event: 'async-error',
time: performance.now(),
reason,
});
throw reason;
}
);
}
// Print execution timeline
static printTimeline(): void {
console.log('\n=== Promise Execution Timeline ===\n');
const sorted = [...this.timeline].sort((a, b) => a.time - b.time);
const startTime = sorted[0]?.time ?? 0;
for (const entry of sorted) {
const relativeTime = (entry.time - startTime).toFixed(3);
const valueStr = entry.value !== undefined
? ` -> ${JSON.stringify(entry.value)}`
: entry.reason !== undefined
? ` -> ERROR: ${entry.reason}`
: '';
console.log(
`[${relativeTime}ms] #${entry.id} ${entry.label}: ${entry.event}${valueStr}`
);
}
console.log('\n=== End Timeline ===\n');
}
static reset(): void {
this.timeline = [];
this.idCounter = 0;
}
}
interface TimelineEntry {
id: number;
label: string;
event: 'created' | 'fulfilled' | 'rejected' | 'async-start' | 'async-complete' | 'async-error';
time: number;
value?: unknown;
reason?: unknown;
stack?: string;
}
// Usage example
async function debugExample(): Promise<void> {
PromiseDebugger.reset();
PromiseDebugger.resolveTraced('A', 'first')
.then(v => {
PromiseDebugger.resolveTraced('B', 'nested');
return v + '-chained';
});
PromiseDebugger.resolveTraced('C', 'second');
await new Promise(r => setTimeout(r, 10));
PromiseDebugger.printTimeline();
}
GIF via GIPHY
Production Incidents
Incident 1: Race Condition from Ordering Assumption
Symptoms: User profile showed stale data 5% of the time after update.
Root Cause: Code assumed Promise resolution order matched call order:
// PROBLEMATIC: Assumed order
async function updateAndFetch(userId: string, updates: UserUpdates) {
// These execute in parallel
await api.updateUser(userId, updates);
const user = await api.getUser(userId); // May return stale data!
return user;
}
// The API calls are sequential but the backend might process them
// out of order, or cache might return stale data
Resolution:
// FIXED: Explicit sequencing with proper cache invalidation
async function updateAndFetchSafe(userId: string, updates: UserUpdates) {
// Wait for update to complete AND propagate
const updateResult = await api.updateUser(userId, updates);
// Use version/etag from update to ensure fresh read
const user = await api.getUser(userId, {
ifNewerThan: updateResult.version,
skipCache: true,
});
return user;
}
// Alternative: Return updated data from mutation
async function updateAndReturn(userId: string, updates: UserUpdates) {
// Server returns updated object
const updatedUser = await api.updateUser(userId, updates, {
returnUpdated: true,
});
return updatedUser;
}
Incident 2: Microtask Queue Starvation
Symptoms: UI froze for 3+ seconds when loading dashboard with complex data transformations.
Root Cause: Deep Promise chains processed all data in microtasks:
GIF via GIPHY
// PROBLEMATIC: Recursive Promise chain
function transformDeep(data: NestedData): Promise<TransformedData> {
return Promise.resolve(data)
.then(d => transform(d.value))
.then(transformed => {
if (data.children) {
// Creates Promise for EACH child, all in microtask queue
return Promise.all(
data.children.map(child => transformDeep(child))
).then(children => ({ ...transformed, children }));
}
return transformed;
});
}
// 10,000 nodes = 10,000+ microtasks, no macrotask can run
Resolution:
// FIXED: Chunked processing with macrotask yields
async function transformDeepChunked(
data: NestedData,
options = { chunkSize: 100 }
): Promise<TransformedData> {
let processed = 0;
async function processNode(node: NestedData): Promise<TransformedData> {
processed++;
// Yield every N items
if (processed % options.chunkSize === 0) {
await new Promise(r => setTimeout(r, 0));
}
const transformed = transform(node.value);
if (node.children) {
const children = await Promise.all(
node.children.map(child => processNode(child))
);
return { ...transformed, children };
}
return transformed;
}
return processNode(data);
}
Incident 3: Unhandled Rejection from Finally
Symptoms: Error tracking showed "Unhandled rejection" but code had .catch().
Root Cause: Promise chain with finally() and error re-throw:
// PROBLEMATIC: Hidden rejection path
async function fetchWithCleanup() {
let connection: Connection | null = null;
return connect()
.then(conn => {
connection = conn;
return conn.query('SELECT * FROM users');
})
.finally(() => {
// This throws if connection is null
connection!.close();
})
.catch(err => {
// This catches query errors, NOT finally errors!
console.error('Query failed:', err);
});
}
// If connect() fails, connection is null, finally throws,
// but that throw is NOT caught by the .catch() above it
Resolution:
// FIXED: Proper cleanup error handling
async function fetchWithCleanupSafe() {
let connection: Connection | null = null;
return connect()
.then(conn => {
connection = conn;
return conn.query('SELECT * FROM users');
})
.finally(() => {
// Guard against null
if (connection) {
return connection.close().catch(closeErr => {
console.error('Failed to close connection:', closeErr);
});
}
})
.catch(err => {
console.error('Operation failed:', err);
throw err; // Re-throw for caller
});
}
// Better: async/await with try-finally
async function fetchWithCleanupAsync() {
const connection = await connect();
try {
return await connection.query('SELECT * FROM users');
} finally {
try {
await connection.close();
} catch (closeErr) {
console.error('Failed to close connection:', closeErr);
}
}
}
Tradeoffs and Engineering Decisions
| Decision | Choice | Alternative | Why This Choice |
|---|---|---|---|
| Promise resolution | Always async | Sync for already-resolved | Predictable ordering, avoids Zalgo |
| Thenable assimilation | Extra microtask | Direct resolution | Spec compliance, prevents sync recursion |
| Unhandled rejection | Async detection | Sync throw | Allows time for handler attachment |
| Promise.all failure | Fail fast | Wait for all | Matches common error handling expectations |
| Reaction execution | FIFO queue | Priority based | Deterministic ordering |
| finally() return | Ignored | Propagated | Cleanup shouldn't change resolution |
| Combinator iteration | Eager (sync) | Lazy | Predictable Promise creation timing |
| Error propagation | Skip .then() | Call with undefined | Clearer error flow |
GIF via GIPHY
Key Takeaways
- .then() handlers are ALWAYS async - even on already-resolved Promises, handlers queue as microtasks
- Returning a Promise from .then() adds an extra microtask - the PromiseResolveThenableJob delays resolution
- Multiple await statements create sequential microtasks - they interleave with other Promise chains
- Promise.all resolves AFTER all input Promises - the completion callback is a separate microtask
- Microtasks are FIFO - handlers execute in the order they were queued within the same checkpoint
- finally() creates additional microtask ticks - return values are ignored but async operations wait
- Unhandled rejections are detected asynchronously - allowing time for .catch() to be attached
- Error propagation skips .then() onFulfilled - chain jumps to nearest rejection handler
- Promise.resolve(promise) returns the same promise - no wrapping for native Promises
- Executor runs synchronously - new Promise(executor) calls executor before returning
GIF via GIPHYWhat did you think?