Introduction
The idea for this series of articles came from a large number of interviews where people even with 10+ years of frontend experience couldn't answer simple questions about what type of await would work with an object that implements the then() method or even if a standard object or class could implement the then method.
That is, people are moving away from front-end development to a narrow framework, where framework abstractions completely replace work with native asynchronous JS models.
JavaScript is single-threaded, meaning it processes one operation at a time. However, many tasks—such as network requests, file operations, or timers—take time to complete and would block execution if handled synchronously.
Although JavaScript has traditionally been considered a single-threaded language, with the advent of Web Workers and SharedArrayBuffer, it is more accurate to say that it has a single main execution thread, but can run additional threads via workers. Each worker has its own execution stack and memory space. However, within each thread, JavaScript is still single-threaded.
In this article, I invite you to explore how native JavaScript handles asynchronous tasks. This can be helpful not only for understanding how native JS works under the hood but also for preparing for technical interviews and seeing some of the interesting concepts and approaches already built into the language.
To handle such tasks efficiently, JavaScript relies on asynchronous programming. Instead of waiting for slow operations to finish, the engine continues executing other code and processes the results later
In this article serial we will investigate next topics:
- Callbacks (the oldest approach)
- Promises (a more structured alternative)
In next articles:
- Advanced Promise Control
- Async/Await (syntactic sugar for Promises)
- Event Emitters & Streams (for event-driven workflows)
- Advanced Patterns (Worker Threads, Actors, Reactive Programming)
While the Event Loop (Node.js) and Web APIs (browsers) manage the execution order of asynchronous tasks, this article focuses on practical patterns and best practices rather than low-level runtime mechanics.
In this article we will focus on callbacks and promises so that the article does not turn out to be huge. Other approaches will be described in the following articles.
Callbacks: The Classic Approach
In this section, we’ll look at callbacks—a fundamentally simple concept. A callback doesn’t have to be asynchronous; you might have already seen plenty of synchronous callbacks, such as those passed to array methods like .filter() or .map(). This contract is extremely common in JavaScript.
Naturally, you can also write fully asynchronous code using callbacks. For example, let’s write some code that takes the next user from an array each second and logs their name:
const users = [
{ name: 'Ivan', age: 27 },
{ name: 'Olena', age: 32 },
{ name: 'Mike', age: 18 },
];
const timer = setInterval(() => {
const nextUser = users.shift();
console.log(`Next user: ${nextUser.name}`);
if (users.length === 0) {
clearInterval(timer); // Stop when done
}
}, 1000);
In this example, a timer is created. Every second, either Node.js or the browser’s Web API will execute the code, place the callback in the queue, and then the Event Loop will push it onto the call stack to run. That’s essentially how asynchronous callbacks work: we wait for an event, Node.js or the Web API processes that event and takes the callback (the function we passed in—here, an arrow function), puts it in the queue, and then the Event Loop eventually executes it on the call stack.
Asynchronous File Reading with Callbacks
Here’s a slightly different example using Node.js and the fs (file system) module:
const fs = require('node:fs');
fs.readFile('/Users/joe/test.txt', 'utf8', handleUserFile);
function handleUserFile(err, data) {
if (err) {
console.error('Error:', err);
return;
}
console.log(data);
}
This is an asynchronous file read operation. The handleUserFile callback is invoked later, receiving arguments once the file has been read. If you have numerous functions, you can use named functions to make your code more readable. In fact, you can use any structure you like for your callback functions, as long as the callback contract (the signature it expects) is fulfilled.
Callbacks also open up many possibilities with closures. For instance, let’s store the filename in a variable and then log the result. This way, we keep the filename in a closure and can use it later in our callback:
const fs = require('node:fs');
const fileName = '/Users/joe/test.txt';
const logFile = (fileName) => (err, data) => {
console.log({ fileName, err, data });
};
fs.readFile(fileName, 'utf8', logFile(fileName));
Here, logFile returns our callback function, which is passed to readFile. Thanks to closures, fileName is available inside that callback.
Callback Hell
Of course, if we’re discussing callbacks, we have to mention “callback hell.” Let’s quickly illustrate that:
function fetchUserData(userId, callback) {
console.log('(1) Fetching user data...');
setTimeout(() => {
if (userId === 1) {
console.log('(1) User data fetched');
callback({ id: 1, name: 'John Doe', role: 'admin' }, null);
} else {
callback(null, 'User not found');
}
}, 1000);
}
function fetchUserPermissions(role, callback) {
console.log('(2) Fetching permissions for role:', role);
setTimeout(() => {
if (role === 'admin') {
console.log('(2) Permissions fetched');
callback(['read', 'write', 'delete'], null);
} else {
callback(null, 'Permissions not found for this role');
}
}, 1000);
}
function logAccessAttempt(user, permissions, callback) {
console.log('(3) Logging access attempt...');
setTimeout(() => {
if (permissions.includes('write')) {
console.log(`(3) Access logged for user: ${user.name}`);
callback('Access logged successfully', null);
} else {
callback(null, 'Insufficient permissions for access logging');
}
}, 1000);
}
fetchUserData(1, (user, err) => {
if (err) return console.log(err);
fetchUserPermissions(user.role, (permissions, err) => {
if (err) return console.log(err);
logAccessAttempt(user, permissions, (result, err) => {
if (err) return console.log(err);
console.log(result);
});
});
});
Here, the functions move deeper to the right, making the code difficult to read and even harder to extend.
One way to mitigate this is by using named functions:
const handleLogAccessAttempt = (result, err) => {
if (err) {
console.log(err);
return;
}
console.log(result);
};
const handleUserPermissions = (user) => (permissions, err) => {
if (err) {
console.log(err);
return;
}
logAccessAttempt(user, permissions, handleLogAccessAttempt);
};
const handleUser = (user, err) => {
if (err) {
console.log(err);
return;
}
fetchUserPermissions(user.role, handleUserPermissions(user));
};
fetchUserData(1, handleUser);
Although this is cleaner, we can see a different kind of problem: the logical flow might not read in the most natural order. One workaround is to use a class:
class UserService {
constructor() {
this.user = null;
this.permissions = null;
}
handleLogAccessAttempt(result, err) {
if (err) {
console.log(err);
return;
}
console.log(result);
}
handleUserPermissions(permissions, err) {
if (err) {
console.log(err);
return;
}
this.permissions = permissions;
logAccessAttempt(this.user, this.permissions, this.handleLogAccessAttempt.bind(this));
}
handleUser(user, err) {
if (err) {
console.log(err);
return;
}
this.user = user;
fetchUserPermissions(this.user.role, this.handleUserPermissions.bind(this));
}
fetchUser(userId) {
fetchUserData(userId, this.handleUser.bind(this));
}
}
const userService = new UserService();
userService.fetchUser(1);
Now, we can invoke a method and use a class to manage data and handlers in one place.
So that’s basically it — we have asynchronous events emitted every time the user interacts with the interface, and we simply attach callbacks to handle them. The model is very straightforward, and it’s generally considered something to avoid in larger applications.
However, if you're dealing with something simple — like triggering a callback when a modal closes or a button is clicked — this approach is not only acceptable, it's actually the right tool for the job.
Promises — the Bread‑and‑Butter of Every Developer (and a Guaranteed Junior/Mid Interview Topic)
Let’s dive right in. A Promise is an object you create to handle deferred operations—an "IOU" that says, "I’ll give you the result (or the error) when the operation finishes."
By nature, Promise resolution is asynchronous. A Promise is created with an open constructor—often called the Revealing Constructor Pattern in JavaScript. You use it constantly, even if you’ve never heard the name.
The Revealing Constructor Pattern
In general terms, it’s a constructor that accepts a function at initialization‑time.
When you create the object, you pass an executor function to the constructor. While that constructor runs, the executor (executor in case of promise is a callback with resolve and reject functions - new Promise((resolve, reject) => {})) receives a private internal store and can read or modify it. Once construction finishes, there is no reference to internal on the outside, so the data stay fully encapsulated—only the public API you attached to this remains visible.
class Revealed {
constructor(executor) {
const internal = {}; // private state
// The executor gets access to that state
executor(internal);
// Public API
this.publicMethod = () => {
console.log('This is a public method');
};
}
}
// Usage
const safeObject = new Revealed((internal) => {
internal.secret = 'Top‑secret data';
internal.method = () => console.log(internal.secret);
internal.method(); // works
});
safeObject.publicMethod(); // works
console.log(safeObject.internal); // undefined
I hope now you understand how the pattern works, let's take a look at a quick everyday Promise example
// Wrap an artificial delay in a Promise
function fetchWithDelay(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Pretend we fetched something
resolve(`Fetched: ${url}`);
// If anything went wrong, we could call reject(err) instead
}, 1000);
});
}
// Consume the Promise
fetchWithDelay('/api/users')
.then((data) => console.log(data)) // “Fetched: /api/users”
.catch((err) => console.error(err))
.finally(() => console.log('Finished ')) // settled (either fulfilled or rejected)
What’s happening?
-
new Promise()invokes the executor(resolve, reject) => { … }. - When the asynchronous work completes (here, after 1s), we call
resolve(...). - The runtime queues your
.then()handler, which runs on the micro‑task queue right after the current call stack empties. - Any error would travel the same path but land in
.catch()viareject(...).
A Promise can be in one of three internal (non-public) states:
-
pending— the operation hasn’t finished yet. -
fulfilled— finished successfully and now holds a value. -
rejected— finished with an error.
We interact with the Promise state using three key methods:
-
.then((data) => { ... })— adds listeners that are interested in the fulfilled state of the Promise. -
.catch((error) => { ... })— reacts if the executor calledreject(error), handling any failure case. -
.finally(() => { ... })— runs once the Promise is settled, meaning either fulfilled or rejected.
These methods give us clean, declarative ways to hook into the lifecycle of a Promise: from creation → to resolution or rejection → to finalization.
With that mental model in place, the code below shows a naive Promise implementation so you can see the mechanics in pure JavaScript:
class SimplePromise {
// private fields
#state = 'pending'; // 'pending' | 'fulfilled' | 'rejected'
#value;
#callbacks = [];
constructor(executor) {
const resolve = (value) => {
if (this.#state !== 'pending') return;
this.#state = 'fulfilled';
this.#value = value;
this.#callbacks.forEach(cb => cb.onFulfilled?.(value));
};
const reject = (reason) => {
if (this.#state !== 'pending') return;
this.#state = 'rejected';
this.#value = reason;
this.#callbacks.forEach(cb => cb.onRejected?.(reason));
};
// hand control to the executor
executor(resolve, reject);
}
then(onFulfilled, onRejected) {
if (this.#state === 'fulfilled') {
onFulfilled?.(this.#value);
} else if (this.#state === 'rejected') {
onRejected?.(this.#value);
} else {
this.#callbacks.push({ onFulfilled, onRejected });
}
// NOTE: returns the same promise for simplicity.
// A real implementation returns a *new* Promise to enable chaining.
return this;
}
}
// 1. Creation
const promise = new SimplePromise((resolve, reject) => {
setTimeout(() => resolve('Done!'), 1000);
});
// 2. Consumption
promise.then(result => console.log(result)); // "Done!"
This stripped‑down class captures the essentials—private state (#state, #value), one‑time resolution via resolve/reject, and a basic .then() callback registry so you can see exactly what happens behind the scenes of JavaScript’s built‑in Promise.
As you can see - it's not that difficult! This is, in principle, the basic work of a promise: an object that stores a state, a value, and functions that are interested in receiving a value after successful execution or an error.
So, the question or task that you may get in an interview is: Why could we write one .then() after another? Because every call to .then() (or .catch() or .finally()) always returns a new Promise.
Even when you omit return inside the handler, .then() silently wraps the result (or undefined) in a fresh promise and passes it along the chain.
What you do inside the handler decides the fate of that next promise:
- Return a plain value → the next promise becomes fulfilled with that value.
- Return nothing → the next promise becomes fulfilled with undefined.
- Throw an error → the next promise becomes rejected with that error.
- Return an already-fulfilled promise → the next promise adopts that value.
- Return an already-rejected promise → the next promise adopts that reason.
- Return a still-pending promise → the next promise waits and then settles exactly the same way.
All these handlers run from the micro-task queue, so they fire only after the current call stack is clear. If a handler returns a promise that is still pending, the next .then() (or .catch() / .finally()) will wait for that promise to settle—either fulfilled or rejected—before it executes.
A little bit above I left a note that the method will temporarily return this. Now we know how this method works and we can experiment and make our SimplePromise an object that can be truly chained.
Now let’s extend our naive SimplePromise to support chaining by returning a new Promise from every call to then(). This will give us a much more realistic behavior, similar to how native JavaScript Promises work.
Here is the updated implementation:
then(onFulfilled, onRejected) {
return new SimplePromise((resolve, reject) => {
const handleCallback = () => {
try {
if (this.#state === 'fulfilled') {
if (typeof onFulfilled === 'function') {
const result = onFulfilled(this.#value);
resolve(result);
} else {
resolve(this.#value);
}
} else if (this.#state === 'rejected') {
if (typeof onRejected === 'function') {
const result = onRejected(this.#value);
resolve(result);
} else {
reject(this.#value);
}
}
} catch (err) {
reject(err);
}
};
if (this.#state === 'pending') {
this.#callbacks.push({
onFulfilled: () => handleCallback(),
onRejected: () => handleCallback(),
});
} else {
// simulate async behavior like microtask queue
queueMicrotask(handleCallback);
}
});
}
With this version:
- Every call to
.then()returns a newSimplePromise. - Any value returned inside the callback is automatically passed into the next promise.
- If a callback throws an error, the next promise is automatically rejected.
- We simulate the microtask queue via
queueMicrotask()to ensure handlers are executed asynchronously, just like native Promises.
Why do we need this handleCallback() function and closures at all?
Now that .then() returns a new Promise, there’s an important challenge we need to handle:
We don’t know whether the current promise is already fulfilled or still pending.
That’s why we use closures and delayed execution through the handleCallback() function.
This function captures:
- user-provided handlers (
onFulfilledandonRejected); - resolve and reject functions for the new Promise.
When executed:
- It checks the current promise state.
- Invokes the appropriate handler.
- Passes its result to
resolve()of the next promise — this is how chaining works internally. - Catches exceptions and passes them to
reject().
Every promise manages its own state.
The only way promises "connect" is by attaching callbacks that know how to settle the next promise.
Other promise capabilities
You can produce a promise that is immediately fulfilled or rejected:
const promise4 = Promise.resolve('Resolved');
console.log(promise4); // Promise { 'Resolved' }
promise4.then(console.log); // → 'Resolved'
const promise5 = Promise.reject(new Error('Error'));
console.dir(promise5); // Promise { <rejected> Error: … }
promise5.catch(console.log); // → Error: …
Typical use-cases
Return cached data via the same async interface
function fetchCachedData() {
if (cache.has('data')) {
return Promise.resolve(cache.get('data')); // already settled
}
return fetch('/api/data'); // real network request
}
Meet an API contract that expects a promise
function getConfig() {
return Promise.resolve({ theme: 'dark' });
}
Another Static methods
Promise.all() and Promise.allSettled()
Both methods can be incredibly useful in real-world applications. It’s very common that rendering a page—or even just a specific widget—requires several asynchronous API calls.
Let’s say we have a dashboard that displays a user profile, their recent gym visits, and subscription type. To assemble this data, we might need to fire off the following requests:
const userDataEndpoints = [
'https://gym/users/1/profile',
'https://gym/users/1/last-visits',
'https://gym/users/1/acc-type'
];
// Convert each URL into a fetch promise
// Promise.all will wait for all of them to resolve
Promise.all(userDataEndpoints.map(url => fetch(url)))
.then(([profile, lastVisits, accType]) => {
// work with responses
});
As you can see, this pattern is quite handy.
However, there’s one important caveat:
If any of the promises reject, the entire
Promise.all()immediately rejects with that error.
This means that as soon as a single request fails, the combined promise fails—and all other results are discarded. For instance, if one of the fetch() calls above fails, the rest will still continue running in the background, but Promise.all will no longer track them. They will eventually settle, but their results are ignored.
Another useful feature is that Promise.all() doesn’t require actual promises as input. It automatically wraps non-promise values using Promise.resolve():
Promise.all([fetch('/api/a'), 'some string'])
.then(([res, str]) => { /* ... */ });
This makes it even more convenient to mix async and static data sources.
Also, you can attach .catch() and .finally() to the combined promise:
Promise.all(userDataEndpoints.map(url => fetch(url)))
.then(([profile, lastVisits, accType]) => {
// All data available
})
.catch(err => {
// Handle the first error that occurred
})
.finally(() => {
// Executed regardless of success/failure
});
Using .finally() like this can help track loading state—for example, hiding a spinner once all data has either loaded or failed.
In production code, you’ll often see Promise.all() used as part of a larger chain.
A common pattern is when we first fetch a user (or any other entity) and then request related details based on it. Here's an example:
const userDataEndpoints = [
'https://gym/users/{id}/profile',
'https://gym/users/{id}/last-visits',
'https://gym/users/{id}/acc-type'
];
fetch('https://gym/user')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch user data');
}
return response.json();
})
.then(user => {
const endpoints = userDataEndpoints.map(url =>
url.replace('{id}', user.id)
);
return Promise.all(
endpoints.map(url =>
fetch(url).then(res => {
if (!res.ok) {
throw new Error(`Request failed for URL: ${url}`);
}
return res.json();
})
)
);
})
.then(([profile, lastVisits, accType]) => {
const userCompleteData = { profile, lastVisits, accType };
console.log('Full user data:', userCompleteData);
return userCompleteData;
})
.catch(err => {
console.error('Error in user data fetching:', err.message);
})
.finally(() => {
console.log('User data fetching process completed');
// e.g. hide a loader
});
So here, instead of using a concrete ID, we can use a placeholder {id}, fetch the user first, and then request the necessary data based on that user's information.
But there’s a limitation worth calling out:
What if we want to see which requests succeeded and which failed, rather than abort the entire operation on first error?
That’s where Promise.allSettled() comes in.
If Promise.all() immediately fails when any single promise rejects, Promise.allSettled() works in a similar way but adds a safety wrapper around each promise. It always waits for all the promises to settle — regardless of whether they succeed or fail.
Each item in the result array will be an object with the following structure:
-
{ status: 'fulfilled', value: ... }— if the promise was resolved successfully -
{ status: 'rejected', reason: ... }— if the promise was rejected with an error
Just like Promise.all(), Promise.allSettled() also wraps non-promise values using Promise.resolve() under the hood. So you can safely mix values and promises in the same array.
Let’s reuse the previous Promise.all() example and wrap it with Promise.allSettled() to illustrate the difference:
const userDataEndpoints = [
'https://gym/users/{id}/profile',
'https://gym/users/{id}/last-visits',
'https://gym/users/{id}/acc-type'
];
fetch('https://gym/user')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch user data');
}
return response.json();
})
.then(user => {
const endpoints = userDataEndpoints.map(url =>
url.replace('{id}', user.id)
);
return Promise.allSettled(
endpoints.map(url =>
fetch(url).then(res => {
if (!res.ok) {
throw new Error(`Request failed for URL: ${url}`);
}
return res.json();
})
)
);
})
.then(results => {
results.forEach((result, index) => {
const label = ['profile', 'lastVisits', 'accType'][index];
if (result.status === 'fulfilled') {
console.log(`${label}:`, result.value);
} else {
console.warn(`${label} failed:`, result.reason.message);
}
});
})
.catch(err => {
console.error('Error during initial user fetch:', err.message);
})
.finally(() => {
console.log('User data fetch (with allSettled) completed');
});
With Promise.allSettled(), we don’t lose everything if one request fails. We get a full report — what succeeded, what didn’t — and can display partial data or retry specific failures. This makes it a better choice when full reliability isn't guaranteed or critical.
Promise.race() and Promise.any()
These two methods show up less frequently in real-world code, but they’re good to know — especially for edge cases or interview questions.
Promise.race() - This method is similar to Promise.all, but instead of waiting for all promises to settle, it returns a promise that settles as soon as any one of the input promises settles (fulfilled or rejected — whichever happens first).
const p1 = new Promise(resolve => setTimeout(() => resolve('First finished'), 100));
const p2 = new Promise(resolve => setTimeout(() => resolve('Second finished'), 200));
Promise.race([p1, p2])
.then(result => {
console.log('Race result:', result); // → 'First finished'
});
If the first settled promise ends with a rejection, race() will immediately reject:
const failFast = new Promise((_, reject) => setTimeout(() => reject('Failed early'), 50));
const slowSuccess = new Promise(resolve => setTimeout(() => resolve('Too late'), 200));
Promise.race([failFast, slowSuccess])
.catch(err => {
console.error('Race failed:', err); // → 'Failed early'
});
So, Promise.race() gives you the first result — whether it’s a success or a failure.
Promise.any() - is similar to race(), but with one important difference: it only resolves when the first fulfilled promise appears. It ignores rejections unless all promises fail.
const p1 = new Promise((_, reject) => setTimeout(() => reject('Error 1'), 100));
const p2 = new Promise(resolve => setTimeout(() => resolve('Success!'), 200));
const p3 = new Promise((_, reject) => setTimeout(() => reject('Error 2'), 300));
Promise.any([p1, p2, p3])
.then(result => {
console.log('Any result:', result); // → 'Success!'
});
But what happens if none of the promises fulfill?
Then Promise.any() rejects with an AggregateError — a special error object that contains a .errors array with all the individual errors:
const p1 = new Promise((_, reject) => setTimeout(() => reject('Fail 1'), 100));
const p2 = new Promise((_, reject) => setTimeout(() => reject('Fail 2'), 200));
Promise.any([p1, p2])
.catch(err => {
console.error('All failed, AggregateError:', err); // [AggregateError: All promises were rejected]
console.error('Individual errors:', err.errors); // → ['Fail 1', 'Fail 2']
});
Promise.any() is a good fit when you want the first successful result, and you’re okay with ignoring failures unless everything fails.
It's time to think about how to use Promise in real examples. There is a concept for this, Promisification.
Promisification
Promisification is wrapping a callback- or event-based API so that it returns a Promise.
That lets you keep the rest of your code promise-friendly. Such transformations are often useful in real life, as functions or libraries often work based on callbacks or events.
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
const fileInput = document.querySelector('#fileInput');
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
readFileAsText(file)
.then(content => {
console.log('File content:', content);
})
.catch(err => {
console.error('Error reading file:', err);
});
});
Real-World Promise Examples
Now let’s take a look at a few practical examples where Promises are widely used in real-world JavaScript applications.
Fetch data from a remote API
The fetch() API is Promise-based by default:
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => {
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
});
}
fetchUser(42)
.then(user => console.log('User data:', user))
.catch(error => console.error('Error:', error));
Chain multiple asynchronous steps
You often chain .then() calls to perform multiple dependent requests:
fetch('/api/user/1')
.then(res => res.json())
.then(user => fetch(`/api/orders/${user.id}`))
.then(res => res.json())
.then(orders => console.log('Orders:', orders))
.catch(err => console.error(err));
Timeout using Promise.race()
You can also combine Promises to implement a timeout mechanism:
function fetchWithTimeout(url, timeoutMs) {
return Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
)
]);
}
fetchWithTimeout('/api/data', 2000)
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
These patterns are extremely common in frontend and backend JavaScript development and demonstrate the full power of Promises beyond simple examples.
Interview-Oriented Promise Tasks
Now that we’ve built a solid understanding of how Promises work internally, including our own SimplePromise implementation — you’re already much better prepared than most candidates!
By the way — implementing part of a Promise (or even building a simplified thenable) is a very common interview problem.
Such tasks help interviewers evaluate your understanding of:
- Closures and how chaining works under the hood
- Managing asynchronous state
- Registering callbacks and triggering them at the right time
One of the most common tasks you’ll face is implementing a promisify() function.
Interview Task: Implement promisify() (Browser version)
One of the most common Promise-related interview tasks is to implement a general-purpose promisify() function. This question tests both your understanding of Promises and your ability to work with callbacks, closures, and asynchronous control flow.
You're given an asynchronous function that works with a callback, following a simple contract:
function someAsyncFunction(arg1, arg2, callback) {
// callback receives just the result
}
Unlike Node.js-style error-first callbacks, many browser APIs only pass back results to the callback, without any error parameter.
Here are real-world browser APIs or patterns where promisify() can be directly applied:
| API | Example of callback signature |
|---|---|
| FileReader | reader.onload = () => callback(result) |
| Geolocation API | navigator.geolocation.getCurrentPosition(callback) |
| setTimeout | setTimeout(() => callback(), ms) |
| MediaDevices | getUserMedia(constraints).then(stream => callback(stream)) |
| Image loading | img.onload = () => callback(img) |
Your task
Implement a function promisify() which:
- Accepts any such callback-based function.
- Returns a new function that returns a Promise.
- Internally wires the callback result to resolve the Promise.
Expected usage:
const promisified = promisify(someAsyncFunction);
promisified('foo', 'bar')
.then(result => console.log(result));
Solution:
function promisify(fn) {
return (...args) => {
return new Promise((resolve, reject) => {
// Inject error-first callback as last argument
args.push((error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
// Call the original function with all arguments
fn(...args);
});
};
}
Step-by-step explanation
Step 1 — High-level idea:
-
promisify()is a higher-order function: it accepts any functionfnthat expects a callback as its last argument. - It returns a new function that wraps the original one and gives back a Promise instead.
Step 2 — Create wrapper function:
return (...args) => {
- Every time this function is called, it immediately returns a new Promise.
- The Promise constructor receives a resolver function which we will call when we get the result from the callback.
Step 3 — Return a Promise:
return new Promise((resolve) => { ... })
- Inside the Promise constructor, we handle the callback resolution logic.
Step 4 — Inject callback:
args.push((result) => {
resolve(result);
});
- Before calling the original function, we add an extra argument — the callback function.
- When the original function completes and calls this callback, we immediately call
resolve()with the result.
Step 5 — Call the original function:
fn(...args);
Finally, we execute the original fn, passing all arguments (including the injected callback).
Key concepts interviewers check here:
- Closures: The inner callback can access resolve thanks to closure scope.
- Higher-order functions: You build functions that return functions.
- Flexible argument handling (rest/spread operator)
- Manual Promise control (resolve/reject)
Advanced Promise Interview Tasks — Promise.all(), Promise.race(), Promise.allSettled()
After you confidently implement promisify(), many interviewers will move one level deeper and ask you to reimplement the built-in Promise combinators.
These tasks test your understanding of:
- Controlling multiple asynchronous flows.
- How Promise resolution and rejection work internally.
- Properly handling edge cases such as empty arrays, synchronous values, and immediately settled promises.
Problem Overview
Let’s briefly recall how each built-in Promise method works:
| Method | Behavior Summary |
|---|---|
Promise.all() |
Waits for all promises to fulfill. Rejects immediately on first rejection. Resolves with an array of values. |
Promise.race() |
Settles as soon as any promise settles (either fulfilled or rejected). Returns the first result. |
Promise.allSettled() |
Waits for all promises to settle. Always resolves with an array of {status, value} or {status, reason} objects. |
For all of these, any non-promise value is automatically converted using Promise.resolve().
Edge Cases
Promise.all()
- If the input array is empty, immediately resolve to an empty array.
- If any promise rejects, the returned promise rejects immediately with that reason.
- Non-promise values are simply treated as fulfilled values.
Promise.race()
- If the input array is empty, the promise stays forever pending.
- If the input contains non-promise values,
Promise.race()resolves to the first one encountered. - Use
.then(resolve, reject)rather than.catch()..catch()scheduling happens after.then(), which can cause incorrect behavior when promises are already settled.
Task 1 — Implement promiseAll()
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let fulfilledCount = 0;
// Step 1 — Handle empty input
if (iterable.length === 0) {
resolve([]);
return;
}
// Step 2 — Iterate through all values
iterable.forEach((item, index) => {
// Always normalize values to promises
Promise.resolve(item)
.then((value) => {
results[index] = value;
fulfilledCount++;
// Step 3 — Resolve when all promises have fulfilled
if (fulfilledCount === iterable.length) {
resolve(results);
}
})
.catch(reject); // Step 4 — Reject immediately on first rejection
});
});
}
Explanation:
- We normalize all values with
Promise.resolve()to handle both promise and non-promise inputs. - The order of results is preserved using
results[index]. - We track fulfilled promises using
fulfilledCount. - On first rejection, we call
reject()immediately.
Task 2 — Implement promiseRace()
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
// Step 1 — Handle empty input
if (iterable.length === 0) {
return;
}
// Step 2 — Iterate and normalize each input
iterable.forEach((item) => {
Promise.resolve(item).then(resolve, reject);
});
});
}
Explanation:
- We don't need to count promises here because race finishes as soon as one settles.
- Using
.then(resolve, reject)ensures both fulfilled and rejected promises are handled properly. - It is important not to use
.catch()here because of its delayed scheduling behavior.
Example:
promiseRace([
Promise.reject(42),
Promise.resolve(2)
]);
If .catch() was used, the second promise might resolve first because .then() runs immediately, while .catch() is scheduled for later.
Task 3 — Implement promiseAllSettled()
function promiseAllSettled(iterable) {
return new Promise((resolve) => {
const results = [];
let settledCount = 0;
// Step 1 — Handle empty input
if (iterable.length === 0) {
resolve([]);
return;
}
// Step 2 — Iterate through all values
iterable.forEach((item, index) => {
Promise.resolve(item)
.then((value) => {
results[index] = { status: 'fulfilled', value };
})
.catch((reason) => {
results[index] = { status: 'rejected', reason };
})
.finally(() => {
settledCount++;
// Step 3 — Resolve when all promises have settled
if (settledCount === iterable.length) {
resolve(results);
}
});
});
});
}
Explanation:
- Every promise is handled independently — fulfilled or rejected.
- No promise can prevent others from being processed.
- The result is always a full array of statuses for all promises.
- The original order of input is preserved.
Summary: Why These Advanced Promise Tasks Matter
Re-implementing Promise.all(), Promise.race(), and Promise.allSettled() is a very popular interview topic, especially at mid-to-senior level interviews in companies like Google, Amazon, Microsoft, or TikTok.
These questions test:
- Your practical knowledge of Promise mechanics.
- Your ability to handle concurrency, ordering, and asynchronous state management.
- Your attention to edge cases (empty input, non-promise values, synchronous vs asynchronous resolutions).
- Your understanding of microtask scheduling and correct callback placement.
Even if you're not directly asked to implement these exact functions, the reasoning patterns behind them — counting completions, preserving order, resolving early, or normalizing input — are extremely transferable to many asynchronous problems that appear at any technical level.
If you feel confident with these tasks, you are much better prepared for most real-world Promise-related interview questions.
Summary
We covered a lot of ground in this article — from basic callbacks to how Promises work internally, and finally to real-world interview tasks that many companies love to ask.
By now, you should not only feel confident using Promises in your everyday work, but also fully understand how they behave under the hood:
- How Promise state changes work (
pending→fulfilled/rejected). - How chaining,
.then(),.catch(), and.finally()actually operate. - How to transform callback-based APIs using
promisify(). - How to reimplement core Promise combinators like
Promise.all(),Promise.race(), andPromise.allSettled()— and why these patterns come up again and again in real-world asynchronous problems.
These patterns are not just "academic interview questions." The logic you practiced here — handling async flows, managing concurrency, respecting execution order, catching edge cases — will directly help you write better production code and handle more complex systems confidently.
And most importantly: if you can deeply understand Promises, you're already far ahead of many candidates at real interviews.
Top comments (0)