JavaScript Promises are Kinda Confusing
Here's how I finally wrapped my head around them...
When I first started learning JavaScript (questionable life choice), I spent ages feeling completely baffled by the event loop, Promises, and how callbacks tie everything together. I highly recommend Philip Robert’s JSConf EU talk regarding the JavaScript event loop. In theory, the event loop handles long-running tasks (e.g., network requests) by shunting them off so the main thread can quickly update the UI. This explanation never stuck with me. How does it actually work? I’m writing this blog post about it so I don’t have to rewatch Philips talk every few months for interviews.
JavaScript is a Single-Threaded, Non-Blocking, Asynchronous, Concurrent Language
It’s a mouthful. JavaScript is single-threaded. You can think of a thread like a car lane. Multi-threaded languages can utilize multiple car lanes to handle their traffic (i.e., tasks). JavaScript can only utilize one lane, meaning one car can jam up traffic. The Call Stack is how JavaScript organizes and manages active function calls on the main thread.
Sometimes, we need to perform operations which may clog up this Call Stack. Let's say we have a function myTimeout
.
function myTimeout(duration, callback) {
const startTime = Date.now();
// Just block the thread for the duration
while (Date.now() - startTime < duration) {}
// Once the loop finishes, call the callback
callback();
}
Functions passed in as arguments are called Callback Functions. Whenever myTimeout
is called, it will block our main thread for duration
ms before calling callback
.
Issue #1: Blocking the Main Thread
The main thread of a browser is responsible for updating the UI, ideally aiming for 60 frames per second (fps). That's a frame approximately every 16.67 ms. To maintain a smooth user experience, the main thread must complete all tasks (including rendering and processing user interactions) within each frame's time slot. If we throw a myTimeout
into the mix, we clog up the main thread for duration
ms. This renders the UI unresponsive within that duration.
You can try this yourself.
<div className="App">
<h1>Count: {count}</h1>
<button
onClick={() => {
myTimeout(1000, () => setCount((prev) => prev + 1));
}}
>
Increment
</button>
</div>
The asynchronous version of our myTimeout
function would be the setTimeout
method in JavaScript. Other asynchronous tasks can include event handlers, timers, and file system operations. One of the most common ones is fetch
used to execute HTTP requests.
Let's say we've built a collaborative document editor called "Boogle Docs." Our editor should handle multiple users on one document and show changes in real time. Whenever a user makes a change or moves their cursor, other users should see this. One way to achieve this is by sending HTTP GET requests at intervals to fetch other users' data from a server. This is called polling. We’ll also need to send POST requests to update our own data. Network requests can be slow due to reasons beyond JavaScript’s control. If the main thread froze every time we fetched updates, our website would render at a painfully slow rate, and we'd lose all of our users (if it wasn't already for the lazy branding)!
But do not fear, the event loop is here!
Solution #1: The Event Loop
The event loop is best described visually, so if this is your first time hearing of it, go watch the talk! Your browser provides the JavaScript runtime with WebAPIs, a rich environment of platform services (e.g., additional threads, system APIs) which asynchronous tasks can utilize. Asynchronous methods (e.g., setTimeout
, fetch
) offload their heavy lifting onto that environment. This frees up the main thread for high-priority tasks, such as rendering frames.
WebAPIs resolve asynchronous tasks, then queue up their callbacks into the Callback Queue. The Callback Queue constantly checks the main thread to see if it's free. As soon as the main thread is free, the Callback Queue pushes the next callback function onto the main threads Call Stack.
This is known as the Event Loop. The Event Loop prioritizes synchronous operations over asynchronous ones.
Issue #2: Callback Hell
How can we use the results of an asynchronous operation? With myTimeout
, we used a callback function. After a set amount of time, our callback function will be executed. What if we need to execute another myTimeout
after the first one? Then we pass the next myTimeout
as a callback to the first myTimeout
.
myTimeout(10000, () => {
// Maybe some code runs here
myTimeout(9000, () => {
// Maybe some code runs here
myTimeout(8000, () => {
// Maybe some code runs here
myTimeout(7000, () => {
// Maybe some code runs here
myTimeout(6000, () => {
// Maybe some code runs here
myTimeout(5000, () => {
// Maybe some code runs here
myTimeout(4000, () => {
// Maybe some code runs here
myTimeout(3000, () => {
// Maybe some code runs here
myTimeout(2000, () => {
// Maybe some code runs here
myTimeout(1000, () => {
console.log('Welcome to hell! 😈');
});
});
});
});
});
});
});
});
});
});
It's more practical to imagine this with fetch
requests, where one request relies on another requests data. This pattern is commonly referred to as Callback Hell. Promise me you won't do this at your first job.
Solution #2: Promises
Promises resolve this issue in JavaScript by providing a new way of handling callbacks. When you create a new Promise
, you pass a callback function into the constructor. This function has two arguments: resolve
and reject
, both are callback functions themselves.
const promise = new Promise((resolve, reject) => {
// TBD...
});
Within this Promise
, we might use setTimout
. We’ll pass a callback function into the setTimeout
that performs some operations after the time is done, then calls the resolve
callback at the end. The resolve
function takes in the result of the operation, and passes it to the next task.
// Promise Chaining
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(5);
}, 1000);
}).then((res) => {
console.log(res); // 5
return new Promise((resolve, reject) {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
}).then((res) => {
console.log(res); // 10
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
}).then((res) => {
console.log(res); // 15
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
});
The resolve
function passes the value to the callback(s) passed to then
. In order to understand the system, we’ll begin building our own implementation of the Promise
class.
// Example implementation of the resolve function within the Promise class
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(callback => callback(value));
}
};
The then
method can be called multiple times on one Promise
. This is different from promise chaining as it attaches independent callbacks that will all be triggered when the Promise
resolves. Each callback passed to then
is stored in an array, and resolve
iterates through it.
// Not Promise Chaining
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(5);
}, 1000);
})
promise.then((res) => {
console.log(res); // 5
return new Promise((resolve, reject) {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
})
promise.then((res) => {
console.log(res); // 5
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
})
promise.then((res) => {
console.log(res); // 5
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(res + 5);
}, 1000);
});
});
The then
method returns a Promise
, enabling promise chaining.
then(onFulfilled, onRejected) {
return new Promise((resolve, reject) => {
// TBD...
});
}
A handler function is defined within the then
method where the then
callback is executed. The return value of the callback is stored in result
. If result
is not a Promise
, it’s passed to the next then
method in the chain. Since then
returns a Promise
, this passing of result
is done through it’s resolve
function.
// Example implementation of the then method within the Promise class
then(onFulfilled, onRejected) {
return new Promise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
const result = onFulfilled(value);
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (error) {
reject(error);
}
};
// ...
});
}
If result
is a Promise
, then the resolve
and reject
callbacks are passed into that returned promise’s then
method.
The handler is then appended onto the callbacks array of the Promise
. Rejections are handled in a similar fashion to resolutions, with minor changes.
Bringing this all together, we have:
class Promise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(callback => callback(value));
}
};
const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(callback => callback(reason));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new Promise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
const result = onFulfilled(value);
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (error) {
reject(error);
}
};
const handleRejected = (reason) => {
try {
const result = onRejected ? onRejected(reason) : reason;
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
reject(result);
}
} catch (error) {
reject(error);
}
};
if (this.state === 'fulfilled') {
handleFulfilled(this.value);
} else if (this.state === 'rejected') {
handleRejected(this.reason);
} else {
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
}
Promise chaining is a hell of a syntactic upgrade from Callback Hell, but it still leaves some more to be desired. It can be tedious to write, and even more so to read.
Bonus Solution: Async/Await
Async/await was introduced in ES2017 as a form of syntactic sugar over Promises. It provides the same functionality, while encouraging clean and readable code. Here’s an implementation of our setTimeout
promise chain using async/await syntax.
async function timeouts() {
let res = await new Promise((resolve, reject) => {
setTimeout(() => resolve(5), 1000);
});
console.log(res); // 5
res = await new Promise((resolve, reject) => {
setTimeout(() => resolve(res + 5), 1000);
});
console.log(res); // 10
res = await new Promise((resolve, reject) => {
setTimeout(() => resolve(res + 5), 1000);
});
console.log(res); // 15
res = await new Promise((resolve, reject) => {
setTimeout(() => resolve(res + 5), 1000);
});
}
timeouts();
Error handling can become a huge pain with async/await’s reliance on try/catch blocks, but that’s out of the scope of this blog post.
Conclusion
Promises are still confusing as hell. I have a higher appreciation for them now that I (sorta) understand how they work. I’ll leave you with this fun exercise I got from this awesome course hosted by Lydia Hallie at FrontendMasters.
// Put the logs in the correct order
Promise.resolve()
.then(() => console.log(1));
queueMicrotask(() => console.log(2));
setTimeout(() => console.log(3), 0);
console.log(4);
new Promise(() => console.log(5));
(async () => console.log(6))();