← Back to question bank
JS FunctionMidMedium#2009 · 30m

Implement memoize with cache invalidation

Build memoize with cache invalidation. The interviewer expects a small, reusable utility with clear behavior under repeated calls and invalid inputs.

Answer Strategy

Memoize is interview-friendly because the surface looks small: cache the return value of a pure function keyed by its arguments. The senior part is what is missing from that one-line description: how arguments become a key, how the cache shrinks, when entries become stale, and how the caller invalidates a single key without throwing away the whole cache.

Separate four owners before coding. The wrapped function owns its work. The serializer owns argument identity. The cache owns lifetime (TTL plus a size bound). The caller owns invalidation triggers, expressed as either an explicit invalidate call or a should-invalidate predicate. With those boundaries the implementation falls out: read, decide freshness, compute on miss, evict if oversized.

Volunteer the dangerous cases. JSON serialization breaks for cycles, Map/Set, Dates, undefined, and deliberate identity (DOM nodes, class instances). Without an eviction bound a long-running app leaks memory through the cache. Without a TTL or invalidate hook a "fast" product page silently shows stale data after upstream entities change. Without a re-insertion trick the eviction throws away the freshest key first. State each tradeoff before the interviewer has to ask.

Reference Implementation: Memoize With TTL, Bounded Size, And Invalidation

A small public contract that lets the caller pick the eviction policy. The wrapped function is unchanged; everything else is composition.

type CacheEntry<R> = {
  value: R;
  storedAt: number;
};

type MemoizeOptions<A extends unknown[], R> = {
  // Argument shape decides cache identity. Default uses JSON; override for
  // arguments that are not JSON-stable (Date, Map, classes) or for arguments
  // whose identity is the only meaningful key (e.g. DOM nodes by reference).
  serializeArgs?: (args: A) => string;
  // Eviction policy. Memoize is most useful when the cache is bounded:
  // a per-call TTL handles freshness, max size handles unbounded keys.
  ttlMs?: number;
  maxSize?: number;
  // Inversion of control: let the caller decide whether to invalidate based
  // on (args, cachedValue). This is how product code expresses "expire when
  // an upstream entity changes" without leaking cache details into the
  // wrapped function.
  shouldInvalidate?: (args: A, cached: R) => boolean;
};

function memoize<A extends unknown[], R>(
  fn: (...args: A) => R,
  options: MemoizeOptions<A, R> = {}
): ((...args: A) => R) & {
  cache: Map<string, CacheEntry<R>>;
  invalidate: (...args: A) => void;
  clear: () => void;
} {
  const serialize = options.serializeArgs ?? ((args: A) => JSON.stringify(args));
  const cache = new Map<string, CacheEntry<R>>();

  function get(args: A): R {
    const key = serialize(args);
    const existing = cache.get(key);
    const now = Date.now();

    const expired =
      existing !== undefined &&
      ((options.ttlMs !== undefined && now - existing.storedAt >= options.ttlMs) ||
        (options.shouldInvalidate?.(args, existing.value) ?? false));

    if (existing !== undefined && !expired) {
      // LRU-ish: re-insertion moves the key to the most recent slot in the
      // Map iteration order, so eviction below removes the actually-oldest.
      cache.delete(key);
      cache.set(key, existing);
      return existing.value;
    }

    const value = fn(...args);
    cache.set(key, { value, storedAt: now });

    if (options.maxSize !== undefined && cache.size > options.maxSize) {
      const oldestKey = cache.keys().next().value;
      if (oldestKey !== undefined) cache.delete(oldestKey);
    }

    return value;
  }

  const memoized = ((...args: A) => get(args)) as ReturnType<typeof memoize<A, R>>;
  memoized.cache = cache;
  memoized.invalidate = (...args: A) => {
    cache.delete(serialize(args));
  };
  memoized.clear = () => cache.clear();
  return memoized;
}

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


type CacheEntry<R> = {
  value: R;
  storedAt: number;
};

type MemoizeOptions<A extends unknown[], R> = {
  // Argument shape decides cache identity. Default uses JSON; override for
  // arguments that are not JSON-stable (Date, Map, classes) or for arguments
  // whose identity is the only meaningful key (e.g. DOM nodes by reference).
  serializeArgs?: (args: A) => string;
  // Eviction policy. Memoize is most useful when the cache is bounded:
  // a per-call TTL handles freshness, max size handles unbounded keys.
  ttlMs?: number;
  maxSize?: number;
  // Inversion of control: let the caller decide whether to invalidate based
  // on (args, cachedValue). This is how product code expresses "expire when
  // an upstream entity changes" without leaking cache details into the
  // wrapped function.
  shouldInvalidate?: (args: A, cached: R) => boolean;
};

