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.

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.
| Capa | Latencia | Invalidación | Ideal para |
|---|---|---|---|
| Caché del navegador (headers HTTP) | 0ms | La controlan los headers | Assets estáticos, respuestas de API |
| Caché de CDN/Edge | 1-50ms | API de purge o TTL | Contenido público, con mucha lectura |
| Caché de aplicación (Redis/memoria) | 1-5ms | Lógica de la aplicación | Datos de sesión, resultados calculados |
| Caché de consultas de base de datos | 5-20ms | Automá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.
// ❌ 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.
// 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).
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.
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:
// 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;
}| Estrategia | Consistencia | Complejidad | Ideal para |
|---|---|---|---|
| Solo TTL | Eventual (dentro del TTL) | Baja | Con mucha lectura, obsolescencia aceptable |
| Basada en eventos | Fuerte | Media | Patrones de escritura después de lectura |
| Claves versionadas | Fuerte | Alta | Alto 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
- Empieza con los headers de caching HTTP — son gratis y eliminan por completo las solicitudes de red
- Cache-aside con Redis es el patrón estándar para el caching a nivel de aplicación
- Previene los thundering herds con locks de mutex o stale-while-revalidate
- Elige una estrategia de invalidación según tus requisitos de consistencia
- Mide las tasas de aciertos de caché — tasas bajas significan que estás agregando complejidad sin beneficio


