ESC

Type to search the knowledge base.

Implement Function Once

Wrap a function so it runs at most once and then returns the first result. this, arguments, thrown errors, and async once that still shares the first promise.

beginner5 min read
  • javascript
  • functions
  • closures
  • interview

A payment handler is bound to a double-submitting button. The first click starts a charge. The second click starts a second charge. Debounce would wait for quiet; you don’t want a wait, you want a hard lock after the first call. That is once.

Implement function once: wrap fn so the first invocation runs fn with that call’s this and arguments, and every later invocation returns the same value without running fn again. Later this and later arguments are ignored. That last sentence is the product behavior, not a bug.

Thrown errors and async functions need an explicit policy. “At most once” can mean “at most one attempt” or “at most one successful attempt.” Say which before you cache.

  • First call runs fn.apply(this, args) and stores the return value. Later calls replay it.
  • Later arguments do not get a second chance. That is the difference from memoization.
  • Decide whether a throw counts as the one call. Retry-on-throw is not once.
  • Preserve this with a plain wrapper and apply, not an arrow.
  • Async once should share the first promise, including a rejection, so two callers cannot start two requests.

First call wins, later calls replay

The wrapper closes over called and value. Use a real function so this comes from the call site (this binding rules, call/apply/bind).

function once(fn) {
  let called = false;
  let value;

  return function wrapped(...args) {
    if (called) return value;
    called = true;
    value = fn.apply(this, args);
    return value;
  };
}

let n = 0;
const incOnce = once(function inc(step) {
  n += step;
  return n;
});

incOnce(2); // 2
incOnce(100); // 2, n stays 2

Mark called before invoking fn if you want a throw to consume the one slot (Lodash-style: later calls return undefined and do not rethrow). Mark it after success if a throw should allow retry. Both are used in production. The retry form is safer for “init on first use” that might 500. The consume-on-throw form is safer for “this click must not fire twice even if the first handler exploded.”

function once(fn, { replayThrow = true } = {}) {
  let called = false;
  let value;
  let error;
  let failed = false;

  return function wrapped(...args) {
    if (called) {
      if (failed) {
        if (replayThrow) throw error;
        return value;
      }
      return value;
    }

    called = true;
    try {
      value = fn.apply(this, args);
      return value;
    } catch (err) {
      failed = true;
      error = err;
      throw err;
    }
  };
}
First call replayThrow: true later consume, no replay
returns x returns x returns x
throws e throws e returns undefined

Default to replaying the throw if you cache the error. It is less mysterious than a later undefined.

this and methods

const client = {
  token: 'abc',
  connect: once(function connect() {
    return fetch('/session', {
      headers: { Authorization: this.token },
    });
  }),
};

client.connect();
client.connect(); // same return value, fetch not called again

An arrow wrapper would lock this to the module scope and this.token would be wrong. The inner fn can be an arrow if the caller doesn’t care; the outer wrapper must not be.

Async once shares a promise

If fn returns a promise, the naive once still runs fn only once and caches that promise object. Two calls in the same turn both see called === false if you set called after await. Don’t await before flipping the flag.

function onceAsync(fn) {
  let pending;

  return function wrapped(...args) {
    if (!pending) {
      pending = Promise.resolve().then(() => fn.apply(this, args));
    }
    return pending;
  };
}

const loadConfig = onceAsync(() => fetch('/config').then((r) => r.json()));

await Promise.all([loadConfig(), loadConfig()]);
// one fetch; both callers await the same promise

Promise.resolve().then(() => fn.apply(...)) also turns a sync throw into a rejected promise, so both callers land in catch instead of one throw and one undefined.

A rejected first promise is cached. Callers will keep seeing the rejection until you expose loadConfig.reset(). Retry is a different helper: once plus a manual clear, or a memo with a TTL. Don’t silently rerun on reject unless the prompt is “at most one in flight, retry after failure.”

function onceAsync(fn) {
  let pending;

  function wrapped(...args) {
    if (!pending) {
      pending = Promise.resolve().then(() => fn.apply(this, args));
    }
    return pending;
  }

  wrapped.reset = () => {
    pending = undefined;
  };

  return wrapped;
}

once is not memo

Memoization keys on arguments and may rerun for a new key. once keys on nothing. once(fn)(1) then once(fn)(2) is still the 1 path if they share the same wrapper. Two wrappers from two once(fn) calls have separate slots.

debounce delays a call. once does not wait; it locks. Debounce is the wrong tool for double-submit.

Footguns of once

  1. Setting called after fn. Re-entrant fn that calls the wrapper again will recurse forever. Set the flag first.
  2. Arrows as the wrapper. Lose method this.
  3. Caching fn() without apply. Methods break; extra arguments passed as a single array if you mix up apply/call.
  4. Async without sharing. if (!called) { called = true; return fn(); } is fine if fn() is called once and its promise is stored. Forgetting to store the promise and returning undefined later is the bug.
  5. Reset and races. reset during an in-flight request lets a second request start. Clear only after settle, or document the race.

Interview out loud

“I close over a called flag and a stored return value. The wrapper is a plain function so this comes from the call site, and I invoke fn with apply. After the first call I return the stored value, ignore new arguments, and I set the flag before invoking so a re-entrant call cannot run fn twice. For throws I cache the error and rethrow on later calls, unless they want retry. For async I cache the first promise so parallel callers share one request, including a rejection. That is not memo: the argument list does not form a cache key.”

Further reading

Related guides