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.
- 01Promisify callback functionsTurn an error-first callback API into a promise-returning function.
- 02Spot wrapper limitsExplain extra callback results, lost
this, repeated callbacks, and non-error-first APIs. - 03Design clean async APIsReturn promises consistently, reject with Error objects, and document resolved values.
Why promisify?
A lot of JavaScript was written before promises became the everyday way to represent future results. Those older APIs often say, “give me a callback and I’ll call it later.” Promisification means wrapping that callback API in a new function that returns a promise instead.
You already met callbacks in the Callbacks lesson and promises in the Promises lesson. This lesson connects them: old socket, new plug. The old work stays the same; the calling style becomes promise-friendly.
If your charger does not fit the hotel socket, you do not rebuild the hotel. You add an adapter. A promisifier is that adapter: your newer promise code plugs into an older callback-shaped function.
- In real life: A wall socket in another country
- In JavaScript: An old callback API
- In real life: Your laptop charger
- In JavaScript: Your promise-based code
- In real life: The travel adapter
- In JavaScript: A
promisifywrapper - In real life: Electricity still comes from the same wall
- In JavaScript: The original async work still does the real job
Where the analogy stops: An adapter does not make electricity faster or safer. Likewise, promisify does not make the underlying work faster, cancellable, or repeated-event friendly by itself.
Promisification wraps a function that reports success or failure through callbacks and exposes a new function that reports success or failure by resolving or rejecting a promise.
Writing a promisify helper
STEP THROUGHThe common Node-style callback convention is called error-first: the callback receives (err, result). If err is an Error, the operation failed. If err is null, the next argument is the success value.
function readConfigPromise(name) { return new Promise((resolve, reject) => { readConfig(name, (err, config) => { if (err) { reject(err); return; } resolve(config); }); });}A generic helper does the same pattern for many functions: return a new function, create a promise, call the original function with all normal arguments, and add one final callback that resolves or rejects.
Change the config name, predict whether the wrapper resolves or rejects, then step through the adapter.
script
function readConfig(name, callback) { setTimeout(() => { if (name === "missing") { callback(new Error("No config named " + name)); return; } callback(null, { name, theme: "dark" }); }, 10);} return function (...args) { return new Promise((resolve, reject) => { fn(...args, (err, value) => { if (err) reject(err); else resolve(value); }); }); };} const readConfigPromise = promisify(readConfig);readConfigPromise("app") .then(config => console.log(config.theme)) .catch(error => console.log(error.message));Where generic wrappers break
INTERACTIVEA generic promisifier assumes two things: the callback is the last argument, and the callback is error-first. That covers many Node APIs, but not every API you meet in browsers, UI libraries, or old codebases.
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)); });}generic works: user:ADA
- Assumptioncallback last,
(err, value) - Lessongeneric wrappers have limits
generic works: user:ADA
| Case | What happens | Better wrapper |
|---|---|---|
| Error-first callback | Works: (err, value) maps cleanly to reject or resolve. | A generic promisify(fn) is fine. |
| Non-error-first callbacks | A loader with onLoad and onError has no single (err, value) callback. | Write a custom promise around both callbacks. |
| Two success values | Default wrappers keep one fulfillment value, so width, height loses one. | Resolve an object like { width, height }. |
Method uses this | Passing obj.method detaches it from obj. | Use obj.method.bind(obj) or close over obj. |
| Callback fires more than once | The promise settles once; later callback calls are ignored. | Use events or async iterators for streams and clicks. |
Node’s util.promisify
NODENode has shipped util.promisify since Node 8. It follows the same error-first, callback-last convention and returns a new function. In modern Node, many modules also expose promise-native APIs, so you often do not need an adapter 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);Some callback APIs have unusual success values. A function can define util.promisify.custom, a symbol, to tell util.promisify what promise wrapper to use instead of the default one.
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"));By default, util.promisify resolves with the first success value after err. Extra results are dropped unless the function defines a custom promisified version.
Wrapping timers, images, and geolocation
INTERACTIVEBrowser APIs do not all use Node’s error-first style. You still use the same promise constructor idea, but the wrapper must match the original API: setTimeout has one success callback, img.onload and img.onerror are separate events, and geolocation has success and error callbacks plus user permission.
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); });}Choose a wrapper and run it. Nothing asks for permission on load.
Geolocation errors use codes 1 permission denied, 2 position unavailable, and 3 timeout.
Choose a wrapper and run it. Nothing asks for permission on load.
Geolocation requires a secure context such as HTTPS or localhost and the user’s permission. Its error codes are 1 PERMISSION_DENIED, 2 POSITION_UNAVAILABLE, and 3 TIMEOUT. Never prompt on page load; only ask after a clear user action.
Designing promise-based APIs
SORTA vending machine would be annoying if candy fell out instantly sometimes, appeared in a side drawer other times, and occasionally shouted an error with no label. Good promise APIs use one slot: a promise every time.
- In real life: Every snack comes from the same slot
- In JavaScript: Every async function returns a promise
- In real life: A clear out-of-stock message
- In JavaScript: Reject with an Error object
- In real life: A cancel button before the vend finishes
- In JavaScript: Accept an
AbortSignaloption for long work
Where the analogy stops: A vending machine hides many details. API design should document the details: what it resolves with, when it rejects, and whether cancellation is supported.
- Always returns a promise, even when cached
- Calls its callback synchronously sometimes
- Rejects with Error objects or Error subclasses
- Rejects with plain strings like
"nope" - Accepts an
AbortSignaloption for long work - Resolves
{ width, height }instead of two positional values - Sometimes accepts a callback and sometimes returns a promise
- Throws synchronously from a promise-returning function for bad input
Sort each design choice into the side you would want in a promise-based API.
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();}In an async function, a bad argument becomes a rejected promise, so callers can handle validation, network errors, and server errors in one try/catch or .catch. Document what the promise resolves to, and avoid APIs that mix callback and promise styles in the same function.
Where you’ll use this
Promisification shows up when you gradually modernize older code. You might wrap a callback-only library so the rest of your app can use promise chains, or write browser wrappers so one feature can use the same error handling style as the rest of your promise code.
Callback style says, “give us your phone number; we’ll call you.” Promise style says, “take this token; it represents your order.” Promisification gives you the token while the old code keeps working.
- In real life: A shop takes your phone number
- In JavaScript: You pass a callback and the API controls when it calls
- In real life: A shop gives you an order token
- In JavaScript: The function returns a promise you can hold, pass around, and await
- In real life: The shop does the same work
- In JavaScript: The wrapper changes control flow, not the work itself
Where the analogy stops: A promise is not a physical token. Only the code that created it can settle it.
Common misconceptions
// 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);}- “Promisify makes work faster.” No. It changes the interface, not the timer, disk, network, or permission prompt underneath.
- “Every callback should become one promise.” No. Repeating events such as clicks, data streams, and progress updates do not fit one settlement.
- “Resolve can happen many times.” A promise settles once. Later resolve or reject calls are ignored.
- “Generic wrappers understand every callback shape.” They assume callback-last and error-first unless you customize them.
- “Rejecting a string is fine.” Prefer Error objects so catch blocks get a message, name, and debugging context.
| Question | Callback style | Promise style |
|---|---|---|
| Who holds the result? | The API calls your function later. | You hold a promise immediately. |
| Failure path | Usually first callback argument: err. | Rejected promise, caught with .catch or try/catch around await. |
| Multiple results | Callback can pass several positional values. | Prefer one object with named properties. |
| Repeated events | Callbacks can fire many times. | One promise settles once; use events or async iterators later. |
Practice exercises
5 EXERCISESRead the program. What does it print after the microtask runs?
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 wins, so the .then handler prints first. The second callback call is ignored by the promise machinery.
sleep(ms) wrapperCreate a function that returns a promise fulfilled by a timer.
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 later. The returned promise fulfills after about ms milliseconds, so callers can use .then or await.
this bugWhich method fixes a function that lost its object as this?
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) creates a function that always runs with this === reader, so the method can read this.prefix.
A callback calls cb(null, "first", "second"). What success value does the default promisified function keep?
Default util.promisify resolves with the first success value after err. Use util.promisify.custom or your own wrapper to return { width, height }.
When a user says no to the browser’s location prompt, which standard geolocation error code should your rejection expose?
Geolocation permission denial is code 1, named PERMISSION_DENIED. Code 2 means position unavailable; code 3 means timeout.
Check your understanding
7 QUESTIONSQuestion 1 of 7What convention does a generic Node-style promisify helper assume?
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 7By default, what happens to extra success values in
util.promisify?Choose an answer to see the explanation.
Question 5 of 7Which browser wrapper should only run after a user clicks a button?
Choose an answer to see the explanation.
Question 6 of 7Which design avoids releasing Zalgo?
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 adapts callback APIs; it does not change the underlying work.
- Generic wrappers assume callback-last and error-first:
(err, value). util.promisifykeeps the first success value unless a custom symbol overrides it.- Promises settle once, so repeating events need events or async iterators instead.
- Good async APIs always return promises, reject with Error objects, and document their resolved value.
Promisification is the adapter that turns “I’ll call your callback” into “here is a promise for the result.”
Up next: async & await.