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

  1. You call promisifiedFn(...args). Synchronously, promisify's wrapper creates and returns a new Promise — nothing has resolved yet.
  2. 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.
  3. Control returns to your code immediately; the promise is pending, and whatever originalFn does (schedule a timer, queue thread-pool work, open a socket) proceeds independently.
  4. 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).
  5. internalCallback calls reject(err) if err is truthy, otherwise resolve(value).
  6. Resolving or rejecting the promise schedules its .then/.catch handlers — or the paused await continuation — 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.promisify for genuinely callback-only APIs: legacy core functions like child_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.promisify repeatedly inside a hot path.
  • .bind() methods to their object before promisifying them so this resolves 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.promisify on child_process.execFile to run node --version and 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 uses setTimeout to call callback(null, a + b) after a delay. Promisify it and use it with await inside an async function.
  • Take the readUserRecord function from Example 3 and extend it so the callback also passes a timestamp as a fourth argument. Update its util.promisify.custom implementation 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 a Promise, so it can be used with async/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, as child_process.exec and dns.lookup do internally.
  • Prefer built-in promise modules (fs/promises, dns/promises, timers/promises) over promisifying callback APIs when they exist; save promisify for 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.callbackify does the reverse conversion, from a promise-returning function back to an error-first callback function.