util.promisify and Callback Interop
Most of Node.js’s original core APIs — and thousands of npm packages built in its first decade — use the error-first callback pattern instead of promises: you pass a function as the last argument, and Node calls it back with (err, result) once the operation finishes. That style still works everywhere, but it doesn’t compose with async/await. util.promisify() is the built-in bridge: it takes a callback-based function and hands you back a version that returns a Promise, so you can await it like any modern API. This lesson covers how promisify works internally, where it breaks down, and how to interoperate cleanly between old callback code and modern async code.
Overview / How it works
Before Promises existed in JavaScript, Node.js standardized on a single calling convention for anything asynchronous: the error-first callback. A function that does I/O takes a callback as its last argument, and when the operation completes, Node invokes that callback with the error (or null) as the first parameter and the result as the second, e.g. fs.readFile(path, (err, data) => { ... }). This convention predates promises and async/await by years, and a huge amount of Node core and third-party code still uses it under the hood, even where newer promise-based wrappers now sit on top.
util.promisify(original) takes a function that follows this convention and returns a new function that, instead of accepting a callback, returns a Promise. When you call the promisified function, it internally calls the original function with the same arguments plus one extra callback that promisify itself controls. When that internal callback fires with (err, value), promisify rejects the promise with err if it’s truthy, or resolves it with value otherwise. This is exactly the wrapping you’d otherwise write by hand around every callback API — promisify just does it once, generically.
It’s important to understand what promisify does not do: it does not make a synchronous operation asynchronous, and it does not change how the underlying work is actually performed. If the original function does its work on libuv’s thread pool (as most fs calls do), the promisified version still uses the thread pool. If the original function is asynchronous via the OS’s non-blocking sockets (as most net/dns operations are), that doesn’t change either. promisify only changes the JavaScript-level calling convention — callback in, promise out — layered on top of whatever the function already does.
Since Node 10, most core modules that do I/O ship a promise-native version already: node:fs/promises, node:dns/promises, node:timers/promises, and others. For those, reach for the promise module directly instead of promisifying the callback version — it’s clearer, and it’s what the Node team maintains. util.promisify earns its keep on legacy core APIs that never got a promise version (like child_process.exec) and on the large amount of older third-party npm packages that only expose callbacks. The reverse utility, util.callbackify(asyncFunction), exists for the opposite situation: turning a promise-returning function back into an error-first callback function, which is occasionally needed when plugging modern async code into an older callback-driven framework.
Syntax
The general form:
const util = require('node:util');
const promisifiedFn = util.promisify(originalFn);
// later, inside an async function
const result = await promisifiedFn(arg1, arg2);
| Part | Meaning |
|---|---|
originalFn |
A function whose last parameter is an error-first callback: (...args, (err, value) => {}). |
util.promisify(originalFn) |
Returns a new function with the same leading arguments but no callback parameter. |
promisifiedFn(...args) |
Calling it returns a Promise that resolves with the callback’s result value, or rejects with the callback’s error. |
originalFn[util.promisify.custom] |
Optional: if present, promisify uses this function instead of the default logic — used when the callback passes back more than one result value. |
Examples
Example 1: Promisifying fs.readFile
The classic case: fs.readFile is callback-based, so wrap it once with promisify and reuse the wrapped version everywhere.
const fs = require('node:fs');
const util = require('node:util');
const readFile = util.promisify(fs.readFile);
async function main() {
fs.writeFileSync('./greeting.txt', 'Hello from a promisified callback!');
try {
const data = await readFile('./greeting.txt', 'utf8');
console.log(data);
} catch (err) {
console.error('Failed to read file:', err.message);
}
}
main();
Hello from a promisified callback!
The synchronous writeFileSync here is only for setting up the demo file — fine at startup, but never do this inside a request handler. readFile itself is the promisified version of the callback API, so it can be awaited with normal try/catch error handling instead of an error-first callback.
Example 2: Promisifying child_process.exec
child_process never got a full promise-native module, so promisify is the standard way to use exec with async/await. Node’s own exec ships a custom promisify implementation that resolves with { stdout, stderr } instead of dropping the second callback argument.
const { exec } = require('node:child_process');
const util = require('node:util');
const execAsync = util.promisify(exec);
async function printNodeVersion() {
try {
const { stdout, stderr } = await execAsync('node --version');
if (stderr) console.error('stderr:', stderr);
console.log('Node version:', stdout.trim());
} catch (err) {
console.error('Command failed:', err.message);
}
}
printNodeVersion();
Node version: v22.11.0
The exact version printed depends on the Node binary running the script. Notice the destructured { stdout, stderr } — that only works because exec defines its own util.promisify.custom handler; a plain callback function without one would only give you the first non-error argument, as the next example shows.
Example 3: When a callback passes back more than one value
util.promisify‘s default behavior assumes the callback receives exactly (err, value). If a callback-style function passes extra arguments after the error, the default wrapping silently keeps only the first one:
const util = require('node:util');
function readUserRecord(id, callback) {
setTimeout(() => {
if (id <= 0) {
return callback(new Error('Invalid id'));
}
callback(null, { id, name: 'Ada' }, 'cache-miss');
}, 10);
}
const readUserRecordAsync = util.promisify(readUserRecord);
async function main() {
const user = await readUserRecordAsync(1);
console.log(user);
}
main();
{ id: 1, name: 'Ada' }
The 'cache-miss' third argument vanished — promisify resolved with only the second callback argument. This is exactly why core APIs like dns.lookup and child_process.exec define their own util.promisify.custom: it lets the author control what shape the resolved value takes. You can do the same for your own callback functions:
const util = require('node:util');
function readUserRecord(id, callback) {
setTimeout(() => {
if (id <= 0) {
return callback(new Error('Invalid id'));
}
callback(null, { id, name: 'Ada' }, 'cache-miss');
}, 10);
}
readUserRecord[util.promisify.custom] = (id) => {
return new Promise((resolve, reject) => {
readUserRecord(id, (err, user, cacheStatus) => {
if (err) return reject(err);
resolve({ user, cacheStatus });
});
});
};
const readUserRecordAsync = util.promisify(readUserRecord);
async function main() {
const result = await readUserRecordAsync(1);
console.log(result);
}
main();
{ user: { id: 1, name: 'Ada' }, cacheStatus: 'cache-miss' }
Now every value the callback produced is preserved, and callers still get a plain promise to await.
How it works step by step / Under the hood
- You call
promisifiedFn(...args). Synchronously,promisify's wrapper creates and returns a newPromise— nothing has resolved yet. - Inside the executor, it calls
originalFn(...args, internalCallback), appending its own callback as the final argument, exactly as if you had written that call by hand. - Control returns to your code immediately; the promise is pending, and whatever
originalFndoes (schedule a timer, queue thread-pool work, open a socket) proceeds independently. - When the underlying operation finishes, Node invokes
internalCallback(err, value)— on the main thread, during whichever event loop phase is appropriate for that operation (e.g. the poll phase for file I/O completions). internalCallbackcallsreject(err)iferris truthy, otherwiseresolve(value).- Resolving or rejecting the promise schedules its
.then/.catchhandlers — or the pausedawaitcontinuation — onto the microtask queue, which drains before the event loop moves to its next phase.
Nothing here bypasses or reorders the event loop; promisify just gives you a promise-shaped handle on a callback that was always going to fire this way.
Common Mistakes
1. Losing this when promisifying a method
Passing a bound method's reference without binding it detaches it from its object:
const util = require('node:util');
class Counter {
constructor() {
this.count = 0;
}
increment(callback) {
setTimeout(() => {
this.count++;
callback(null, this.count);
}, 5);
}
}
const counter = new Counter();
const incrementAsync = util.promisify(counter.increment);
incrementAsync().then((count) => console.log(count));
Because Node's CommonJS modules run in sloppy mode, this doesn't even throw — this inside the detached increment becomes the global object, so this.count++ silently produces NaN instead of the real counter value. Bind the method before promisifying it:
const incrementAsync = util.promisify(counter.increment.bind(counter));
incrementAsync().then((count) => console.log(count)); // 1
2. Promisifying a callback that can fire more than once
promisify assumes the callback is invoked exactly once. APIs like fs.watch, whose callback fires repeatedly for every filesystem event, are a poor fit:
const fs = require('node:fs');
const util = require('node:util');
const watchAsync = util.promisify(fs.watch);
watchAsync('./data').then((event) => {
console.log('File changed:', event); // resolves at most once, then goes silent
});
The resulting promise can only ever settle once, so every event after the first is lost and the watcher itself is never exposed for cleanup. Use the async-iterable version instead:
const fs = require('node:fs');
async function watchDir() {
const watcher = fs.promises.watch('./data', { recursive: false });
for await (const event of watcher) {
console.log(event.eventType, event.filename);
}
}
watchDir();
3. Forgetting error handling around an awaited promisified call
A promisified function still rejects on error — skip the try/catch and you get an unhandled promise rejection that can crash the process:
const util = require('node:util');
const fs = require('node:fs');
const readFile = util.promisify(fs.readFile);
async function loadConfig() {
const data = await readFile('./config.json', 'utf8'); // no try/catch
return JSON.parse(data);
}
loadConfig().then((config) => console.log(config));
If config.json is missing, the rejection has nowhere to go. Handle it explicitly:
async function loadConfig() {
try {
const data = await readFile('./config.json', 'utf8');
return JSON.parse(data);
} catch (err) {
console.error('Could not load config:', err.message);
throw err;
}
}
loadConfig()
.then((config) => console.log(config))
.catch(() => process.exit(1));
Best Practices
- Prefer a module's built-in promise API (
node:fs/promises,node:dns/promises,node:timers/promises) over promisifying its callback version when one exists. - Reserve
util.promisifyfor genuinely callback-only APIs: legacy core functions likechild_process.exec, and third-party packages that never shipped a promise API. - Promisify a function once, at module load time, and reuse the resulting function — don't call
util.promisifyrepeatedly inside a hot path. .bind()methods to their object before promisifying them sothisresolves correctly.- If a callback passes back more than one result value, define
[util.promisify.custom]on it rather than relying on the default (which drops everything after the first value). - Never promisify a callback that can be invoked more than once (event-style APIs); use the object's async-iterator or event-based API instead.
- Always wrap awaited promisified calls in
try/catch, exactly as you would for any other promise.
Practice Exercises
- Use
util.promisifyonchild_process.execFileto runnode --versionand log the trimmed output. Handle the rejected case where the command doesn't exist. - Write your own callback-style function
addLater(a, b, callback)that usessetTimeoutto callcallback(null, a + b)after a delay. Promisify it and use it withawaitinside anasyncfunction. - Take the
readUserRecordfunction from Example 3 and extend it so the callback also passes a timestamp as a fourth argument. Update itsutil.promisify.customimplementation so the resolved object includes all three pieces of data.
Summary
util.promisify(fn)wraps an error-first callback function and returns a function that returns aPromise, so it can be used withasync/await.- It works by appending its own callback to the arguments you pass, then resolving or rejecting based on that callback's
(err, value)result — it doesn't change how the underlying operation actually runs. - By default only the first non-error callback argument survives; use
fn[util.promisify.custom]to control the resolved shape when a callback passes back multiple values, aschild_process.execanddns.lookupdo internally. - Prefer built-in promise modules (
fs/promises,dns/promises,timers/promises) over promisifying callback APIs when they exist; savepromisifyfor legacy and third-party callback-only code. - Never promisify a callback that can fire more than once, and always bind object methods before promisifying them.
util.callbackifydoes the reverse conversion, from a promise-returning function back to an error-first callback function.
