NNODE LOOP LABruntime observatoryNNEON · Articles in 90 languages
CONNECTING
v24.18.0linux/x64
Key → hit/miss → invalidation
21

Caching in Node.js, NestJS, Redis, and HTTP

Learn where caching removes repeated work, how cache-aside behaves, and how stale data, stampedes, and unbounded memory cause incidents.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Timeline

READY
0 ms
Events will appear hereRun the selected scenario
#TIMESOURCEEVENT
Waiting for an experiment…
CHAPTER 21
DEEP DIVE · FROM BASICS TO CODE

Understanding: Caching in Node.js, NestJS, Redis, and HTTP

You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.

FIRST, IN PLAIN LANGUAGE

A cache is like a small shelf next to your desk. Keeping a frequently used book there is cheaper than walking to the archive every time. The shelf is limited, its copy can become outdated, and an archive update must tell you which shelf copy to remove.

TECHNICAL FOUNDATION

A cache stores a derived copy of data or computation closer to its consumer. A hit avoids the slower primary source, while a miss adds cache lookup before ordinary loading. A correct design defines key identity, lifetime, size bounds, invalidation, concurrency behavior, source of truth, and acceptable staleness.

Why it mattersCaching reduces latency, database or network load, and repeated-computation cost, but introduces a second state version. Senior engineers must prove the benefit through hit ratio and latency while preventing stale security data, memory leaks, stampedes, and Redis becoming a mandatory failure point.
WHERE THE WORK RUNS
01CLIENT / CDNCache-Control · ETag
02NODE / NESTMap · CacheModule
03DISTRIBUTEDRedis · shared keys
04PRIMARYPostgreSQL · external API
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Cache hit

The key exists and the result is returned without contacting the primary source.

02

Cache miss

The key is absent or expired, so the application loads from primary and usually stores a copy.

03

Hit ratio

The share served from cache: hits divided by hits plus misses; it matters only with latency and correctness.

04

TTL

Time To Live, after which an entry expires and the next access becomes a miss.

05

Eviction

Removal under a TTL, LRU, LFU, or other policy so bounded cache memory can admit new entries.

06

Invalidation

Explicit removal or replacement of a cached copy after its source of truth changes.

07

Cache-aside

The app reads cache first, loads primary on a miss, stores a copy, and deletes the key after a write.

08

Cache stampede

Many callers miss one hot key simultaneously and repeat the same expensive loader.

09

Single-flight

Concurrent misses for one key share an in-flight Promise or coordinated lock.

10

Stale data

A cached value no longer matches the source of truth but is still visible to a consumer.

11

Cache key

Stable result identity containing every relevant dimension such as entity, tenant, locale, filters, and version.

12

CDN

A Content Delivery Network: distributed shared HTTP caches close to users.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Find repeated expensive work

    Measure database queries, external calls, or CPU computation; do not cache a cheap unique lookup.

  2. 02
    Name the source of truth

    PostgreSQL, an upstream API, or an immutable artifact remains authoritative and can rebuild the cache.

  3. 03
    Design the key

    Include every response dimension; omitting tenant or permission scope can mix user data.

  4. 04
    Handle hit and miss

    A hit returns immediately; a miss starts the loader and stores only a successful result.

  5. 05
    Bound lifetime and size

    TTL bounds staleness while maximum entries or bytes and eviction bound memory independently.

  6. 06
    Define write policy

    Cache-aside normally commits primary and then deletes the key; write-through and write-behind have different costs.

  7. 07
    Protect a hot miss

    Single-flight, locks, stale-while-revalidate, or TTL jitter prevent synchronized primary load.

  8. 08
    Choose a cache level

    Local memory is fastest, Redis is shared by replicas, and HTTP or CDN caching can bypass Node entirely.

  9. 09
    Measure the result

    Observe hits, misses, load duration, evictions, memory, Redis errors, stale age, and primary load.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

No cache is not always a defect

A rare high-cardinality query gets few hits while lookup, serialization, and invalidation add overhead.

02

Local cache is not shared

