Promises in JavaScript have always had a firm grip on their own destiny. The point at which one resolves or rejects (or, more colloquially, “settles”) is up to the executor function provided when the promise is constructed. A simple example:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
if(Math.random() < 0.5) {
resolve("Resolved!")
} else {
reject("Rejected!");
}
}, 1000);
});
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
Code language: JavaScript (javascript)
The design of this API impacts how we structure asynchronous code. If you’re using a promise, you need to be OK with it owning the execution of that code.
Most of the time, that model is fine. But occasionally, there are cases when it would be nice to control a promise remotely, resolving or rejecting it from outside the constructor. I was going to use “remote detonation” as a metaphor here, but hopefully your code is doing something less… destructive. So let’s go with this instead: you hired an accountant to do your taxes. They could follow you around, crunching numbers as you go about your day, and they let you know when they are finished. Or, they could do it all from their office across town and ping you with the results. The latter is what I’m getting at here.
Typically, this sort of thing has been accomplished by reassigning variables from an outer scope and then using them when needed. Building on that example from earlier, this is what that outer scope method is like:
let outerResolve;
let outerReject;
const promise = new Promise((resolve, reject) => {
outerResolve = resolve;
outerReject = reject;
});
// Settled from _outside_ the promise!
setTimeout(() => {
if (Math.random() < 0.5) {
outerResolve("Resolved!")
} else {
outerReject("Rejected!");
}
}, 1000);
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
Code language: JavaScript (javascript)
It gets the job done, but it feels a little ergonomically off, particularly since we need to declare variables in a broader scope, only for them to be reassigned later on.
A More Flexible Way to Settle Promises
The new Promise.withResolvers()
method makes remote promise settlement much more concise. The method returns an object with three properties: a function for resolving, a function for rejecting, and a fresh promise. Those properties can be easily destructured and made ready for action:
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => {
if (Math.random() < 0.5) {
resolve('Resolved!');
} else {
reject('Rejected!');
}
}, 1000);
promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});
Code language: JavaScript (javascript)
Since they come from the same object, the resolve()
and reject()
functions are bound to that particular promise, meaning they can be called wherever you like. You’re no longer tied to a constructor, and there’s no need to reassign variables from a different scope.
Exploring Some Examples
It’s a simple feature, but one that can breathe fresh air into how you design some of your asynchronous code. Let’s look at a few examples.
Slimming Down Promise Construction
Let’s say we’re triggering a job managed by a web worker for some resource-heavy processing. When a job begins, we want to represent it with a promise, and then handle the outcome based on its success. To determine that outcome, we’re listening for three events: message
, error
, and messageerror
. Using a traditional promise, that’d mean wiring up something like this:
const worker = new Worker("/path/to/worker.js");
function triggerJob() {
return new Promise((resolve, reject) => {
worker.postMessage("begin job");
worker.addEventListener('message', function (e) {
resolve(e.data);
});
worker.addEventListener('error', function (e) {
reject(e.data);
});
worker.addEventListener('messageerror', function(e) {
reject(e.data);
});
});
}
triggerJob()
.then((result) => {
console.log("Success!");
})
.catch((reason) => {
console.error("Failed!");
});
Code language: JavaScript (javascript)
That’ll work, but we’re stuffing a lot into the promise itself. The code becomes a more laborious to read, and you’re bloating the responsibility of the triggerJob()
function (there’s more than just “triggering” going on here).
But with Promise.withResolvers()
we have more options for tidying this up:
const worker = new Worker("/path/to/worker.js");
function triggerJob() {
worker.postMessage("begin job");
return Promise.withResolvers();
}
function listenForCompletion({ resolve, reject, promise }) {
worker.addEventListener('message', function (e) {
resolve(e.data);
});
worker.addEventListener('error', function (e) {
reject(e.data);
});
worker.addEventListener('messageerror', function(e) {
reject(e.data);
});
return promise;
}
const job = triggerJob();
listenForCompletion(job)
.then((result) => {
console.log("Success!");
})
.catch((reason) => {
console.error("Failed!");
})
Code language: JavaScript (javascript)
This time, triggerJob()
really is just triggering the job, and there’s no constructor stuffing going on. Unit testing is likely easier too, since the functions are more narrow in purpose with fewer side effects.
Waiting for User Action
This feature can also make handling user input more interesting. Let’s say we have a <dialog>
prompting a user to review a new blog comment. When the user opens the dialog, “approve” and “reject” buttons appear. Without using any promises, handling those button clicks might look like this:
reviewButton.addEventListener('click', () => dialog.show());
rejectButton.addEventListener('click', () => {
// handle rejection
dialog.close();
});
approveButton.addEventListener('click', () => {
// handle approval
dialog.close();
});
Code language: JavaScript (javascript)
Again, it works. But we can centralize some of that event handling using a promise, while keeping our code relatively flat:
const { promise, resolve, reject } = Promise.withResolvers();
reviewButton.addEventListener('click', () => dialog.show());
rejectButton.addEventListener('click', reject);
approveButton.addEventListener('click', resolve);
promise
.then(() => {
// handle approval
})
.catch(() => {
// handle rejection
})
.finally(() => {
dialog.close();
});
Code language: JavaScript (javascript)
Here’s how more fleshed-out implementation might look:
With this change, the handlers for the user’s actions don’t need to be sprinkled across multiple event listeners. They can be colocated more easily, and save a bit of duplicate code too, since we can place anything that needs to run for every action in a single .finally()
.
Reducing Function Nesting
Here’s one more example highlighting the subtle ergonomic benefit of this method. When debouncing an expensive function, it’s common to see everything self-contained to that single function. There’s usually no value being returned.
Think of a live search form. Both the request and UI updates are likely handled in the same invocation.
function debounce(func) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, 1000);
};
}
const debouncedHandleSearch = debounce(async function (query) {
// Fetch data.
const results = await search(query);
// Update UI.
updateResultsList(results);
});
input.addEventListener('keyup', function (e) {
debouncedHandleSearch(e.target.value);
});
Code language: JavaScript (javascript)
But you might have good reason to debounce only the asynchronous request, rather than lumping the UI updates in with it.
This means augmenting debounce()
to return a promise that’d sometimes resolve to the result (when the request is permitted to go through). It’s not very different from the simpler timeout-based approach. We just need to make sure we properly resolve or reject a promise as well.
Prior to Promise.withResolvers()
being available, the code would’ve looked very… layered:
function asyncDebounce(callback) {
let timeout = null;
let reject = null;
return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);
return new Promise((res, rej) => {
reject = rej;
timeout = setTimeout(() => {
res(callback.apply(this, args));
}, 500);
});
};
}
Code language: JavaScript (javascript)
That’s a dizzying amount of function nesting. We have a function that returns a function, which constructs a promise accepting a function containing a timer, which takes another function. And only in that function can we call the resolver, finally invoking the function provided like 47 functions ago.
But now, we could streamline things at least a little bit:
function asyncDebounce(callback) {
let timeout = null;
let resolve, reject, promise;
return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);
({ promise, resolve, reject } = Promise.withResolvers());
timeout = setTimeout(() => {
resolve(callback.apply(this, args));
}, 500);
return promise;
};
}
Code language: JavaScript (javascript)
Updating the UI while discarding the rejected invocations could then look something like this:
input.addEventListener('keyup', async function (e) {
try {
const results = await debouncedSearch(e.target.value);
appendResults(results);
} catch (e) {
// Discard exceptions from intentionally rejected
// promises, but let everything else throw.
if(e !== 'rejected_pending') {
throw e;
}
}
});
Code language: JavaScript (javascript)
And we’d get the same desired experience, without bundling everything up into a single void
function:
It’s not a dramatic change, but one that smooths over some of the rough edges in accomplishing such a task.
A Tool for Keeping More Options Open
As you can see, there’s nothing conceptually groundbreaking introduced with this feature. Instead, it’s one of those “quality of life” improvements. Something to ease the occasional annoyance in architecting asynchronous code. Even so, I’m surprised by how frequently I’m beginning to see more use cases for this tool in my day-to-day, along with many of the other Promise properties introduced in the past few years.
If anything, I think it all verifies how foundational and valuable Promise-based, asynchronous development has become, whether it’s run in the browser or on a server. I’m eager to see how much we can continue to level-up the concept and its surrounding APIs in the future.
This is really insightful. Very helpful article!
This is great! I learned something new.