util.promisify

intermediate nodejs util promises async

A huge chunk of Node’s core was designed before promises existed in JavaScript. Lots of APIs — fs, child_process, dns, plenty of npm packages — still take an error-first callback as their last argument. We don’t want to keep nesting callbacks in 2025. util.promisify is the official adapter that converts any such function into one that returns a promise.

In simple language: it takes a function that wants a callback and gives back a function that returns a promise. Zero ceremony, works on almost anything.

The convention it relies on

promisify assumes the function follows the error-first callback rule:

  • callback is the last argument
  • callback’s signature is (err, value) => ...

If both are true, promisify works automatically. Here’s the manual version of what it does, just to demystify it:

function manualPromisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (err, result) => {
        if (err) reject(err);
        else resolve(result);
      });
    });
  };
}

That’s basically it. The real util.promisify is more robust (handles this, multi-arg callbacks, special-cased core funcs) but the spirit is identical.

Using it

import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import dns from 'node:dns';

const execAsync = promisify(exec);
const lookup = promisify(dns.lookup);

const { stdout } = await execAsync('git rev-parse HEAD');
console.log('HEAD:', stdout.trim());

const { address } = await lookup('nodejs.org');
console.log('IP:', address);

We now await what used to need a callback. Errors flow through normal try/catch.

What fs.promises actually is

fs/promises is what we’d get if we sat down and promisified every function in fs. The Node team did that work for us and shipped it as a separate module.

fs.readFile(path, cb) — promisify → fs.promises.readFile(path)
Same logic, same options. The callback became a returned promise.

Proof — we could literally rebuild it:

import fs from 'node:fs';
import { promisify } from 'node:util';

const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

// these now behave just like fs.promises.readFile / writeFile
const data = await readFile('./config.json', 'utf8');

fs.promises is just nicer ergonomics with a single import.

Custom promisify behavior

Some core APIs don’t follow the strict (err, value) shape — for example dns.lookup calls back with (err, address, family), two result args. Node special-cases these via the util.promisify.custom symbol. The promisified version returns { address, family } instead of just address.

import { promisify } from 'node:util';
import dns from 'node:dns';

const lookup = promisify(dns.lookup);
const result = await lookup('nodejs.org');
// { address: '104.20.22.46', family: 4 }

We don’t normally need to set [util.promisify.custom] ourselves, but if we ship a library with non-standard callback shapes, that’s how we’d do it.

When NOT to use promisify

If the function has any of these traits, promisify is wrong:

  • Emits events repeatedly (e.g., a stream emitting data multiple times). Promises resolve once. Use for await...of, pipeline, or stay on events.
  • The callback isn’t error-first (e.g., setTimeout(cb, ms) — its callback has no err). You can still wrap it manually, just not with promisify.
  • Already returns a promise. No-op at best, weird wrapping at worst.

Here’s the right way to “promisify” setTimeout — Node ships a promise version already:

import { setTimeout as sleep } from 'node:timers/promises';

await sleep(1000); // pauses for 1s

The mental model

util.promisify is the bridge between Node’s callback past and its promise present. We use it directly when we hit an old API that hasn’t been modernized, and we use the already-promisified versions (fs/promises, timers/promises, dns/promises, stream/promises) whenever they exist — they’re idiomatic and well-tested.