skip to content

A module caches the promise returned by its initialisation function in a module-level variable so that concurrent callers share one in-flight request. The very first attempt rejects, and from then on every caller in the process gets the identical failure with no further network activity. Explain what the promise lifecycle is doing here, and how you would fix it.

level: seniorimportance: should knowfreq 38%

answer

  1. the cache stores an outcome, not an attempt
  2. rejected is a terminal state
  3. no subscriber ever re-runs the work
  4. evict the entry when it rejects
  5. rethrow after clearing, or you swallow it

basics

~20 s

The cache holds a settled promise, and settlement is permanent. A rejected promise replays the same reason to every later subscriber and never re-runs the work, so the cached failure is now the module's permanent answer until the process restarts.

solid answer

~50 s

Caching a promise caches an *outcome*, not an operation. The first call constructed the promise, the work ran once, and the promise settled as rejected. Because settlement is one-way and one-time, every later caller that reads the cached variable subscribes to an already-rejected promise and immediately receives the same stored reason — no request is issued, because a promise is a record of a completed attempt, not a task that re-runs per subscriber. This is the standard failure mode of promise memoisation: the happy path works beautifully, deduplicating concurrent callers onto one request, while a single transient startup failure becomes permanent. The fix is to make the cache entry conditional on success — attach a rejection handler that clears the cached variable and rethrows, so the next caller finds an empty slot and starts a fresh attempt. Cache the function, or the successful promise; never a settled failure.

code

javascript · 18 lines
javascript
let cached = null;

function loadConfigFromServer() {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('server unavailable')), 10);
  });
}

function getConfig() {
  if (cached === null) cached = loadConfigFromServer();
  return cached;
}

getConfig().catch((e) => console.log('first:', e.message));
setTimeout(() => {
  // same rejected promise, no second attempt
  getConfig().catch((e) => console.log('later:', e.message));
}, 100);

go deeper

for a junior

Recall that a rejected promise stays rejected: reading it again gives the same error and never re-runs the work that produced it.

for a middle

Explain the mechanism — the variable holds a settled object, so subscription is a read, not an invocation — and write the eviction-on-rejection fix with the rethrow.

for a senior

Recognise the production signature: identical errors on every request, no outbound traffic, cured by a restart. Then reason about eviction plus backoff so the fix does not create a retry stampede.

for a principal

Decide what a shared module should hand out at all: a live promise commits every consumer to one attempt's fate, so make retry policy, cooldown, and failure isolation explicit parts of the interface.

## What the cache actually holds The pattern looks like textbook memoisation: ```js let cached = null; function getConfig() { if (cached === null) cached = loadConfigFromServer(); return cached; } ``` On the success path this is genuinely good: ten callers during startup all get the same promise, one request goes out, and everyone shares the result. It works because of the two properties this leaf is about — a promise is eager, so the work is already running by the time the second caller arrives, and a settled promise replays its outcome to any number of late subscribers. The problem is that both properties apply just as strongly to failure. When `loadConfigFromServer()` rejects, `cached` now holds a *rejected* promise. Settlement is one-way and one-time: there is no path from rejected back to pending, and nothing about subscribing re-runs the underlying work. Every subsequent `getConfig()` returns that same object, and every subscriber gets the same reason instantly. A momentary DNS blip during boot has become a permanent, process-lifetime outage. ## Why no retry happens "for free" Candidates often expect a rejected promise to retry when re-awaited, by analogy with lazy abstractions where the object is a recipe. In JavaScript the object is a receipt. Awaiting it a second time performs no work at all; it reads a stored value. The only way to attempt the operation again is to call the function that constructs a promise again — which is precisely what the `if (cached === null)` guard now prevents. ## Diagnosing it in production The signature is distinctive and worth recognising: a service that fails every request with the *same* error message and the same stack, with zero outbound calls to the dependency, while the dependency itself is demonstrably healthy. Restarting the process fixes it, which is the tell that the bad state lives in memory rather than in the dependency. Retry-on-failure at the HTTP layer will not help either, because the failure is served from memory long before any transport is involved. ## Fixing it: evict on rejection The minimal fix keeps the deduplication benefit and removes the poisoning: ```js let cached = null; function getConfig() { if (cached === null) { cached = loadConfigFromServer().catch((err) => { cached = null; // let the next caller start a fresh attempt throw err; // still reject this caller }); } return cached; } ``` Note both halves. Clearing the variable restores retryability. Rethrowing is essential — a rejection handler that does not rethrow *recovers*, so the callers already waiting would silently receive a fulfilled promise with `undefined` instead of an error. Callers that are already awaiting the poisoned promise still get the rejection: their subscription is to an object that has already settled and cannot be changed. Eviction only affects who arrives afterwards. That is the correct semantics — you cannot retroactively repair an answer you have already handed out. ## The wider design point This is one instance of a general rule that follows from settle-once immutability: **cache promises for deduplication, but cache only successes.** Concretely: - Store the promise while it is pending so concurrent callers share the in-flight work. - On fulfilment, keeping it is fine and is the whole point. - On rejection, evict — the entry represents an attempt that failed, and attempts are not results. If you want more than one-shot retryability, the state you keep should be the *function*, plus policy around it: attempt counters, a backoff delay before the slot becomes eligible again, or a circuit breaker so that a genuinely dead dependency does not attract a stampede of simultaneous retries the instant you clear the cache. That last point matters at scale: naive eviction can convert one shared failure into N concurrent retries, since every waiting caller now finds an empty slot. ## A related variant to watch for The same bug appears without an explicit cache whenever a promise is created once at module scope: ```js export const ready = connect(); // runs at import time, settles once, forever ``` Here the promise is created during module evaluation, and any consumer importing `ready` after a failed connection observes the identical rejection with no chance of a retry. Exporting `connect` — or a `getReady()` accessor with eviction — keeps the retry decision in the caller's hands, which is where it belongs.

  • After you clear the cache in a rejection handler, do the callers already awaiting the poisoned promise get a retry?
    No. They subscribed to an object that has already settled as rejected, and settlement is immutable, so they receive that rejection. Eviction only helps callers who arrive after the slot is cleared. Repairing the in-flight callers would require them to hold a retrying wrapper rather than the raw cached promise.
  • Why must the rejection handler rethrow after clearing the cached variable?
    Because a rejection handler that returns normally *recovers* — the promise it produces fulfils. Without the rethrow, every waiting caller would receive a fulfilled promise carrying `undefined` instead of an error, turning a visible failure into a silent one, which is strictly worse than the original bug.
  • What new problem can naive eviction introduce under load?
    A retry stampede. The moment the slot clears, every caller that arrives finds it empty and starts its own attempt, so one shared failure becomes many concurrent requests against a dependency that is probably already struggling. Real implementations pair eviction with a cooldown, backoff, or circuit breaker.
  • How does the same bug show up without an explicit cache variable?
    By creating the promise once at module scope — for example `export const ready = connect();`. The work runs at import time and the promise settles once for the process lifetime, so a failed connection is permanently visible to every consumer. Exporting the function instead keeps each caller able to start a new attempt.

saying these in an interview costs you the question

  • Expects awaiting a rejected promise to retry the work
  • Thinks a promise re-runs its executor for each new subscriber
  • Clears the cache but forgets to rethrow, swallowing the error
  • Blames the dependency when no outbound request is made
  • Assumes a rejected promise expires or heals over time

context