Coding problem · Medium · Design & Data Structures

Single-Flight Cache Loader

Medium Practical tasks

The home page's "deals" block takes loadMs to build and is cached for ttlMs. When it expires, thousands of concurrent requests would all rebuild it. Implement a cache that coalesces loads (like Go's singleflight or a rebuild lock). Time is passed in explicitly.

SingleFlightCache(loadMs, ttlMs):

  • get(key, nowMs):
    1. If a load for key started at time s and nowMs ≥ s + loadMs, it has completed: the value is cached until s + loadMs + ttlMs.
    2. If the key is cached and nowMs < expiresAt, return "hit".
    3. Else if a load for the key is in flight, return "wait" (the caller joins it).
    4. Else start a load now and return "load".
  • loads(key) — how many loads were started for the key.

Calls arrive with non-decreasing nowMs. See Cache Stampedes.

Example 1

Input: ["SingleFlightCache","get","get","get","get","get","get","loads"]
[[100,1000],["k",0],["k",10],["k",50],["k",100],["k",1099],["k",1100],["k"]]
Output: [null,"load","wait","wait","hit","hit","load",2]
Explanation: Requests during the first load wait for it; the value is fresh from 100 to 1100, then one request reloads.

Hints

Hint 1

Per key, track an in-flight start time (or none), the expiry of the cached value, and a load counter.

Hint 2

Always complete finished loads first, then decide hit / wait / load.

Write and run your solution

Loading the editor… (JavaScript is required to run code in your browser.)

Solution and explanation

Show the solution

Time O(1) per call · Space O(keys)

The key idea is a per-key in-flight marker: the first miss starts the load and records it; later misses see the marker and join instead of starting their own. When the load finishes, the value is cached and the marker cleared. In a real service the marker is a promise/future in memory (singleflight, Caffeine loaders) or a short-lived Redis lock (SET lock NX PX) shared by all instances, and joiners either wait for the result or serve a stale copy.

Reference solution

class SingleFlightCache {
  constructor(loadMs, ttlMs) {
    this.loadMs = loadMs;
    this.ttlMs = ttlMs;
    this.inflight = new Map(); // key -> load start
    this.expires = new Map();  // key -> expiresAt
    this.count = new Map();    // key -> loads started
  }

  get(key, nowMs) {
    const start = this.inflight.get(key);
    if (start !== undefined && nowMs >= start + this.loadMs) {
      this.expires.set(key, start + this.loadMs + this.ttlMs);
      this.inflight.delete(key);
    }
    if (this.expires.has(key) && nowMs < this.expires.get(key)) return "hit";
    if (this.inflight.has(key)) return "wait";
    this.inflight.set(key, nowMs);
    this.count.set(key, (this.count.get(key) ?? 0) + 1);
    return "load";
  }

  loads(key) {
    return this.count.get(key) ?? 0;
  }
}

Common mistakes

  • Forgetting to complete a finished load before checking freshness.
  • Starting a second load while one is in flight.
  • Off-by-one on expiry (now < expiresAt is fresh).

Follow-up questions

  • Serve the stale value to joiners instead of making them wait.
  • Refresh slightly before expiry (probabilistic early expiration).