Every Node process, Worker, or Kubernetes Pod owns memory and can temporarily expose a different revision.

03

TTL does not bound key count

Millions of unique entries can be created during one TTL window, so maximum size and eviction remain necessary.

04

Zero and false are valid values

Check miss with undefined or null according to the API rather than if (!value).

05

Negative caching can help

Short-lived “not found” entries protect the database but can hide a newly created object until invalidated.

06

TTL jitter distributes expiry

A small random offset prevents thousands of keys from expiring in the same second.

07

Cache errors can often degrade

A derived cache timeout can fall back to primary, but fallback must be bounded so cache failure does not overload it.

08

Authorization needs stricter freshness

A long permission TTL can preserve revoked access, requiring short lifetime, versioned keys, or reliable invalidation.

09

HTTP caching stores representations

Cache-Control controls freshness, ETag validates versions, and Vary separates response variants.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly products: ProductsRepository,
  ) {}

  async findOne(id: string) {
    const key = `product:v1:${id}`;
    const hit = await this.cache.get<Product>(key);
    if (hit !== undefined && hit !== null) return hit;

    const product = await this.products.findById(id);
    await this.cache.set(key, product, 30_000);
    return product;
  }
}
05 · Runtime code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/cache-lab.js
cache218 lines
import 'reflect-metadata';
import {
  Dependencies,
  Injectable,
  Module,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  CACHE_MANAGER,
  CacheModule,
} from '@nestjs/cache-manager';
import { performance } from 'node:perf_hooks';

const CACHE_TRACE = Symbol('CACHE_TRACE');
const PRODUCT_TTL_MS = 120;

const wait = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

class ProductRepository {
  constructor() {
    this.readCount = 0;
    this.products = new Map([
      [
        'book-42',
        {
          id: 'book-42',
          name: 'Node.js Runtime',
          price: 4200,
          revision: 1,
        },
      ],
    ]);
  }

  async findById(id) {
    this.readCount += 1;
    await wait(35);
    const product = this.products.get(id);
    return product ? structuredClone(product) : null;
  }

  async updatePrice(id, price) {
    const current = this.products.get(id);
    const updated = {
      ...current,
      price,
      revision: current.revision + 1,
    };
    this.products.set(id, updated);
    return structuredClone(updated);
  }
}
Injectable()(ProductRepository);

class ProductCacheService {
  constructor(cache, products, trace) {
    this.cache = cache;
    this.products = products;
    this.trace = trace;
    this.inFlight = new Map();
  }

  key(id) {
    return `product:v1:${id}`;
  }

  async getById(id) {
    const key = this.key(id);
    const cached = await this.cache.get(key);
    if (cached !== undefined && cached !== null) {
      this.trace.emit('cache', 'hit', `HIT ${key}: repository не вызывается`);
      return cached;
    }

    if (this.inFlight.has(key)) {
      this.trace.emit(
        'single-flight',
        'join',
        `MISS ${key}: запрос присоединён к уже выполняющемуся loader`,
      );
      return this.inFlight.get(key);
    }

    this.trace.emit(
      'cache',
      'miss',
      `MISS ${key}: выполняется repository.findById`,
    );
    const loading = this.products
      .findById(id)
      .then(async (product) => {
        await this.cache.set(key, product, PRODUCT_TTL_MS);
        return product;
      })
      .finally(() => {
        this.inFlight.delete(key);
      });
    this.inFlight.set(key, loading);
    return loading;
  }

  async updatePrice(id, price) {
    const updated = await this.products.updatePrice(id, price);
    await this.cache.del(this.key(id));
    this.trace.emit(
      'invalidation',
      'delete',
      `UPDATE записан в primary; ключ ${this.key(id)} удалён`,
    );
    return updated;
  }
}
Dependencies(CACHE_MANAGER, ProductRepository, CACHE_TRACE)(
  ProductCacheService,
);
Injectable()(ProductCacheService);

class CacheLabModule {}

