skip to content

You are designing a library function that accepts a user-supplied callback. What contract must you fix and document before shipping it, and why is inconsistent invocation timing the dangerous one?

level: principalimportance: should knowfreq 26%

answer

  1. accepting a callback inverts control
  2. four commitments, all breaking to change
  3. never sometimes-sync, sometimes-async
  4. defer the fast path to be consistent
  5. state the throw and reentrancy policy

basics

~20 s

Fix four things: which arguments the callback receives, how many times and in what order it may be called, whether it is invoked synchronously or always deferred, and what happens if it throws. Timing is the dangerous one because a function that sometimes calls back synchronously makes caller state nondeterministic.

solid answer

~50 s

Accepting a callback is inverting control, so the contract is a public API: the argument list, the invocation count and order, the timing, and the error behaviour. Timing deserves special care because a function that calls back synchronously on a fast path — a cache hit, a validation failure — and asynchronously otherwise leaves callers unable to reason about whether code after the call has already run. That is the "don't release Zalgo" rule, and the fix is to pick one and never vary: either always synchronous, or always deferred, typically via `queueMicrotask` on the fast path. The other commitments matter too. Callback arguments are frozen once callers pass functions point-free, so adding one is breaking. Reentrancy needs a stated policy, since a callback invoked mid-iteration may call back into your API and mutate state you are walking. And you must decide whether a throwing callback propagates to your caller, aborts the remaining callbacks, or is isolated.

code

javascript · 22 lines
javascript
const cache = new Map([['a', 1]]);

// INCONSISTENT: synchronous on a hit, asynchronous on a miss
function loadBad(key, cb) {
  if (cache.has(key)) return cb(null, cache.get(key));
  setTimeout(() => cb(null, 42), 0);
}

// CONSISTENT: always deferred, whatever the path
function loadGood(key, cb) {
  if (cache.has(key)) {
    const value = cache.get(key);
    queueMicrotask(() => cb(null, value));
    return;
  }
  setTimeout(() => cb(null, 42), 0);
}

let handle;
loadBad('a', (e, v) => console.log('bad sees handle:', handle, v));   // undefined 1
loadGood('a', (e, v) => console.log('good sees handle:', handle, v)); // 'ready' 1
handle = 'ready';

go deeper

for a junior

Know that a callback is code someone else runs on your behalf, and that you must know how many arguments it receives and when it fires before you write one.

for a middle

Explain the concrete consequence of inconsistent timing with an example: on a fast path the callback runs before the next statement, so caller state assigned afterwards is not yet there.

for a senior

Show how you would fix an existing API that varies its timing — defer the fast path with queueMicrotask, keep the slow path as-is — and how you would find affected callers before changing it.

for a principal

Own the compatibility surface: which of the four commitments are breaking to change, how you document and version them, and when the right answer is not to take a callback at all but to accept data or return an iterable.

