Saltar al contenido

Estrategias de caching para aplicaciones web

De las cabeceras HTTP a los patrones de Redis: guía práctica de capas de caché que reducen latencia y carga de base de datos sin servir datos obsoletos.

4 min de lectura
Diagrama de arquitectura de caché en capas que muestra los niveles de navegador, CDN, aplicación y base de datos

Toda aplicación lenta tiene el mismo problema: busca datos que ya tiene. El caching es la herramienta de rendimiento más efectiva en el cinturón de un ingeniero de backend, pero un caching incorrecto es peor que no tener caching — sirve datos obsoletos, genera bugs de consistencia y da una falsa sensación de rendimiento.

Las capas de caching

Las aplicaciones web modernas pueden cachear en cuatro niveles. Cada uno tiene características de latencia y complejidad de invalidación distintas.

CapaLatenciaInvalidaciónIdeal para
Caché del navegador (headers HTTP)0msLa controlan los headersAssets estáticos, respuestas de API
Caché de CDN/Edge1-50msAPI de purge o TTLContenido público, con mucha lectura
Caché de aplicación (Redis/memoria)1-5msLógica de la aplicaciónDatos de sesión, resultados calculados
Caché de consultas de base de datos5-20msAutomática (según la query)Consultas idénticas repetidas

Empieza desde arriba — el caching del navegador es gratis y elimina por completo las solicitudes de red. Agrega capas inferiores solo cuando las superiores no sean suficientes.

Headers de caching HTTP

La caché incorporada del navegador es la opción más rápida y barata. Dos headers controlan la mayor parte.

tstypescript
// ❌ No cache headers — browser re-fetches every time
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  res.json(products);
});
 
// ✅ Cache headers — browser reuses response for 60 seconds
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  res.set("Cache-Control", "public, max-age=60, s-maxage=300");
  res.set("ETag", computeETag(products));
  res.json(products);
});

max-age le indica al navegador cuánto tiempo usar la respuesta cacheada sin revalidar. s-maxage le indica a las CDNs y proxies un TTL separado. ETag habilita solicitudes condicionales — el navegador envía If-None-Match y el servidor puede devolver 304 Not Modified sin enviar el cuerpo.

tstypescript
// Conditional request handling
app.get("/api/products", async (req, res) => {
  const products = await db.products.findMany();
  const etag = computeETag(products);
 
  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end();
  }
 
  res.set("Cache-Control", "public, max-age=60");
  res.set("ETag", etag);
  res.json(products);
});

Caching a nivel de aplicación con Redis

Para datos calculados o agregados que son costosos de regenerar, Redis es la opción estándar. El patrón es cache-aside (también llamado lazy loading).

tstypescript
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
async function getProductCatalog(categoryId: string) {
  const cacheKey = `catalog:${categoryId}`;
 
  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  // 2. Cache miss — fetch from database
  const products = await db.products.findMany({
    where: { categoryId, status: "active" },
    include: { pricing: true, inventory: true },
  });
 
  // 3. Store in cache with TTL
  await redis.set(cacheKey, JSON.stringify(products), "EX", 300);
 
  return products;
}

El patrón cache-aside es simple pero tiene un problema de thundering herd: cuando la caché expira, todas las solicitudes concurrentes golpean la base de datos simultáneamente.

Prevenir thundering herds

Cuando una cache key popular expira, cientos de solicitudes pueden intentar reconstruirla simultáneamente. Un mutex (lock) asegura que solo una haga el trabajo mientras las demás esperan.

tstypescript
async function getWithLock<T>(
  key: string,
  ttl: number,
  fetchFn: () => Promise<T>,
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      const data = await fetchFn();
      await redis.set(key, JSON.stringify(data), "EX", ttl);
      return data;
    } finally {
      await redis.del(lockKey);
    }
  }
 
  // Another process holds the lock — wait and retry
  await new Promise((resolve) => setTimeout(resolve, 100));
  return getWithLock(key, ttl, fetchFn);
}

Una alternativa es stale-while-revalidate: servir el valor cacheado expirado de inmediato mientras se refresca en segundo plano.

Estrategias de invalidación de caché

La invalidación de caché es genuinamente difícil. Estos son los enfoques prácticos:

tstypescript
// Strategy 1: TTL-based — simplest, accept brief staleness
await redis.set("catalog:electronics", data, "EX", 300); // 5 min
 
// Strategy 2: Event-driven — invalidate on write
async function updateProduct(id: string, updates: ProductUpdate) {
  const product = await db.products.update({ where: { id }, data: updates });
 
  // Invalidate related cache keys
  await redis.del(`product:${id}`);
  await redis.del(`catalog:${product.categoryId}`);
 
  return product;
}
 
// Strategy 3: Versioned keys — never invalidate, always fresh
async function getProductV2(id: string) {
  const version = await redis.get(`product-version:${id}`);
  const cacheKey = `product:${id}:v${version}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const product = await db.products.findUnique({ where: { id } });
  await redis.set(cacheKey, JSON.stringify(product), "EX", 3600);
  return product;
}
EstrategiaConsistenciaComplejidadIdeal para
Solo TTLEventual (dentro del TTL)BajaCon mucha lectura, obsolescencia aceptable
Basada en eventosFuerteMediaPatrones de escritura después de lectura
Claves versionadasFuerteAltaAlto tráfico, debe estar siempre fresco

Qué no cachear

No todo se beneficia del caching. Evita cachear:

  • Datos específicos de usuario con sesiones cortas — la tasa de aciertos de caché será casi cero
  • Datos que cambian rápidamente — el costo de invalidar la caché supera el beneficio
  • Datos con requisitos estrictos de consistencia — transacciones financieras, conteos de inventario durante el checkout
  • Payloads grandes de acceso poco frecuente — desperdician memoria para una tasa de aciertos mínima

La primera pregunta antes de agregar una caché siempre debería ser: ¿cuál va a ser la tasa de aciertos? Si está por debajo del 50%, la caché probablemente esté agregando complejidad sin suficiente beneficio.

Puntos clave

  1. Empieza con los headers de caching HTTP — son gratis y eliminan por completo las solicitudes de red
  2. Cache-aside con Redis es el patrón estándar para el caching a nivel de aplicación
  3. Previene los thundering herds con locks de mutex o stale-while-revalidate
  4. Elige una estrategia de invalidación según tus requisitos de consistencia
  5. Mide las tasas de aciertos de caché — tasas bajas significan que estás agregando complejidad sin beneficio
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX