InterviewsVector
intermediateCommon4 min read · Updated Jul 18, 2026

Implement promisify: Callbacks to Promises

Implement util.promisify in JavaScript: error-first callback conventions, this preservation, multi-argument results, and the settle-once guarantee against misbehaving callbacks.


The problem

Implement promisify(fn): given a function whose last argument is an error-first callbackfn(...args, (err, result) => {}) — return a function that takes the same leading arguments and returns a promise.

This is Node's util.promisify, and the question tests whether you understand the convention being bridged: callback APIs signal errors as the callback's first argument ((err, result)), promises signal them as rejections. The implementation is a mechanical translation of that convention — plus the defensive details that separate a demo from a library.

Implementation

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      let settledByCallback = false;
 
      const callback = (err, ...results) => {
        settledByCallback = true;
        if (err != null) {
          reject(err);
        } else {
          // one result → unwrap it; several → keep the array
          resolve(results.length <= 1 ? results[0] : results);
        }
        // extra invocations hit a settled promise — harmless by design
      };
 
      try {
        fn.call(this, ...args, callback); // preserve `this` for method usage
      } catch (err) {
        // a SYNC throw before the callback: reject rather than explode
        if (!settledByCallback) reject(err);
      }
    });
  };
}

Usage

const readFileAsync = promisify(fs.readFile);
 
const content = await readFileAsync("config.json", "utf8");
// err path: readFileAsync("missing.txt") → rejected promise → try/catch it
 
// `this` preservation matters for method-style APIs:
const db = {
  connectionString: "...",
  query(sql, cb) { /* uses this.connectionString */ },
};
db.queryAsync = promisify(db.query);
await db.queryAsync("SELECT 1"); // works because of fn.call(this, ...)

The design decisions interviewers probe

  • Why error-first? Node standardized (err, result) in 2009 so every async API had one error contract; promisify works because that contract is universal. Callback APIs that don't follow it (see below) can't be promisified generically.
  • err != null, not if (err) — a callback reporting 0 or "" as its "error" would be treated as success by a truthiness check. Rare, but the != null-check costs nothing and is what Node does.
  • Multiple results(err, lat, lon) doesn't fit a single resolution value. Unwrapping singles and arraying multiples is a policy; Node's real promisify instead relies on a customPromisifyArgs symbol for named multi-results. Stating "this is a convention decision, here's mine, here's Node's" is exactly the right register.
  • The settle-once shield — legacy callbacks sometimes fire twice (bug) or synchronously (Zalgo). The promise absorbs both: extra calls no-op against a settled promise, and a sync callback just settles early. You get this robustness for free from promise semantics — pointing that out shows you know why the bridge is safer than the original.
  • Sync throwsfn may validate arguments and throw before ever calling the callback; without the try/catch, that error escapes the executor's caller in a confusing way. (Inside new Promise, a sync throw actually rejects — but only if it happens inside the executor; the guard also documents intent.)

Edge cases interviewers probe

  • Callback invoked synchronously — the returned promise still settles asynchronously to its consumers (.then is always a microtask): the Zalgo-proofing argument, one sentence.
  • fn calls back with only (err) — success with no value → resolve undefined (e.g. fs.unlink); the results.length <= 1 branch covers it.
  • Non-error-first APIssetTimeout(cb, ms) (no error slot, callback first among trailing args) or xhr.onload/onerror (two callbacks) don't fit; each needs a hand-written wrapper. Recognizing when promisify doesn't apply is part of the question.
  • Overwriting thispromisify(db.query) detaches the method; either promisify(db.query.bind(db)) or the fn.call(this, ...) + assign-to-object pattern above. Ties directly into the this keyword and custom bind.

Common mistakes

  • if (err) truthiness (falsy error values misread as success).
  • Losing this (fn(...args, cb) instead of fn.call(this, ...)).
  • Resolving with results always as an array — makes the common single-value case awkward ((await f())[0]).
  • No thought about double-invoking callbacks — fine because of settle-once, but you should be able to say so rather than be surprised by the question.
  • Promisifying non-error-first APIs and shipping subtle breakage instead of writing the three-line manual wrapper.

Follow-up questions

  • "Go the other way: callbackify(asyncFn)."(...args, cb) => asyncFn(...args).then((r) => cb(null, r), (e) => cb(e ?? new Error("falsy rejection"))) — note the falsy-rejection wrinkle: reject(undefined) must still produce a truthy err.
  • "Promisify an event-emitter API?" — resolve on success event, reject on error, remove both listeners on settle — the leak-free version is the senior answer; see event emitter.
  • "What does Node's promisify.custom do?" — lets an API ship its own promise version behind a symbol; promisify returns it verbatim. Exists precisely because generic translation can't fit every API.
  • "Why not just wrap call sites in new Promise ad hoc?" — you can, but N call sites re-implement the convention N times with N chances at the if (err) bug; promisify centralizes it. The DRY argument, made concrete.
  • "Where does this pattern still matter in 2026?" — old Node APIs, browser APIs that never got promises (FileReader, IndexedDB — see the deferred pattern in Promise.withResolvers), and every SDK stuck on callbacks.

  • Memoize API Calls

    intermediate

    Cache in-flight and resolved requests to deduplicate API calls — with cache invalidation trade-offs.

  • The three concurrency shapes in one guide — sum vs slowest vs fastest, what 'parallel' means on one thread, and first-settle vs first-success.

  • Batching Promises

    intermediate

    Process a large list of async tasks in fixed-size sequential batches to protect downstream services.

  • Retry a failing async operation N times with backoff — a small function with big production implications.