function memoize<A extends unknown[], R>(
  fn: (...args: A) => R,
  options: MemoizeOptions<A, R> = {}
): ((...args: A) => R) & {
  cache: Map<string, CacheEntry<R>>;
  invalidate: (...args: A) => void;
  clear: () => void;
} {
  const serialize = options.serializeArgs ?? ((args: A) => JSON.stringify(args));
  const cache = new Map<string, CacheEntry<R>>();

  function get(args: A): R {
    const key = serialize(args);
    const existing = cache.get(key);
    const now = Date.now();

    const expired =
      existing !== undefined &&
      ((options.ttlMs !== undefined && now - existing.storedAt >= options.ttlMs) ||
        (options.shouldInvalidate?.(args, existing.value) ?? false));

    if (existing !== undefined && !expired) {
      // LRU-ish: re-insertion moves the key to the most recent slot in the
      // Map iteration order, so eviction below removes the actually-oldest.
      cache.delete(key);
      cache.set(key, existing);
      return existing.value;
    }

    const value = fn(...args);
    cache.set(key, { value, storedAt: now });

    if (options.maxSize !== undefined && cache.size > options.maxSize) {
      const oldestKey = cache.keys().next().value;
      if (oldestKey !== undefined) cache.delete(oldestKey);
    }

    return value;
  }

  const memoized = ((...args: A) => get(args)) as ReturnType<typeof memoize<A, R>>;
  memoized.cache = cache;
  memoized.invalidate = (...args: A) => {
    cache.delete(serialize(args));
  };
  memoized.clear = () => cache.clear();
  return memoized;
}
TypeScript · runnable

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Contract
Cover argument identity: numbers, strings, ordered arrays, deep objects with stable keys, and the documented failure when serialization is unsafe (Date, Map, undefined). State the rule in code review.
Lifetime
Use fake timers to assert TTL expiry; use a small maxSize to assert eviction order. Add a leak test that calls the memoized function with thousands of unique keys and asserts the cache size never exceeds the bound.
Invalidation
Test the caller-driven hooks: invalidate(...args) clears one key, clear() clears all, shouldInvalidate runs against the cached value before the function fires.
import { describe, it, expect, vi } from 'vitest';

describe('memoize', () => {
  it('returns the cached value on identical arguments', () => {
    const fn = vi.fn((a: number, b: number) => a + b);
    const memoized = memoize(fn);
    expect(memoized(1, 2)).toBe(3);
    expect(memoized(1, 2)).toBe(3);
    expect(fn).toHaveBeenCalledTimes(1);
  });

  it('respects ttl', () => {
    vi.useFakeTimers();
    const fn = vi.fn((value: string) => value.toUpperCase());
    const memoized = memoize(fn, { ttlMs: 100 });
    memoized('a');
    memoized('a');
    vi.advanceTimersByTime(150);
    memoized('a');
    expect(fn).toHaveBeenCalledTimes(2);
    vi.useRealTimers();
  });

  it('evicts oldest when maxSize is exceeded', () => {
    const memoized = memoize((value: string) => value, { maxSize: 2 });
    memoized('a');
    memoized('b');
    memoized('c');
    expect(memoized.cache.has('["a"]')).toBe(false);
    expect(memoized.cache.has('["b"]')).toBe(true);
    expect(memoized.cache.has('["c"]')).toBe(true);
  });

  it('exposes invalidate and clear', () => {
    const fn = vi.fn((value: string) => value);
    const memoized = memoize(fn);
    memoized('a');
    memoized.invalidate('a');
    memoized('a');
    expect(fn).toHaveBeenCalledTimes(2);
    memoized.clear();
    expect(memoized.cache.size).toBe(0);
  });
});

Interviewer Signal

Tests whether you can turn a familiar utility into a precise contract instead of coding only the happy path.

Constraints

  • Define the function signature before coding.
  • Do not rely on global mutable state unless it is part of the returned closure.
  • Explain time and space cost for the common path.

Model Answer Shape

  • Write the smallest public contract first.
  • Cover empty input, repeated calls, thrown errors, and cleanup behavior.
  • Keep implementation readable enough to narrate under interview pressure.

Tradeoffs

  • A compact implementation is attractive, but explicit state names are easier to debug live.
  • Supporting every possible input can distract from the core contract; state the scope before coding.

Edge Cases

  • No arguments or undefined callbacks.
  • Synchronous throw inside the wrapped function.
  • Repeated calls before the previous result settles.

Testing And Proof

  • Happy path with representative inputs.
  • Boundary input and repeated invocation.
  • Cleanup or cancellation if timers or promises are involved.

Follow-Ups

  • How would you expose cancellation?
  • How would the API change for React usage?