PAUL CHONGSenior Software Engineer

Client-Side Caching

2026-08-06 · 7 min read

Every network request has a cost: latency, bandwidth, server load, and perceived slowness. Client-side caching avoids paying that cost twice for the same data. There are two distinct layers where caching happens — in your application's memory, and in the HTTP stack — and they solve different problems.

In-memory cache

An in-memory cache stores API responses in a JavaScript data structure so repeat requests return instantly without a network round trip.

The canonical use case is search autocomplete: the user types "re", you fetch results, they backspace to "r" and retype "re" — the second "re" should come from cache, not the network.

const cache = new Map();

async function fetchResults(query) {
  if (cache.has(query)) return cache.get(query);

  const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
  cache.set(query, results);
  return results;
}

Cache hit → instant. Cache miss → fetch, store, return.

Cache structure options

How you store cached results affects memory usage and lookup cost.

1. Hash map (query → results[])

{
  "re":     [{ id: 1, name: "React" }, { id: 2, name: "Redux" }],
  "rea":    [{ id: 1, name: "React" }, { id: 3, name: "Reanimated" }],
  "react":  [{ id: 1, name: "React" }],
}

Simple O(1) lookup by key. The downside: the same entity (React, id: 1) is stored as a separate object under every query that returns it. Memory usage grows with overlap.

2. Normalized (query → resultIds[], entities by ID)

{
  queries: {
    "re":    [1, 2],
    "rea":   [1, 3],
    "react": [1],
  },
  entities: {
    1: { id: 1, name: "React" },
    2: { id: 2, name: "Redux" },
    3: { id: 3, name: "Reanimated" },
  }
}

Each entity lives exactly once. A query result is just a list of IDs — a join step reconstructs the full objects. No duplication across overlapping queries. Updating an entity (stale data, server push) means updating one place. Best for long-lived SPAs with many overlapping queries. See Data Normalization for the full pattern.

3. Simple array

[
  { query: "re",    results: [...] },
  { query: "react", results: [...] },
]

Lookup requires a linear scan. Loses ranking order on retrieval. No reason to use this — a Map is strictly better.

Eviction strategies

An unbounded cache grows forever. Eviction keeps memory under control.

LRU (Least Recently Used)

Keep the N most recently accessed entries. When the cache is full and a new entry arrives, evict the one that was accessed least recently.

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return null;
    // Re-insert to mark as most recently used
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    else if (this.cache.size >= this.capacity) {
      // Map preserves insertion order — first key is least recently used
      this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
  }
}

JavaScript's Map preserves insertion order (guaranteed by the spec), which means the first key returned by .keys() is always the oldest entry. Combined with O(1) delete by key, that's enough to implement LRU without a linked list.

LRU is the right default for autocomplete: the user's recent queries are the most likely to be retyped.

TTL (Time-To-Live)

Expire entries after N seconds, regardless of access frequency.

const cache = new Map();

function set(key, value, ttlMs) {
  cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}

function get(key) {
  const entry = cache.get(key);
  if (!entry) return null;
  if (Date.now() > entry.expiresAt) {
    cache.delete(key);
    return null;
  }
  return entry.value;
}

TTL is the right default for data that goes stale: stock prices, live scores, inventory counts. An LRU cache of stale data is worse than no cache.

LRU and TTL compose: keep at most N entries (LRU), and expire any entry older than T seconds (TTL).

HTTP caching

The browser has its own cache below your application code. HTTP caching headers control it. This layer requires no JavaScript — the browser handles it automatically based on response headers.

Cache-Control

Cache-Control: max-age=60

The browser serves this response from its cache for 60 seconds without making any network request. On second load, the request never leaves the browser.

Cache-Control: no-cache

Don't serve from cache without revalidating with the server first. Confusingly, no-cache doesn't mean "don't cache" — it means "always check freshness." no-store means truly don't cache.

ETag and conditional requests

After max-age expires, the browser has a stale response but doesn't know if anything changed. Instead of re-downloading the full response, it can ask:

GET /api/feed
If-None-Match: "abc123"       ← the ETag from the previous response

If the data hasn't changed, the server returns 304 Not Modified with no body — just a confirmation that the cached version is still valid. The browser uses its cached copy. The cost is one round trip with no payload transfer instead of a full re-download.

HTTP/1.1 304 Not Modified
ETag: "abc123"

If the data changed, the server returns 200 OK with the new response and a new ETag.

stale-while-revalidate

Cache-Control: max-age=30, stale-while-revalidate=300

Serve from cache immediately (even if stale, up to 300 seconds past expiry), while revalidating in the background. The user sees instant results; the cache updates silently.

This is ideal for a news feed or blog index: the user returns to the feed and sees their last-known state instantly, while the browser fetches fresher data in the background. If they scroll down after a second, the updated content is already there.

stale-while-revalidate timeline:

0–30s:   max-age fresh     → serve from cache, no request
30–330s: stale-while-revalidate window → serve stale immediately + revalidate in background
330s+:   expired           → must revalidate before serving

Request deduplication

If two components mount simultaneously and both need the same data, a naive implementation fires two identical requests:

Component A mounts → GET /api/feed
Component B mounts → GET /api/feed   ← duplicate, wasted

Request deduplication coalesces concurrent requests for the same key into one. Both components wait on the same in-flight promise:

const inFlight = new Map();

async function fetchDeduped(url) {
  if (inFlight.has(url)) return inFlight.get(url);

  const promise = fetch(url).then(r => r.json()).finally(() => {
    inFlight.delete(url);
  });

  inFlight.set(url, promise);
  return promise;
}

Both callers get the same promise. When it resolves, both receive the result. Only one request was made.

TanStack Query and Relay do this automatically — any query with the same key that's already in flight shares the result. Without a library, this is easy to miss: mounting a <UserAvatar> in a header and a sidebar simultaneously shouldn't cause two /api/me requests.

Putting it together

These layers are complementary, not alternatives:

LayerWhat it avoidsScope
In-memory cacheRe-fetching within a sessionJavaScript, per page load
HTTP cacheRe-downloading unchanged responsesBrowser, across page loads
Request dedupParallel identical requestsJavaScript, per render cycle

A news feed benefits from all three: HTTP caching serves stale content instantly on return visits, request deduplication prevents parallel mounts from firing duplicate requests, and an in-memory cache makes navigating back to a previously loaded page instant within the same session.