function configureCacheLab(trace) {
  Module({
    imports: [
      CacheModule.register({
        ttl: PRODUCT_TTL_MS,
      }),
    ],
    providers: [
      ProductRepository,
      ProductCacheService,
      {
        provide: CACHE_TRACE,
        useValue: trace,
      },
    ],
  })(CacheLabModule);
  return CacheLabModule;
}

export async function cachingStrategies(emit) {
  const trace = { emit };
  const application = await NestFactory.createApplicationContext(
    configureCacheLab(trace),
    { logger: false },
  );

  try {
    const repository = application.get(ProductRepository);
    const service = application.get(ProductCacheService);

    emit(
      'baseline',
      'start',
      'Без cache три одинаковых чтения трижды занимают repository и connection',
    );
    const baselineStarted = performance.now();
    await repository.findById('book-42');
    await repository.findById('book-42');
    await repository.findById('book-42');
    const baselineMs = Math.round(performance.now() - baselineStarted);
    emit(
      'baseline',
      'result',
      `Без cache: reads=3, elapsed≈${baselineMs} мс`,
    );

    const readsBeforeCache = repository.readCount;
    const cachedStarted = performance.now();
    await service.getById('book-42');
    await service.getById('book-42');
    await service.getById('book-42');
    const cachedMs = Math.round(performance.now() - cachedStarted);
    emit(
      'cache',
      'result',
      `Cache-aside: repository reads=${repository.readCount - readsBeforeCache}, elapsed≈${cachedMs} мс`,
    );

    await wait(PRODUCT_TTL_MS + 20);
    const readsBeforeExpiry = repository.readCount;
    await service.getById('book-42');
    emit(
      'ttl',
      'expired',
      `После TTL новый MISS добавил repository reads=${repository.readCount - readsBeforeExpiry}`,
    );

    const cache = application.get(CACHE_MANAGER);
    await cache.del(service.key('book-42'));
    const readsBeforeBurst = repository.readCount;
    await Promise.all(
      Array.from({ length: 5 }, () => service.getById('book-42')),
    );
    emit(
      'single-flight',
      'result',
      `Пять одновременных MISS вызвали loader ${repository.readCount - readsBeforeBurst} раз вместо 5`,
    );

    await service.updatePrice('book-42', 4500);
    const fresh = await service.getById('book-42');
    emit(
      'invalidation',
      'result',
      `После invalidation прочитана revision=${fresh.revision}, price=${fresh.price}`,
    );

    emit(
      'architecture',
      'boundary',
      'In-memory cache принадлежит одному Node process; Redis нужен для общего cache нескольких replicas',
    );
  } finally {
    await application.close();
    emit('cleanup', 'done', 'Nest application context и cache store закрыты');
  }
}

The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.

06 · RECIPES

Practical patterns worth keeping nearby

Compare the goal, code, and caveats instead of memorizing syntax without a model.

01

Node.js: minimal cache-aside

Avoid running one expensive loader on every sequential read.

const cache = new Map();

async function getProduct(id) {
  const key = `product:${id}`;
  const entry = cache.get(key);

  if (entry && entry.expiresAt > Date.now()) {
    return entry.value;
  }

  const product = await repository.findById(id);
  cache.set(key, {
    value: product,
    expiresAt: Date.now() + 30_000,
  });
  return product;
}
  • Without cache, every read holds a database connection and pays full latency.
  • This teaching Map still needs maximum-size eviction.
  • Each process has a separate copy in a multi-process deployment.
02

Nest: CacheModule and CACHE_MANAGER

Obtain a managed cache store through dependency injection.

@Module({
  imports: [CacheModule.register({ ttl: 30_000 })],
  providers: [ProductsService],
})
export class ProductsModule {}

@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
  ) {}

  get(id: string) {
    return this.cache.get(`product:v1:${id}`);
  }
}
  • CacheModule owns provider lifecycle.
  • Current cache-manager TTL is expressed in milliseconds.
  • v1 allows a future cached-value schema change.
03

Nest: cache-aside with invalidation

Avoid returning an old price after a successful update.

