Exercise
The microtask queue is a special queue in the JavaScript event loop that has higher priority than the macrotask queue (which handles setTimeout, setInterval, etc.). When a Promise settles, its .then(), .catch(), or .finally() callbacks are placed into the microtask queue. The event loop processes all microtasks in the queue before moving on to the next macrotask. This means Promise callbacks always execute before any pending setTimeout or setInterval callbacks, even if the timer delay is set to 0.
console.log('Start');
setTimeout(() => {
console.log('Macrotask: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('Microtask: Promise 1');
}).then(() => {
console.log('Microtask: Promise 2');
});
console.log('End');
// Output:
// Start
// End
// Microtask: Promise 1
// Microtask: Promise 2
// Macrotask: setTimeout
Promise.allSettled() takes an iterable of Promises and returns a single Promise that resolves after all the given Promises have either fulfilled or rejected. Unlike Promise.all(), which short-circuits and rejects immediately when any Promise rejects, Promise.allSettled() always waits for every Promise to settle. The resolved value is an array of objects, each with a status property ("fulfilled" or "rejected") and either a value or reason property. This makes it ideal for scenarios where you need results from all operations regardless of individual failures.
const promises = [
Promise.resolve('Success'),
Promise.reject('Error occurred'),
Promise.resolve(42)
];
// Promise.all - rejects immediately on first failure
Promise.all(promises)
.then(results => console.log(results))
.catch(err => console.log('all() rejected:', err));
// Output: all() rejected: Error occurred
// Promise.allSettled - waits for all to settle
Promise.allSettled(promises)
.then(results => console.log(results));
// Output:
// [
// { status: 'fulfilled', value: 'Success' },
// { status: 'rejected', reason: 'Error occurred' },
// { status: 'fulfilled', value: 42 }
// ]
A retry mechanism allows you to re-attempt a failed asynchronous operation a specified number of times before giving up. You can implement this by creating a function that accepts an async operation, a maximum number of retries, and an optional delay between attempts. The function calls the operation recursively or iteratively, catching rejections and decrementing the retry counter until either the operation succeeds or all retries are exhausted. Adding an exponential backoff delay between retries is a common production pattern.
function retry(operation, maxRetries, delay = 1000) {
return new Promise((resolve, reject) => {
function attempt(retriesLeft) {
operation()
.then(resolve)
.catch((error) => {
if (retriesLeft <= 0) {
reject(error);
return;
}
console.log(`Retrying... ${retriesLeft} attempts left`);
setTimeout(() => {
attempt(retriesLeft - 1);
}, delay);
});
}
attempt(maxRetries);
});
}
// Usage
let attemptCount = 0;
function unreliableOperation() {
attemptCount++;
return new Promise((resolve, reject) => {
if (attemptCount < 3) {
reject(`Attempt ${attemptCount} failed`);
} else {
resolve(`Succeeded on attempt ${attemptCount}`);
}
});
}
retry(unreliableOperation, 5, 500)
.then(result => console.log(result))
.catch(err => console.log('All retries failed:', err));
// Output: Succeeded on attempt 3
When you return a rejected Promise from inside a .then() handler, the next Promise in the chain adopts that rejected state. This means subsequent .then() handlers are skipped, and execution jumps to the nearest .catch() handler. The same behavior occurs if you throw an error inside a .then() callback. Returning Promise.reject() is functionally equivalent to throwing an error within the handler, but using throw is generally preferred for clarity and consistency.
Promise.resolve('Step 1')
.then(value => {
console.log(value);
return Promise.reject('Failed at Step 2');
})
.then(value => {
// This is skipped entirely
console.log('Step 3:', value);
})
.catch(error => {
console.log('Caught:', error);
})
.then(() => {
console.log('Recovery: chain continues after catch');
});
// Output:
// Step 1
// Caught: Failed at Step 2
// Recovery: chain continues after catch
A Promise-based semaphore limits how many asynchronous tasks run at the same time. This is useful when you have many tasks (for example, network requests) but want to avoid overwhelming a server or exceeding resource limits. The semaphore maintains a counter of available slots and a queue of waiting tasks. When a task wants to run, it calls acquire() to reserve a slot. If all slots are taken, the task waits in the queue. When a task finishes, it calls release() to free the slot and allow the next queued task to proceed.
class Semaphore {
constructor(maxConcurrency) {
this.max = maxConcurrency;
this.running = 0;
this.queue = [];
}
acquire() {
return new Promise(resolve => {
if (this.running < this.max) {
this.running++;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release() {
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
} else {
this.running--;
}
}
}
// Usage: limit to 2 concurrent tasks
async function runWithLimit(tasks) {
const semaphore = new Semaphore(2);
const results = tasks.map(async (task) => {
await semaphore.acquire();
try {
return await task();
} finally {
semaphore.release();
}
});
return Promise.all(results);
}
const tasks = [1, 2, 3, 4, 5].map(id => () =>
new Promise(resolve => {
console.log(`Task ${id} started`);
setTimeout(() => {
console.log(`Task ${id} done`);
resolve(id);
}, 1000);
})
);
runWithLimit(tasks).then(results => console.log('All done:', results));
// Only 2 tasks run at a time
Understanding nested Promise chains with mixed resolve and reject calls requires careful attention to how Promises propagate values through the chain. When a .catch() handler does not re-throw or return a rejected Promise, the chain recovers and subsequent .then() handlers execute normally. A .then() that receives two arguments uses the second function as its rejection handler, similar to .catch(). Also, returning a thenable (an object with a .then() method) from inside a handler causes the chain to wait for that thenable to settle before continuing.
Promise.reject('error')
.then(
val => console.log('A:', val),
err => {
console.log('B:', err);
return 'recovered';
}
)
.then(val => {
console.log('C:', val);
throw new Error('broken');
})
.catch(err => {
console.log('D:', err.message);
return Promise.resolve('fixed');
})
.then(val => {
console.log('E:', val);
return new Promise(resolve => {
setTimeout(() => resolve('delayed'), 100);
});
})
.then(val => console.log('F:', val));
// Output:
// B: error
// C: recovered
// D: broken
// E: fixed
// F: delayed
Native JavaScript Promises are not cancellable by design. Once a Promise is created, you cannot stop its executor from running. However, you can implement a cancellable wrapper using the AbortController API or a simple boolean flag pattern. The AbortController approach is the modern, recommended way. You pass an AbortSignal to your async operation, and when abort() is called, the signal triggers cancellation. The operation checks the signal and rejects the Promise with an AbortError. This pattern is widely used with the fetch API and custom async logic.
function cancellablePromise(executor) {
const controller = new AbortController();
const signal = controller.signal;
const promise = new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Operation cancelled', 'AbortError'));
});
executor(resolve, reject, signal);
});
return { promise, cancel: () => controller.abort() };
}
// Usage
const { promise, cancel } = cancellablePromise((resolve, reject, signal) => {
const timer = setTimeout(() => {
if (!signal.aborted) {
resolve('Operation completed');
}
}, 5000);
signal.addEventListener('abort', () => clearTimeout(timer));
});
promise
.then(result => console.log(result))
.catch(err => console.log(err.message));
// Cancel after 1 second
setTimeout(() => cancel(), 1000);
// Output: Operation cancelled
Microtasks and macrotasks are two separate queues in the JavaScript event loop. Microtasks include Promise callbacks (.then(), .catch(), .finally()), queueMicrotask(), and MutationObserver callbacks. Macrotasks include setTimeout, setInterval, setImmediate (Node.js), I/O operations, and UI rendering events. The critical difference is execution priority: after each macrotask completes, the event loop drains the entire microtask queue before picking up the next macrotask. This means microtasks can starve macrotasks if they continuously schedule more microtasks.
console.log('1: Script start');
setTimeout(() => console.log('2: Macrotask - setTimeout 1'), 0);
Promise.resolve()
.then(() => {
console.log('3: Microtask - Promise 1');
queueMicrotask(() => console.log('4: Microtask - nested queueMicrotask'));
})
.then(() => console.log('5: Microtask - Promise 2'));
setTimeout(() => console.log('6: Macrotask - setTimeout 2'), 0);
queueMicrotask(() => console.log('7: Microtask - queueMicrotask'));
console.log('8: Script end');
// Output:
// 1: Script start
// 8: Script end
// 3: Microtask - Promise 1
// 7: Microtask - queueMicrotask
// 5: Microtask - Promise 2
// 4: Microtask - nested queueMicrotask
// 2: Macrotask - setTimeout 1
// 6: Macrotask - setTimeout 2
Implementing Promise.all() from scratch requires handling several key behaviors. It must accept an iterable of Promises (or values), return a new Promise that resolves with an array of results in the original order when all input Promises fulfill, and reject immediately when any input Promise rejects. Non-Promise values in the input must be treated as already-resolved values. You also need a counter to track how many Promises have settled, because Promises may resolve out of order but results must be stored at their original index positions.
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const inputs = Array.from(promises);
if (inputs.length === 0) {
resolve([]);
return;
}
const results = new Array(inputs.length);
let settledCount = 0;
inputs.forEach((item, index) => {
Promise.resolve(item)
.then(value => {
results[index] = value;
settledCount++;
if (settledCount === inputs.length) {
resolve(results);
}
})
.catch(reject);
});
});
}
// Testing the custom implementation
promiseAll([
Promise.resolve(1),
new Promise(resolve => setTimeout(() => resolve(2), 100)),
3
]).then(results => console.log(results));
// Output: [1, 2, 3]
promiseAll([
Promise.resolve('a'),
Promise.reject('fail'),
Promise.resolve('c')
]).catch(err => console.log('Rejected:', err));
// Output: Rejected: fail
An unhandled Promise rejection occurs when a Promise is rejected but no .catch() handler or rejection callback is attached to handle the error. In browsers, you can listen for the unhandledrejection event on the window object to catch these globally. In Node.js, you listen for the unhandledRejection event on the process object. Starting from Node.js 15 and above, unhandled rejections terminate the process by default. It is a best practice to always attach .catch() to every Promise chain and use try/catch blocks inside async functions to prevent unhandled rejections from crashing your application.
// Browser: global handler for unhandled rejections
window.addEventListener('unhandledrejection', (event) => {
console.log('Unhandled rejection:', event.reason);
event.preventDefault();
});
// Node.js: global handler for unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
console.log('Unhandled rejection at:', promise, 'reason:', reason);
});
// This rejection has no .catch() - triggers the global handler
Promise.reject('Something went wrong');
// Best practice: always handle rejections
async function safeOperation() {
try {
const result = await someAsyncTask();
return result;
} catch (error) {
console.log('Handled error:', error);
return null;
}
}
// Utility: attach a safety catch to any Promise
function safeCatch(promise) {
return promise.catch(error => {
console.log('Caught silently:', error);
return undefined;
});
}
safeCatch(Promise.reject('handled safely'));
// Output: Caught silently: handled safely