## Taking a callback is publishing an API When your function accepts a function, you are not just reading an argument — you are promising to call it. Everything about *how* you call it becomes a contract users depend on, usually without reading any documentation. Four commitments deserve an explicit decision. ## 1. The argument list What you pass is frozen the moment anyone writes `onItem(handler)` point-free. Adding a second argument later is a breaking change for every callback that already declares an optional second parameter meaning something else. Keep callback signatures narrow, and prefer passing a single object when the set of things you might want to pass is likely to grow: `cb({ item, index })` can gain a field without disturbing anyone. ## 2. Count and order Exactly once? Zero-or-more? Once per item, in array order, or in completion order? Callers write very different code for each. If a callback may fire more than once, say so, and consider whether users need a way to stop — returning `false` to break, or accepting an abort mechanism. ## 3. Timing — the sharp one Consider: ```js function load(key, cb) { if (cache.has(key)) { cb(null, cache.get(key)); // synchronous on a hit return; } fetchFromDisk(key, cb); // asynchronous on a miss } ``` The API now behaves in two incompatible ways depending on runtime state the caller cannot see. Caller code like this breaks: ```js let handle; load('a', (err, value) => { use(handle, value); }); handle = createHandle(); ``` On a cache miss, `handle` is assigned before the callback runs and everything works. On a hit, the callback runs *before* the next line, and `handle` is `undefined`. Now the bug appears only after the cache warms, which is to say in production and not in tests. This is what Isaac Schlueter memorably called "releasing Zalgo": an API whose ordering is nondeterministic from the caller's point of view. The rule is to pick one timing and hold it for every path. If any path is asynchronous, make all of them asynchronous by deferring the fast path: ```js function load(key, cb) { if (cache.has(key)) { const value = cache.get(key); queueMicrotask(() => cb(null, value)); // always after the current job finishes return; } fetchFromDisk(key, cb); } ``` The opposite choice is equally valid when nothing can be async: `Array.prototype.map` and `forEach` are unconditionally synchronous and callers rely on that completely. What is not valid is "usually async, sometimes sync". State which one you are in the documentation, in one sentence, near the top. ## 4. Errors and reentrancy If the user's callback throws, what happens? Three defensible policies: let it propagate to your caller (simple, and it surfaces bugs loudly); catch it and abort the remaining work; catch it, report it through your own error channel, and continue with the other callbacks. Any of these is fine — silently swallowing is not, and neither is leaving your internal state half-updated because the throw unwound you mid-mutation. Do your own state changes before invoking the callback, so a throw cannot leave an inconsistent structure behind. Reentrancy is the second-order version of the same concern. A callback invoked while you are iterating your own list may call back into your API and add or remove entries from the list you are walking. Decide the policy — iterate over a copy, defer mutations until the pass completes, or explicitly document that mutation during iteration is unsupported — and enforce it, because "whatever the code happens to do" is a policy that changes silently in the next refactor. ## Why this is a lead-level question All four commitments are cheap to make on day one and expensive to change afterwards, because every caller's code is shaped around them and the compiler cannot tell you who depends on what. The interviewer is checking that you think of a callback parameter as an interface with a compatibility surface, not as a convenient hook — and that you can name the timing hazard concretely rather than gesturing at "async is hard". A strong answer also says when *not* to take a callback at all: if the extension point is really configuration, take data; if it is really a sequence of results, hand back an iterable; a callback is right when the caller genuinely needs to run code at a moment you control.

  • If everything your function does is synchronous today, must you still defer the callback?
    No — unconditionally synchronous is a perfectly good contract, and array methods prove it works. The rule is consistency, not deferral. The risk is future drift: if a later version adds an asynchronous path, switching timing then is a breaking change for callers who relied on the callback having already run. Decide deliberately and write it down.
  • How do you keep a throwing callback from corrupting your library's internal state?
    Complete your own state transitions before invoking the callback, so an exception unwinds through code that has nothing left to do. Then pick a documented policy — propagate, abort the remaining callbacks, or isolate and continue — and apply it uniformly. Anything else leaves half-updated structures whose symptoms appear much later, far from the throwing callback.
  • What is the reentrancy hazard when you call user callbacks while iterating your own collection?
    The callback can call back into your API and add or remove entries from the very collection you are walking, so you may skip entries, visit removed ones, or loop forever. The usual mitigations are iterating a snapshot copy, queuing mutations until the pass finishes, or documenting mutation-during-iteration as unsupported and detecting it.
  • When should an extension point not be a callback at all?
    When the caller does not actually need to run code at a moment you control. If the variability is really configuration, take data — an option object or a comparator table — which is inspectable, serialisable and easy to validate. If you are really producing a sequence of results, hand back an iterable and let the caller drive. Reserve callbacks for genuine inversion of control.

saying these in an interview costs you the question

  • Calls back synchronously on the fast path and async otherwise
  • Treats the callback argument list as an internal detail
  • Adds an argument to a callback and calls it non-breaking
  • Lets a throwing callback unwind mid-mutation
  • Ignores callbacks that reenter the API during iteration

context