async findOne(id: string) {
  const key = `product:v1:${id}`;
  const hit = await this.cache.get<Product>(key);
  if (hit !== undefined && hit !== null) return hit;

  const product = await this.products.findById(id);
  await this.cache.set(key, product, 30_000);
  return product;
}

async update(id: string, dto: UpdateProductDto) {
  const product = await this.products.update(id, dto);
  await this.cache.del(`product:v1:${id}`);
  return product;
}
  • Primary update happens before eviction.
  • The next read repopulates the new revision.
  • Define behavior for cache.del failure after a successful database commit.
04

Nest CacheInterceptor for a simple GET

Cache a safe representation without manual controller get and set calls.

@Controller('public/catalog')
@UseInterceptors(CacheInterceptor)
export class CatalogController {
  @Get()
  @CacheTTL(15_000)
  findAll() {
    return this.catalog.findPublicItems();
  }
}
  • The official interceptor automatically caches GET responses.
  • Personalized responses need correct key tracking.
  • Never response-cache business commands and side effects.
05

Single-flight against a stampede

Let five concurrent misses share one database Promise.

const inFlight = new Map<string, Promise<Product>>();

async function loadOnce(key: string) {
  const running = inFlight.get(key);
  if (running) return running;

  const promise = repository
    .findById(key)
    .finally(() => inFlight.delete(key));

  inFlight.set(key, promise);
  return promise;
}
  • finally removes coordination state, not the cached value.
  • Local single-flight protects only one process.
  • Multiple replicas need a Redis lock, early refresh, or other distributed mechanism.
06

Redis cache-aside

Share a hot cache across multiple Node or Nest replicas.

