InterviewsVector
beginnerCommon5 min read · Updated Jul 18, 2026

Implement _.once(): Run a Function Exactly Once

Implement lodash's _.once in JavaScript — the smallest real closure question, with the throwing-first-call policy, GC detail, and once-vs-memoize distinction.


The problem

Implement once(fn): the returned function runs fn on the first call and returns that same result — cached forever — on every call after, without running fn again.

The smallest interview question that's still a real one. It's closures distilled to two variables, and because the code is trivial, everything rides on the edges: what happens when the first call throws, what happens to fn afterward, and how once differs from memoize. Small question, sharp follow-ups.

Implementation

function once(fn) {
  let called = false;
  let result;
 
  return function (...args) {
    if (!called) {
      called = true;                 // BEFORE the call — see the throw discussion
      result = fn.apply(this, args); // forward this and args
      fn = null;                     // release fn for garbage collection
    }
    return result;
  };
}
let count = 0;
const init = once(() => {
  count += 1;
  return `initialized #${count}`;
});
 
init(); // "initialized #1" — runs
init(); // "initialized #1" — cached; count is still 1
init(); // "initialized #1" — forever

called and result live in the closure: invisible to callers, impossible to reset from outside, garbage-collected only with the function itself. If the interviewer asks "where is the state?", the answer is the question.

The three details that upgrade the answer

1. The throwing first call — a genuine policy fork. If fn throws, has it "been called"?

  • called = true before invoking (above, lodash's behavior): the attempt counts; a throw means every later call returns undefined (the assignment never happened). Guarantee: at most once, ever.
  • called = true after: a throw leaves called false → the next call retries. Guarantee: at most one success.

Neither is wrong; not knowing which you built is. For side-effect guards (the main use), at-most-once is safer — a half-completed initializer retrying can double-fire the side effects that did succeed. Name the fork, pick, justify — that's the whole senior move on this question.

2. fn = null after the call. The closure keeps fn alive forever otherwise. Usually irrelevant; meaningful when fn closes over something heavy (a big config blob, a DOM subtree). Lodash does exactly this. One line, disproportionate signal — it shows you think about what closures retain.

3. this and arguments forward on the first call only. Later calls ignore their arguments entirely and return the cached result — init("ignored") doesn't warn. That's the contract; contrast with memoize below, where arguments are the identity.

Where you see it in production

  • Lazy one-time init — connect once, create the singleton, inject the script tag: const getClient = once(() => createClient(config)).
  • Guarding side effects — analytics "first interaction" events; unsubscribing exactly once in cleanup paths.
  • The platform agrees the pattern mattersaddEventListener(type, handler, { once: true }) bakes it into the DOM, and knowing that option exists (auto-removal included) is the better answer for listener use cases than wrapping by hand.

once vs memoize — the standard follow-up

once(fn)memoize(fn)
Cachesone result, periodone result per argument key
Later argsignoredare the cache key
Stateboolean + valuea Map that can grow
Jobside-effect guardcomputation cache

One-liner: once is memoize with a constant cache key — and if you can say that, you can implement either from the other, which is occasionally the actual follow-up task.

Edge cases interviewers probe

  • First call throws — the policy fork above; the interviewer nearly always asks.
  • undefined resultfn legitimately returning undefined is indistinguishable from "never ran" if you guard on result !== undefined instead of a boolean flag — the flag isn't optional bookkeeping, it's correctness.
  • Concurrent async first calls — two calls before the first's promise settles: once returns the same in-flight promise (the promise is the result, cached synchronously) — accidental but correct request-dedupe, same mechanism as memoizing API calls. If the promise rejects, at-most-once caches the rejection forever — tie back to the throw policy.
  • this on the first call — method usage (obj.init = once(function () { ... })) needs the function/apply wiring; an arrow wrapper breaks it (why).

Common mistakes

  • Guarding on the result (if (!result)) — breaks for falsy/undefined returns.
  • Not having an answer for the throwing case.
  • Arrow function as the wrapper (loses this).
  • Resetting nothing, ever, by design — but presenting it as resettable; if the interviewer wants reset(), that's an explicit API addition (once.reset = () => { called = false; }) with thread-of-control caveats, not a freebie.
  • Overbuilding: reaching for a Map-based memoize when the question is a boolean and a value.

Follow-up questions

  • "Implement once such that a throw allows retry." — move called = true after the call; state the changed guarantee (at most one success).
  • "Implement nTimes(fn, n)." — generalize the boolean to a counter; once is nTimes(fn, 1) — checks the abstraction transfers.
  • "Async-safe once that retries failed promises?" — cache the promise, but on rejection clear the slot (the same eviction move as API memoization).
  • "How does { once: true } in addEventListener differ?" — the platform also removes the listener, releasing it for GC — strictly more than the wrapper does; knowing the difference is the point.
  • "Where's the state, and who can touch it?" — closure scope; nobody. Then the inverse: "how would you make it inspectable?" — expose a readonly wasCalled() — API-design in miniature.

  • memoize()

    intermediate

    Cache function results by argument key — and discuss cache-key strategy and memory trade-offs.

  • _.partial()

    intermediate

    Pre-fill leading arguments of a function — partial application, and how it differs from currying.

  • memoizeLast()

    intermediate

    A single-slot memoizer that remembers only the previous call — the pattern behind React's useMemo.

  • pipe()

    intermediate

    Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.