How to Promisify Callback-Based Functions in JavaScript
Before promises became the standard for asynchronous programming, JavaScript relied almost entirely on callbacks. A function would accept a callback as its last argument, perform an async operation, and then call that callback with the result or error. Thousands of libraries and APIs were built around this pattern, and many still use it today.
Promisification is the process of converting a callback-based function into one that returns a promise. Instead of passing a callback, you get back a promise that you can await, chain with .then(), and handle errors with .catch(). This bridges the gap between older callback-style code and modern promise-based and async/await code, letting you use both seamlessly.
This guide shows you how promisification works, how to build a reusable helper that converts any callback-based function, and how to use Node.js's built-in util.promisify() for the same purpose.
What Is Promisification?
Promisification takes a function that communicates results through a callback and wraps it so that it communicates results through a promise instead.
The Callback Pattern
The classic callback pattern, especially in Node.js, follows a specific convention called error-first callbacks. The callback's first argument is an error (or null if no error occurred), and the remaining arguments are the result data:
// Typical callback-based function
function readFile(path, callback) {
// ... performs async work ...
// On success: callback(null, fileContents)
// On failure: callback(error)
}
// Usage
readFile("/path/to/file.txt", (err, data) => {
if (err) {
console.error("Failed:", err.message);
return;
}
console.log("File contents:", data);
});
The Promise Pattern
The same operation expressed as a promise-returning function:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFile(path, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
// Usage with .then()
readFilePromise("/path/to/file.txt")
.then(data => console.log("File contents:", data))
.catch(err => console.error("Failed:", err.message));
// Usage with async/await
const data = await readFilePromise("/path/to/file.txt");
console.log("File contents:", data);
This transformation is promisification. The function's behavior is identical, but the interface changes from callback-based to promise-based.
Why Promisify?
The benefits become clear when you compare real-world usage:
// Callback hell: nested, hard to read, error handling at every level
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
console.log("Details:", details);
});
});
});
// Promisified: flat, readable, single error handler
const user = await getUserPromise(userId);
const orders = await getOrdersPromise(user.id);
const details = await getOrderDetailsPromise(orders[0].id);
console.log("Details:", details);
Converting Callback-Based Functions to Promises
Let us work through several examples of manually promisifying different types of callback-based functions.
Simple Callback: One Result Value
The most common case. The callback receives an error and a single result value:
// Original callback-based function
function loadScript(src, callback) {
const script = document.createElement("script");
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Failed to load ${src}`));
document.head.appendChild(script);
}
// Promisified version
function loadScriptPromise(src) {
return new Promise((resolve, reject) => {
loadScript(src, (err, script) => {
if (err) {
reject(err);
} else {
resolve(script);
}
});
});
}
// Now you can use it with async/await
try {
const script = await loadScriptPromise("https://cdn.example.com/library.js");
console.log("Script loaded:", script.src);
} catch (err) {
console.error("Load failed:", err.message);
}
Callback with Multiple Result Values
Some callback-based functions pass multiple values to the callback. Since resolve() accepts only a single value, you need to combine them into an object or array:
// Original: callback receives (err, address, family)
function dnsLookup(hostname, callback) {
// Simulated DNS lookup
setTimeout(() => {
callback(null, "93.184.216.34", 4);
}, 100);
}
// Promisified: combine results into an object
function dnsLookupPromise(hostname) {
return new Promise((resolve, reject) => {
dnsLookup(hostname, (err, address, family) => {
if (err) {
reject(err);
} else {
resolve({ address, family });
}
});
});
}
const result = await dnsLookupPromise("example.com");
console.log(result.address); // "93.184.216.34"
console.log(result.family); // 4
Callback Without Error Parameter
Not all callback functions follow the error-first convention. Some simply pass the result directly:
// Non-standard callback: no error parameter
function getUserLocation(callback) {
navigator.geolocation.getCurrentPosition(
position => callback(position),
error => callback(null, error)
);
}
// Promisified: adapting to the non-standard format
function getUserLocationPromise() {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
position => resolve(position),
error => reject(error)
);
});
}
try {
const position = await getUserLocationPromise();
console.log("Latitude:", position.coords.latitude);
} catch (err) {
console.error("Location error:", err.message);
}
Preserving this Context
When promisifying methods that depend on this, you need to ensure the original function is called with the correct context:
class Database {
constructor(name) {
this.name = name;
}
// Callback-based method that uses 'this'
query(sql, callback) {
setTimeout(() => {
console.log(`[${this.name}] Executing: ${sql}`);
callback(null, [{ id: 1 }, { id: 2 }]);
}, 100);
}
}
const db = new Database("users_db");
// WRONG: 'this' is lost
function queryPromiseBroken(sql) {
return new Promise((resolve, reject) => {
db.query(sql, (err, rows) => { // Works here because db is hardcoded
if (err) reject(err);
else resolve(rows);
});
});
}
// CORRECT: bind to the object, or call on the object
function queryPromise(sql) {
return new Promise((resolve, reject) => {
db.query.call(db, sql, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
// ALSO CORRECT: promisify as a method
Database.prototype.queryAsync = function(sql) {
return new Promise((resolve, reject) => {
this.query(sql, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
};
const rows = await db.queryAsync("SELECT * FROM users");
console.log(rows); // [{ id: 1 }, { id: 2 }]
setTimeout and setInterval
These built-in functions do not follow the error-first convention, but they are commonly promisified:
// Promisified setTimeout: a "delay" function
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
console.log("Starting");
await delay(2000);
console.log("2 seconds later");
// With a value
function delayValue(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
const result = await delayValue(1000, "hello");
console.log(result); // "hello" (after 1 second)
// Promisified animation frame
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
// Usage: wait for next animation frame
const timestamp = await nextFrame();
console.log("Frame at:", timestamp);
Helper Function for Promisification
Writing manual wrappers for every callback-based function is tedious. You can create a generic promisify helper that converts any error-first callback function into a promise-returning function automatically.
Basic Promisify Helper
function promisify(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
// Append a callback as the last argument
args.push((err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
// Call the original function with all arguments + callback
fn.apply(this, args);
});
};
}
Now you can promisify any error-first callback function in one line:
// Original callback-based functions
function readFile(path, callback) {
setTimeout(() => callback(null, `Contents of ${path}`), 100);
}
function writeFile(path, data, callback) {
setTimeout(() => callback(null), 100);
}
function deleteFile(path, callback) {
setTimeout(() => callback(null), 100);
}
// Promisify them
const readFileAsync = promisify(readFile);
const writeFileAsync = promisify(writeFile);
const deleteFileAsync = promisify(deleteFile);
// Use with async/await
const contents = await readFileAsync("/path/to/file.txt");
console.log(contents); // "Contents of /path/to/file.txt"
await writeFileAsync("/path/to/output.txt", "Hello!");
await deleteFileAsync("/path/to/old-file.txt");
How It Works Step by Step
Let us trace through what happens when you call readFileAsync("/path/to/file.txt"):
// 1. promisify(readFile) returns a new function
const readFileAsync = promisify(readFile);
// 2. Calling readFileAsync("/path/to/file.txt") executes:
readFileAsync("/path/to/file.txt");
// Which internally does:
// args = ["/path/to/file.txt"]
// args.push(callback) → args = ["/path/to/file.txt", callback]
// readFile.apply(this, ["/path/to/file.txt", callback])
// 3. readFile runs and eventually calls:
// callback(null, "Contents of /path/to/file.txt")
// 4. The callback maps to:
// err = null → resolve("Contents of /path/to/file.txt")
// 5. The promise resolves with the file contents
Handling Multiple Result Values
The basic helper only captures a single result value. For functions that pass multiple values to the callback, create a variant:
function promisifyMulti(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
args.push((err, ...results) => {
if (err) {
reject(err);
} else if (results.length <= 1) {
resolve(results[0]);
} else {
resolve(results); // Resolve with array of all results
}
});
fn.apply(this, args);
});
};
}
// Function with multiple callback values
function getStats(path, callback) {
setTimeout(() => {
callback(null, 1024, "2024-01-15", "rw-r--r--");
}, 100);
}
const getStatsAsync = promisifyMulti(getStats);
const [size, modified, permissions] = await getStatsAsync("/path/to/file");
console.log(size); // 1024
console.log(modified); // "2024-01-15"
console.log(permissions); // "rw-r--r--"
Preserving this Context in the Helper
The fn.apply(this, args) in our helper preserves this when the promisified function is called as a method:
const db = {
connection: "active",
query(sql, callback) {
console.log(`Connection: ${this.connection}`);
setTimeout(() => callback(null, [{ id: 1 }]), 100);
}
};
// Promisify the method
db.queryAsync = promisify(db.query);
// 'this' is preserved when called as a method
const rows = await db.queryAsync("SELECT * FROM users");
// Connection: active
console.log(rows); // [{ id: 1 }]
This works because db.queryAsync(...) sets this to db, and our helper passes it through with fn.apply(this, args).
Promisify All Methods on an Object
A utility that promisifies every method on an object:
function promisifyAll(obj) {
const promisified = {};
for (const key of Object.keys(obj)) {
if (typeof obj[key] === "function") {
promisified[key + "Async"] = promisify(obj[key]).bind(obj);
}
}
return { ...obj, ...promisified };
}
// Original object with callback-based methods
const fileSystem = {
readFile(path, callback) {
setTimeout(() => callback(null, `Content of ${path}`), 50);
},
writeFile(path, data, callback) {
setTimeout(() => callback(null), 50);
},
deleteFile(path, callback) {
setTimeout(() => callback(null), 50);
}
};
// Promisify all methods
const fs = promisifyAll(fileSystem);
// Original methods still work
fs.readFile("/test.txt", (err, data) => console.log(data));
// New async versions are available
const content = await fs.readFileAsync("/test.txt");
console.log(content); // "Content of /test.txt"
Edge Cases to Consider
// Functions that don't use error-first convention need special handling
function nonStandardCallback(value, onSuccess, onError) {
setTimeout(() => {
if (value > 0) onSuccess(value * 2);
else onError(new Error("Negative value"));
}, 100);
}
// Cannot use generic promisify (need manual wrapper)
function nonStandardPromise(value) {
return new Promise((resolve, reject) => {
nonStandardCallback(value, resolve, reject);
});
}
console.log(await nonStandardPromise(5)); // 10
try {
await nonStandardPromise(-1);
} catch (err) {
console.log(err.message); // "Negative value"
}
The generic promisify helper only works with functions that follow the error-first callback convention: the callback is the last argument, and its first parameter is the error. Functions with different callback patterns need manual promisification.
Node.js util.promisify()
Node.js provides a built-in util.promisify() function that does exactly what our helper does, but with additional features and edge case handling.
Basic Usage
const util = require("util");
const fs = require("fs");
// Promisify individual functions
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const access = util.promisify(fs.access);
// Use with async/await
async function processFile(inputPath, outputPath) {
try {
const data = await readFile(inputPath, "utf8");
const processed = data.toUpperCase();
await writeFile(outputPath, processed, "utf8");
console.log("File processed successfully");
} catch (err) {
console.error("Error:", err.message);
}
}
Common Node.js Promisifications
const util = require("util");
// File system
const fs = require("fs");
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const readdir = util.promisify(fs.readdir);
const stat = util.promisify(fs.stat);
const unlink = util.promisify(fs.unlink);
const mkdir = util.promisify(fs.mkdir);
// Child process
const { exec, execFile } = require("child_process");
const execAsync = util.promisify(exec);
const execFileAsync = util.promisify(execFile);
// DNS
const dns = require("dns");
const dnsLookup = util.promisify(dns.lookup);
const dnsResolve = util.promisify(dns.resolve);
// Crypto
const crypto = require("crypto");
const randomBytes = util.promisify(crypto.randomBytes);
const pbkdf2 = util.promisify(crypto.pbkdf2);
// Usage examples
const files = await readdir("./src");
const stats = await stat("./package.json");
const { stdout } = await execAsync("ls -la");
const bytes = await randomBytes(32);
Node.js fs.promises API
For the file system specifically, Node.js provides a pre-promisified API that is even cleaner than manual promisification:
// Instead of promisifying fs methods manually:
const util = require("util");
const fs = require("fs");
const readFile = util.promisify(fs.readFile);
// Use the built-in promises API:
const fsPromises = require("fs/promises");
// or
const { readFile, writeFile, readdir } = require("fs/promises");
// Usage is identical
const data = await readFile("/path/to/file.txt", "utf8");
Many Node.js modules now include their own promise-based APIs, reducing the need for manual promisification.
Custom Promisification with util.promisify.custom
Some functions do not follow the error-first convention or have complex callback signatures. util.promisify supports a special symbol that lets functions define their own promisified version:
const util = require("util");
// A function with non-standard callback behavior
function setTimeout2(callback, delay, ...args) {
// The callback here is the FIRST argument, not the last
return setTimeout(callback, delay, ...args);
}
// Define a custom promisified version
setTimeout2[util.promisify.custom] = (delay, ...args) => {
return new Promise(resolve => {
setTimeout(() => resolve(...args), delay);
});
};
// Now util.promisify uses the custom version
const sleep = util.promisify(setTimeout2);
await sleep(1000);
console.log("1 second passed");
Several built-in Node.js functions use this mechanism. For example, setTimeout already has a custom promisified version:
const { setTimeout: sleep } = require("timers/promises");
await sleep(1000);
console.log("This works natively in Node.js 16+");
util.promisify vs. Custom Helper
| Feature | util.promisify | Custom promisify |
|---|---|---|
| Environment | Node.js only | Any JavaScript environment |
| Error-first convention | Required (unless custom) | Required |
util.promisify.custom | Supported | Not applicable |
this binding | Preserved | Preserved (with apply) |
| Multiple results | Single value only | Can be extended for multiple |
| Browser support | No | Yes |
When You Still Need Manual Promisification
Even with util.promisify, some patterns require manual wrapping:
// Event-based APIs (not callback-based)
function waitForDrain(stream) {
return new Promise((resolve) => {
stream.once("drain", resolve);
});
}
// Functions with multiple success callbacks
function promisifyDualCallback(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
fn(...args, {
onSuccess: (result) => resolve(result),
onError: (error) => reject(error)
});
});
};
}
// Functions where the callback is not the last argument
function promisifyMiddleCallback(fn) {
return function(arg1, arg3) {
return new Promise((resolve, reject) => {
fn(arg1, (err, result) => {
if (err) reject(err);
else resolve(result);
}, arg3);
});
};
}
Before writing a promisify wrapper for a well-known library, check if the library already provides a promise-based API. Most popular Node.js libraries have migrated to promises or provide both callback and promise interfaces. For example:
fshasfs/promisestimershastimers/promisesdnshasdns/promisesstreamhasstream/promisesreadlinehasreadline/promises
Summary
Promisification is the bridge between callback-era JavaScript and modern promise-based code. Whether you are working with legacy libraries, Node.js core modules, or third-party APIs that still use callbacks, promisification lets you integrate them cleanly into async/await workflows.
| Concept | Key Point |
|---|---|
| Promisification | Converting a callback-based function to return a promise instead |
| Error-first convention | Callback's first argument is error, remaining are results. Required for generic promisification. |
| Manual wrapping | Wrap the function call in new Promise(), map err to reject, result to resolve |
Generic promisify helper | Appends a callback to the arguments, maps error/result to reject/resolve |
fn.apply(this, args) | Preserves this context in the promisified function |
| Multiple callback values | Collect extra arguments into an array or object since resolve() takes one value |
util.promisify() | Node.js built-in, handles edge cases and custom promisification symbols |
util.promisify.custom | Lets functions define their own promisified behavior |
fs/promises, timers/promises | Pre-promisified Node.js APIs that eliminate the need for manual conversion |
| Non-standard callbacks | Functions that do not follow error-first convention need manual wrapping |
Key rules to remember:
- Generic promisify helpers only work with error-first callback functions where the callback is the last argument
- Always check if a promise-based API already exists before writing your own wrapper
- Preserve
thiscontext by usingfn.apply(this, args)or.bind()when promisifying methods - For functions with multiple callback results, decide between resolving with an array or an object
- In Node.js, prefer
require("fs/promises")overutil.promisify(fs.readFile)for core modules - A promisified function should behave identically to the original, just with a different interface for consuming the result