async function getProduct(id) {
  const key = `product:v1:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);

  const product = await repository.findById(id);
  await redis.set(key, JSON.stringify(product), {
    EX: 30 + Math.floor(Math.random() * 6),
  });
  return product;
}
  • Redis adds a network hop but provides a shared copy.
  • EX is in seconds; verify the API of the chosen client.
  • Random jitter spreads expiration.
07

External API: short cache

Avoid exhausting a provider rate limit with identical calls.

const key = `shipping:v2:${country}:${postalCode}:${weight}`;
const cached = await cache.get(key);
if (cached) return cached;

const quote = await shippingProvider.quote(input);
await cache.set(key, quote, 60_000);
return quote;
  • Without cache, a traffic spike repeats network latency and consumes quota.
  • The key must contain every calculation input.
  • TTL follows the business freshness requirement.
08

HTTP browser and CDN cache

Avoid sending an unchanged public response through Node for every user.

response.setHeader(
  'Cache-Control',
  'public, max-age=60, s-maxage=300, stale-while-revalidate=30',
);
response.setHeader('ETag', versionHash);
  • max-age controls browser freshness.
  • s-maxage controls shared cache or CDN freshness.
  • ETag enables 304 without retransmitting the body.
  • Personalized data usually needs private or no-store.
09

What to observe

Prove that caching helps and does not conceal an incident.

cache_requests_total{result="hit"}
cache_requests_total{result="miss"}
cache_load_duration_seconds
cache_evictions_total
cache_entries
cache_stale_served_total
primary_queries_total
  • Read hit ratio alongside primary load and end-to-end latency.
  • Entries and memory expose missing bounds.
  • A miss storm appears as a synchronized loader spike.
07 · PRODUCTION CASES

How a learning mistake becomes an incident

A realistic service: the original code, observable failure, corrected implementation, and why the correction works.

CASE 01

The home page repeats one expensive query for every visitor

A popular Nest catalog endpoint runs the same aggregate JOIN. Data changes a few times per minute, but every HTTP request occupies another PostgreSQL connection.

INCIDENT CONTEXT

Without caching, database queries grow almost linearly with traffic. Pool wait and p95 rise first; timeouts then trigger retries that add even more primary load.

BEFOREPROBLEMATIC IMPLEMENTATION
@Injectable()
export class FeaturedProductsQuery {
  constructor(private readonly db: Database) {}

  async execute(locale: string) {
    return this.db.query(
      `SELECT p.id, t.name, avg(r.score) AS rating
       FROM products p
       JOIN translations t ON t.product_id = p.id
       LEFT JOIN reviews r ON r.product_id = p.id
       WHERE p.featured AND t.locale = $1
       GROUP BY p.id, t.name
       ORDER BY rating DESC NULLS LAST
       LIMIT 24`,
      [locale],
    );
  }
}

The query is correct, but thousands of consumers repeat identical work. Even a fast plan consumes connection time, CPU, and buffers; a traffic spike can exhaust a small pool.

AFTERCORRECTED IMPLEMENTATION
@Injectable()
export class FeaturedProductsQuery {
  private readonly inFlight = new Map<string, Promise<Product[]>>();

  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly repository: ProductsRepository,
  ) {}

  async execute(locale: string) {
    const key = `featured:v2:${locale}`;
    const hit = await this.cache.get<Product[]>(key);
    if (hit !== undefined && hit !== null) return hit;

    const running = this.inFlight.get(key);
    if (running) return running;

    const loading = this.repository
      .findFeatured(locale)
      .then(async (rows) => {
        await this.cache.set(key, rows, 10_000);
        return rows;
      })
      .finally(() => this.inFlight.delete(key));

    this.inFlight.set(key, loading);
    return loading;
  }

  invalidate(locale: string) {
    return this.cache.del(`featured:v2:${locale}`);
  }
}

The key separates locale and schema version. TTL bounds staleness, invalidation runs after featured data changes, and an in-flight Promise prevents concurrent local misses from duplicating SQL.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

@Inject(CACHE_MANAGER)
Asks the Nest dependency-injection container for the cache-manager instance created by CacheModule.
cache.get(key)
Reads the derived copy under one exact key; undefined or null is a miss while zero, false, and an empty string can be valid hits.
cache.set(key, value, ttl)
Stores a serializable copy for a bounded lifetime; current Nest cache-manager uses a millisecond TTL.
cache.del(key)
Invalidates one cached copy after primary state changes so the next read loads the new revision.
inFlight.get(key)
Checks whether the current process already runs this key loader and lets a concurrent caller reuse its Promise.
finally(() => inFlight.delete(key))
Removes coordination after success or failure so a rejected loader cannot block future attempts forever.
stableHash(dimensions)
A representative helper that canonically serializes result dimensions into a compact key without raw personal data.
ttlJitter(maxMs)
A representative helper that adds a small random TTL offset so popular keys do not expire simultaneously.
singleFlight.do(key, loader)
A conceptual abstraction that joins parallel calls for one key; local implementations store a Promise while distributed ones coordinate externally.
featured:v2:${locale}
A versioned key includes locale because each language produces a different catalog representation.
WHY THE CORRECTION WORKS

Cache measurable repeated work. Compare hit ratio, p95, and primary query rate afterward. If nearly every key is unique, remove this layer instead of continually increasing TTL.

WHAT PRODUCTION SHOWED
  • One query fingerprint dominates pg_stat_statements.
  • Database pool wait rises with home-page requests per second.
  • Expiry creates a narrow burst of identical SQL queries.
CASE 02

Calculating shipping on every edit exhausts an external API quota

The checkout frontend refines a cart and repeats shipping-price requests. A Nest service calls the paid provider every time even when country, postal code, weight, and cart revision are unchanged.

INCIDENT CONTEXT

Without caching, every request pays network latency and total traffic quickly reaches the provider rate limit. Retries after 429 can amplify load synchronously.

BEFOREPROBLEMATIC IMPLEMENTATION
@Injectable()
export class ShippingService {
  constructor(private readonly provider: ShippingProvider) {}

  quote(input: ShippingQuoteDto) {
    return this.provider.quote({
      country: input.country,
      postalCode: input.postalCode,
      weight: input.weight,
      items: input.items,
    });
  }
}

A result that is deterministic for a short window is never reused. The endpoint inherits all latency, quota pressure, and brief outages of the upstream provider.

AFTERCORRECTED IMPLEMENTATION
@Injectable()
export class ShippingService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
    private readonly provider: ShippingProvider,
    private readonly singleFlight: SingleFlight,
  ) {}

  async quote(input: ShippingQuoteDto) {
    const dimensions = {
      country: input.country,
      postalCode: input.postalCode.trim().toUpperCase(),
      weight: input.weight,
      cartRevision: input.cartRevision,
    };
    const key = `shipping:v3:${stableHash(dimensions)}`;
    const hit = await this.cache.get<ShippingQuote>(key);
    if (hit !== undefined && hit !== null) return hit;

    return this.singleFlight.do(key, async () => {
      const quote = await this.provider.quote(input);
      await this.cache.set(key, quote, 60_000 + ttlJitter(5_000));
      return quote;
    });
  }
}

The key includes every result dimension without raw personal data. Short TTL matches quote freshness, jitter spreads expiry, and single-flight reduces parallel upstream calls.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

@Inject(CACHE_MANAGER)
Asks the Nest dependency-injection container for the cache-manager instance created by CacheModule.
cache.get(key)
Reads the derived copy under one exact key; undefined or null is a miss while zero, false, and an empty string can be valid hits.
cache.set(key, value, ttl)
Stores a serializable copy for a bounded lifetime; current Nest cache-manager uses a millisecond TTL.
cache.del(key)
Invalidates one cached copy after primary state changes so the next read loads the new revision.
inFlight.get(key)
Checks whether the current process already runs this key loader and lets a concurrent caller reuse its Promise.
finally(() => inFlight.delete(key))
Removes coordination after success or failure so a rejected loader cannot block future attempts forever.
stableHash(dimensions)
A representative helper that canonically serializes result dimensions into a compact key without raw personal data.
ttlJitter(maxMs)
A representative helper that adds a small random TTL offset so popular keys do not expire simultaneously.
singleFlight.do(key, loader)
A conceptual abstraction that joins parallel calls for one key; local implementations store a Promise while distributed ones coordinate externally.
featured:v2:${locale}
A versioned key includes locale because each language produces a different catalog representation.
WHY THE CORRECTION WORKS

For an external API, caching can reduce latency, cost, and quota dependence. Tax, inventory, permissions, and other critical data need their own freshness policies; stale values are not automatically safe during an outage.

WHAT PRODUCTION SHOWED
  • Provider requests greatly exceed the number of unique carts.
  • Provider 429 and p95 map directly onto checkout p95.
  • Synchronized expiry creates a burst of identical outbound spans.
08 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

A cache always makes an application faster.

ACTUALLY

At low hit ratio, lookup, serialization, network, and invalidation can make the path slower.

MYTH

A global Map is enough.

ACTUALLY

Without TTL, maximum size, and eviction, it becomes a memory leak and disappears on restart.

MYTH

TTL completely solves consistency.

ACTUALLY

TTL only bounds a stale window; critical writes usually need invalidation or versioning.

MYTH

Redis is the source of truth.

ACTUALLY

An ordinary cache must be rebuildable while durable business state belongs in an appropriate primary store.

MYTH

One miss produces one database query.

ACTUALLY

Hundreds of concurrent callers can all miss; use single-flight or distributed coordination.

MYTH

Caching POST prevents double payment.

ACTUALLY

Business side effects need idempotency keys and database constraints rather than a response cache.

09 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. When does missing cache cause a problem, and when does cache only add complexity?
  2. How do TTL, maximum size, and eviction differ?
  3. Why does a local Map not share values between two Kubernetes Pods?
  4. Which fields belong in the key of a personalized endpoint?
  5. Why is if (!cached) wrong when zero is a valid cached value?
  6. How does single-flight protect the primary from a stampede?
  7. In which order does cache-aside update primary and delete the key?
  8. How does CacheInterceptor differ from manual CACHE_MANAGER use?
  9. When is HTTP or CDN caching more useful than Redis?
  10. Which metrics prove that caching actually helps?