Promisification & promise-based APIs
Wrap callback-based JavaScript APIs in promises, understand Node’s util.promisify, and design async functions that are predictable to use.
- 01Wrap a callback function in a promiseWrite a function that calls an old error-first callback function and returns a promise instead.
- 02Know when a simple wrapper isn't enoughSpot four cases that need a custom wrapper: two callbacks, two results, a method that uses
this, and a callback that runs many times. - 03Design promise functions that are easy to useAlways return a promise, reject with an
Errorobject, and say what the promise fulfills with.
Why turn callbacks into promises?
Many older JavaScript functions were written before promises were common. They don't return their result. Instead, you give them a callback: a function they call later, with the result.
A promise is an object that stands for a result that isn't ready yet. Later, the promise is either fulfilled with a value, or rejected with an error.
Promisification means writing a new function around an old callback function. The new function is called a wrapper. It returns a promise, so you don't pass it a callback.
You met callbacks in the Callbacks lesson and promises in the Promises lesson. This lesson joins the two. The old function still does the same work. Only the way you get the result changes.
You travel abroad, and your laptop charger doesn't fit the hotel's wall socket. You don't rebuild the wall. You put a small travel adapter between the charger and the socket. The power still comes from the same wall.
- In real life: The wall socket in a hotel abroad
- In JavaScript: An old function that takes a callback
- In real life: Your laptop charger
- In JavaScript: Your code, which uses promises
- In real life: The travel adapter between them
- In JavaScript: The wrapper, which returns a promise
- In real life: The power still comes from the same wall
- In JavaScript: The old function still does the real work
Promisification means wrapping a function that reports its result through a callback. The wrapper returns a promise instead. If the callback gets a result, the promise is fulfilled with it. If the callback gets an error, the promise is rejected with it.
function getName(callback) {
callback(null, "Asha");
}
function getNameAsync() {
return new Promise((resolve, reject) => {
getName((err, name) => err ? reject(err) : resolve(name));
});
}
getNameAsync().then(name => console.log(name));In plain words, line by line:
- Lines 1 to 3:
getNameis the old function. It calls its callback with two values:null(meaning "no error") and"Asha". - Lines 5 to 9:
getNameAsyncis the wrapper. Line 6 makes a new promise and returns it. While making it, JavaScript hands you two functions:resolvefulfills the promise with a value, andrejectrejects it with an error. - Line 7 calls
getNameand passes it a callback. The? :picks one of two actions: iferrholds an error, callreject(err); if not, callresolve(name). - Line 11 calls the wrapper and gets a promise.
.thenruns when the promise is fulfilled, so it printsAsha.
getName still calls back with null and "Asha". The wrapper, getNameAsync, passes that value on to a promise, which is fulfilled with "Asha".Writing a promisify helper
STEP THROUGHMany functions in Node.js (JavaScript that runs outside the browser) follow one rule for callbacks. It's called error-first. The callback gets two arguments, (err, result):
- If the work failed,
erris anErrorobject. - If the work worked,
errisnullandresultholds the value.
Here is a wrapper for one error-first function, readConfig. It reads a settings object, called a config, by its name.
function readConfigPromise(name) { return new Promise((resolve, reject) => { readConfig(name, (err, config) => { if (err) { reject(err); return; } resolve(config); }); });}- Line 2 makes a new promise and returns it.
- Line 3 calls the old
readConfigfunction. It adds a callback as the last argument. - Line 5 runs if
errholds an error. It rejects the promise, and line 6 stops the callback there. - Line 8 runs if there was no error. It fulfills the promise with
config.
err first. An error rejects the promise; null means it worked, so the promise is fulfilled with config.A promisify helper does the same job for any error-first function, not just one. You give it the old function. It gives you back a new function that does four things:
- It takes the same arguments as the old function.
- It makes a new promise.
- It calls the old function with those arguments, plus one callback at the end.
- When that callback runs, it rejects or resolves the promise.
In the code below, ...args on line 13 collects all the arguments into one list. Line 15 calls the old function, fn, with those arguments, and adds the callback last.
Pick a config name. Guess whether the promise will be fulfilled or rejected. Then step through the wrapper.
script
function readConfig(name, callback) { setTimeout(() => { if (name === "missing") { callback(new Error("No config named " + name)); return; } callback(null, { name, theme: "dark" }); }, 10);} function promisify(fn) { return function (...args) { return new Promise((resolve, reject) => { fn(...args, (err, value) => { if (err) reject(err); else resolve(value); }); }); };} readConfigPromise("app") .then(config => console.log(config.theme)) .catch(error => console.log(error.message));When one wrapper doesn't fit
INTERACTIVEA promisify helper that works for many functions is called generic. A generic helper expects two things from the old function:
- The callback is the last argument.
- The callback is error-first: it gets
(err, value).
Many Node.js functions follow both rules. Some functions in browsers, libraries, and old code don't. In the playground below, pick a kind of old function and see if the generic wrapper fits.
const fsLike = { prefix: "user:", readFile(name, cb) { cb(null, this.prefix + name.toUpperCase()); }}; function genericPromisify(fn) { return (...args) => new Promise((resolve, reject) => { fn(...args, (err, value) => err ? reject(err) : resolve(value)); });}Fits. The callback comes last and gets (err, value), so the generic wrapper works as it is.
- The generic wrapper expectsthe callback last, called with
(err, value) - Sosome functions need their own wrapper
Fits. The callback comes last and gets (err, value), so the generic wrapper works as it is.
function readSize(callback) {
callback(null, 640, 480);
}
function readSizeAsync() {
return new Promise((resolve, reject) => {
readSize((err, width, height) => {
if (err) reject(err);
else resolve({ width, height });
});
});
}
readSizeAsync().then(size => console.log(size.width, size.height));On line 2, readSize calls back with two values: a width, 640, and a height, 480. A promise holds only one value, so a generic wrapper would keep just the width. This custom wrapper puts both into one object on line 9, { width, height }, and fulfills the promise with it. So line 14 prints 640 480.
640. The custom wrapper puts both into one object.const reader = {
prefix: "file:",
read(name, callback) {
callback(null, this.prefix + name);
}
};
function promisifyOne(fn) {
return (...args) => new Promise((resolve, reject) => {
fn(...args, (err, value) => err ? reject(err) : resolve(value));
});
}
const readAsync = promisifyOne(reader.read.bind(reader));
readAsync("notes.txt").then(console.log);reader.read is a method: a function stored on the reader object. On line 4 it uses this.prefix, and this must be reader for that to work.
But promisifyOne(reader.read) would pass only the function, without reader. Then this would not be reader. On line 14, .bind(reader) makes a copy of the function where this is always reader. So line 15 prints file:notes.txt.
function watchOnce(callback) {
callback(null, "first");
callback(null, "second");
}
const promise = new Promise((resolve, reject) => {
watchOnce((err, value) => err ? reject(err) : resolve(value));
});
promise.then(value => console.log(value));watchOnce calls its callback twice: first with "first", then with "second". A promise can settle (become fulfilled or rejected) only once. The first resolve wins, and the second call is ignored. So line 10 prints only first.
Some things happen many times, like clicks, progress updates, or pieces of a download. Don't turn those into one promise. Keep the event listener, or use an async iterator: a loop that waits for each new value. You'll meet async iterators in a later lesson.
The table sums up this section. Each row is one kind of old function. Read across to see what a generic wrapper does with it, and what to use instead.
| The old function | What a generic wrapper does | What to use instead |
|---|---|---|
Its callback gets (err, value). | It works. An error rejects the promise, and a value fulfills it. | The generic promisify(fn) is fine. |
It has two callbacks, like onLoad and onError. | It doesn't fit. There is no single (err, value) callback. | Write a custom wrapper that uses both callbacks. |
| Its callback gets two results, like a width and a height. | It keeps only the first result, so the height is lost. | Fulfill with one object, like { width, height }. |
It's a method that uses this. | Passing obj.method passes the function without obj, so this is wrong. | Pass obj.method.bind(obj) instead. |
| Its callback runs many times. | The promise settles once. Later calls are ignored. | Keep the events, or use an async iterator. |
Node's util.promisify
NODENode.js has a promisify helper built in: util.promisify, from the node:util module. It has been there since Node 8. It follows the same two rules: the callback comes last, and it is error-first. It gives back a new function that returns a promise.
Many Node modules now come with promise versions built in, too. For example, node:fs/promises reads files and node:timers/promises waits. When a promise version exists, use it. Then you don't need a wrapper at all.
import { promisify } from "node:util";import { readFile } from "node:fs";import { readFile as readFilePromise } from "node:fs/promises";import { setTimeout as sleep } from "node:timers/promises"; const readFileAsync = promisify(readFile);const text = await readFileAsync("package.json", "utf8");const sameText = await readFilePromise("package.json", "utf8");await sleep(100);- Line 1 imports
promisify. Line 2 imports the old callback version ofreadFile. - Line 6 wraps the old
readFile. Line 7 calls the wrapper and waits for the file's text. - Line 3 imports the promise version of
readFile. Line 8 uses it with no wrapper. - Line 4 imports a promise version of
setTimeout, namedsleep. Line 9 waits 100 milliseconds.
This example runs in Node, not in the editor on this page. It imports node: modules and reads a file from your computer, which a web page can't do.
Some callbacks get more than one result. A function like that can bring its own promise version. It stores it under a special key, util.promisify.custom. That key is a symbol: a unique value that can be used as a property name. When util.promisify finds a function under that key, it gives back that function instead of making its own wrapper.
import { promisify } from "node:util"; function size(path, callback) { callback(null, 640, 480);} size[promisify.custom] = path => Promise.resolve({ path, width: 640, height: 480 }); const sizeAsync = promisify(size);console.log(await sizeAsync("hero.png"));- Line 4:
sizecalls back with two results, 640 and 480. - Lines 7 and 8 store a promise version under
promisify.custom. It fulfills with one object that holds both results. - Line 10:
promisify(size)finds that stored function and gives it back. - Line 11 prints
{ path: 'hero.png', width: 640, height: 480 }.
Without a custom version, util.promisify keeps only the first result after err. If the callback gets (err, width, height), the promise is fulfilled with width, and height is lost.
Wrapping timers, images, and location
INTERACTIVEBrowser functions don't follow Node's error-first rule. You can still wrap them with new Promise. But each wrapper has to match the function it wraps:
setTimeoutcalls one function when the time is up. It never fails.- An image has two events:
img.onloadruns when it loads, andimg.onerrorruns when it fails. navigator.geolocation.getCurrentPositionfinds where the device is. It takes a success callback and an error callback. It also asks the user for permission first.
const sleep = ms => new Promise(resolve => { setTimeout(resolve, ms);}); function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Image failed to load")); img.src = src; });} function getPosition(options) { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error("Geolocation is not available")); return; } navigator.geolocation.getCurrentPosition(resolve, reject, options); });}Pick a wrapper and run it. Nothing asks for permission until you press a button.
Location errors have a number: 1 means the user said no, 2 means the position couldn't be found, and 3 means it took too long.
Pick a wrapper and run it. Nothing asks for permission until you press a button.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1).then(() => console.log("tea is ready"));Line 2 makes a promise and starts a timer. When the timer fires, it calls resolve, which fulfills the promise with undefined. Line 5 waits 1 millisecond, then prints tea is ready.
Location needs extra care:
- It works only on a secure page: one served over HTTPS, or from
localhoston your own computer. - The user must give permission.
- When it fails, the error has a number in
error.code: 1 isPERMISSION_DENIED(the user said no), 2 isPOSITION_UNAVAILABLE(the device couldn't find its place), and 3 isTIMEOUT(it took too long).
Never ask for the location as soon as the page loads. Ask only after the user clicks a button that clearly needs it.
Designing promise functions
SORTOnce you can wrap other people's functions, you'll start writing your own functions that return promises. A few rules make them easy to use.
Picture a vending machine that drops the snack right away one time, puts it in a side drawer the next time, and sometimes just beeps with no message. Nobody would like it. A good machine always uses the same slot and shows a clear message when something is sold out.
- In real life: Every snack comes out of the same slot
- In JavaScript: Every async function returns a promise
- In real life: A clear "sold out" message
- In JavaScript: Reject with an
Errorobject that has a clear message - In real life: A cancel button that works before the snack drops
- In JavaScript: Take a
signaloption, so the caller can cancel long work
- Always returns a promise, even when the value is already saved
- Sometimes calls its callback right away, sometimes later
- Rejects with an
Errorobject, or a kind ofError - Rejects with a plain string like
"nope" - Takes a
signaloption so callers can cancel long work - Fulfills with
{ width, height }, not two separate values - Sometimes takes a callback, sometimes returns a promise
- Throws an error right away, not as a rejected promise, when the input is bad
Sort each choice. Does it make a function that returns a promise easy to use, or risky?
Here is a function that follows the rules. It loads a user's profile from a server.
async function getUserProfile(id, { signal } = {}) { if (typeof id !== "string") { throw new TypeError("id must be a string"); } const response = await fetch(`/api/users/${id}`, { signal }); if (!response.ok) { throw new Error(`Profile request failed: ${response.status}`); } return response.json();}- Line 1:
asyncmakes the function always return a promise. It also takes an optionalsignal. A signal (anAbortSignal) lets the caller cancel the request later. - Line 3: if
idisn't a string, it throws aTypeError. Inside anasyncfunction, athrowdoesn't escape right away. It rejects the returned promise. - Line 6:
fetchasks the server for the profile. It passes the signal along. - Line 8:
response.okis false when the server answers with an error, like 404 "not found". Then it throws anError, which also rejects the promise. - Line 11: on success, the promise is fulfilled with the profile data, read from the response as JSON.
So callers handle every problem in one place: a .catch, or a try/catch around await. Write down what the promise is fulfilled with. And don't let one function sometimes take a callback and sometimes return a promise.
Where you'll use this
You'll use promisification when you update older code a little at a time. For example:
- A library only has callback functions, but the rest of your app uses promises and
await. Wrap the library's functions once, and use the wrappers everywhere. - A browser feature uses events or callbacks. Wrap it, so it handles errors the same way as the rest of your promise code.
One shop says, "Give us your phone number. We'll call you when your food is ready." Another shop says, "Take this token. It stands for your order." The kitchen cooks the same food either way.
- In real life: The shop takes your phone number and calls you
- In JavaScript: You pass a callback, and the function decides when to call it
- In real life: The shop gives you an order token
- In JavaScript: The function returns a promise you can keep, pass around, and
await - In real life: The kitchen cooks the same food
- In JavaScript: The wrapper changes how you get the result, not the work
Common misconceptions
Some callback functions call back right away in one case and later in another. Programmers nicknamed this "releasing Zalgo", after a monster from internet jokes. Code that runs after the call can't know if the callback has run yet.
// Avoid: sometimes callback now, sometimes later.function getCached(key, callback) { if (cache.has(key)) callback(null, cache.get(key)); else setTimeout(() => callback(null, load(key)), 0);} // Prefer: one promise-shaped result every time.async function getCached(key) { if (cache.has(key)) return cache.get(key); return load(key);}- Line 3: when the value is already saved in
cache, the first version calls back right away. - Line 4: when it isn't saved, it calls back later, after a timer.
- Line 8: the second version is an
asyncfunction, so it always returns a promise. Your.thencode always runs later, saved or not.
async version always gives back a promise, so your .then code always runs later.Here are some common wrong ideas about promisification, and what is true instead:
- "Promisify makes the work faster." No. It changes how you get the result. The timer, the file, the network, and the permission prompt take just as long.
- "Every callback should become a promise." No. Things that happen many times, like clicks, downloads in pieces, and progress updates, don't fit one promise.
- "A promise can be resolved many times." No. A promise settles once. Later
resolveorrejectcalls are ignored. - "A generic wrapper fits every callback." No. It expects the callback last and error-first. Other shapes need a custom wrapper.
- "Rejecting with a string is fine." Use an
Errorobject. Then thecatchgets a message, a name, and a record of where it happened.
The table compares the two styles. Each row asks one question. Read across to see how callbacks answer it, and how promises do.
| Question | With a callback | With a promise |
|---|---|---|
| Who holds the result? | The function calls your callback later. | You get a promise right away and hold on to it. |
| How do you hear about a failure? | The callback usually gets it as its first argument, err. | The promise is rejected. You catch it with .catch, or with try/catch around await. |
| What if there are two results? | The callback can get several values, one after another. | Fulfill with one object that names each result. |
| What if it happens many times? | The callback can run many times. | A promise settles once. Use events, or an async iterator. |
Practice exercises
5 EXERCISESRead the program. What does it print?
function oldApi(callback) {
callback(null, "first");
callback(null, "second");
}
const once = (...args) => new Promise((resolve, reject) => {
oldApi((err, value) => err ? reject(err) : resolve(value));
});
once().then(value => console.log(value));The first resolve("first") fulfills the promise, so .then prints first. The second call to the callback runs, but the promise has already settled, so it is ignored.
sleep(ms) wrapperWrite a function that returns a promise. A timer should fulfill the promise after ms milliseconds.
function sleep(ms) {
// return a promise here
}const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
sleep(1).then(() => console.log("awake"));setTimeout calls resolve after about ms milliseconds. That fulfills the promise, so callers can use .then or await. Here .then prints awake.
this bugThis code uses reader and promisifyOne from the "Keep the object a method needs" example. Inside read, this is no longer reader. Which method fixes that?
const read = promisifyOne(reader.read);
read("notes.txt").then(console.log);const reader = {
prefix: "file:",
read(name, cb) { cb(null, this.prefix + name); }
};
const read = promisifyOne(reader.read.bind(reader));.bind(reader) makes a copy of the function where this is always reader. So the method can read this.prefix.
A callback is called as cb(null, "first", "second"). Which result does the promise from util.promisify keep?
util.promisify keeps only the first result after err, so the promise is fulfilled with "first". To keep both, give the function a custom version under util.promisify.custom, or write your own wrapper.
The browser asks the user to share their location, and the user says no. Which error code does your wrapper's rejection get?
Code 1 is PERMISSION_DENIED: the user said no. Code 2 means the position couldn't be found, and code 3 means it took too long.
Check your understanding
7 QUESTIONSQuestion 1 of 7A promisify helper that works for many Node functions expects their callbacks to follow a rule. Which one?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictlet settle; const p = new Promise(resolve => { settle = resolve; }); settle("first"); settle("second"); p.then(value => console.log(value));Choose an answer to see the explanation.
Question 3 of 7Why can passing
obj.methodtopromisifybreak?Choose an answer to see the explanation.
Question 4 of 7A callback gets
(err, width, height). What doesutil.promisifydo withheight?Choose an answer to see the explanation.
Question 5 of 7Which wrapper should run only after the user clicks a button?
Choose an answer to see the explanation.
Question 6 of 7Which design avoids "releasing Zalgo" (calling back sometimes now, sometimes later)?
Choose an answer to see the explanation.
Question 7 of 7What should a promise reject with?
Choose an answer to see the explanation.
Key takeaways
- Promisification wraps an old callback function so it returns a promise. The old function still does the same work.
- A generic wrapper expects the callback last and error-first:
(err, value). util.promisifykeeps only the first result aftererr, unless the function brings its own version underutil.promisify.custom.- A promise settles once. For things that happen many times, use events or async iterators.
- Good promise functions always return a promise, reject with an
Errorobject, and say what the promise is fulfilled with.
Promisification turns "I'll call your callback later" into "here is a promise for the result".
Up next: async & await.