1. Introducción contextual
En la última década la velocidad de respuesta ha dejado de ser un lujo y se ha convertido en un requisito de negocio. Según el Google PageSpeed Insights 2024, una mejora de 0,1 s en el tiempo de carga puede incrementar la conversión en un 8 %. En entornos SaaS y e‑commerce, la latencia también impacta en la retención de usuarios y en el coste de infraestructura: cada milisegundo extra genera un gasto medio de $0.02 USD en servidores cloud por cada 10 000 peticiones.
El caching es la herramienta que permite romper la relación directa entre cada petición del cliente y la ejecución completa del stack de backend. Sin embargo, no todo puede o debe cachearse. Un caché mal dimensionado o con TTL inadecuados introduce inconsistencias, aumenta la complejidad operativa y, en el peor de los casos, degrada la experiencia del usuario.
En este artículo abordaremos el problema de forma holística:
- Qué datos son candidatos a caché (estáticos, semi‑estáticos, dinámicos).
- Dónde colocar la capa de caché (cliente, edge, servidor de aplicación, base de datos).
- Cuánto tiempo mantenerlos (TTL, invalidación proactiva, versiones).
Todo ello bajo una perspectiva práctica, con código listo para producción en Next.js/React, Node.js, Python, PostgreSQL y scripts de bash. Al final tendrás una hoja de ruta que podrás aplicar a cualquier proyecto web de alto tráfico.
2. Fundamentos y Arquitectura
2.1 Conceptos clave
| Concepto | Definición | Impacto en la arquitectura |
|---|---|---|
| Cache Hit | La petición se satisface leyendo del caché. | Reduce latencia y carga del origen. |
| Cache Miss | No hay entrada válida; se consulta el origen. | Incrementa tiempo de respuesta y consumo de recursos. |
| TTL (Time‑to‑Live) | Tiempo máximo que una entrada permanece válida. | Balancea frescura vs. ahorro de recursos. |
| Stale‑while‑revalidate | Sirve datos “stale” mientras se actualiza en background. | Mejora la disponibilidad sin bloquear al cliente. |
| Cache Invalidation | Proceso de eliminar o actualizar entradas obsoletas. | Crucial para mantener consistencia. |
| Cache Penetration | Peticiones repetidas a claves inexistentes que siempre generan miss. | Se mitiga con negative caching o bloom filters. |
2.2 Decisiones de diseño y trade‑offs
| Decisión | Ventaja | Desventaja |
|---|---|---|
| Cache en memoria (process‑local) | Latencia mínima, sin red de red. | No compartido entre instancias, pérdida al reinicio. |
| Cache distribuido (Redis, Memcached) | Escala horizontal, persistencia opcional. | Overhead de red, necesidad de gestión de clúster. |
| Edge CDN (Cloudflare, Fastly) | Proximidad al cliente, offload de tráfico. | TTL limitado a 2 h en algunos proveedores, costo adicional. |
| Cache a nivel de base de datos (materialized view, pg_bouncer) | Reducción de consultas complejas. | Necesita refresco programado o trigger, consumo de espacio. |
| Cache en el navegador (Service Worker, SWR) | Experiencia offline, reducción de round‑trip. | Depende de la capacidad del cliente, vulnerabilidad a datos obsoletos. |
Diagrama conceptual (descrito en texto)
- Cliente → solicita recurso → Service Worker (cache del navegador).
- Si miss, la petición atraviesa CDN Edge → API Gateway (capa de edge).
- Desde el gateway, se consulta Redis (caché distribuido).
- Si Redis miss, se dirige a Node/Next.js que a su vez llama a PostgreSQL o a micro‑servicios.
- Cada capa devuelve la respuesta y la almacena según la política de TTL correspondiente.
3. Implementación práctica
A continuación se muestra la evolución de una estrategia de caché en un proyecto Next.js que expone una API de productos. Cada ejemplo incluye imports, tipos, manejo de errores, validaciones y está listo para copiarse a producción.
3.1 Versión 1 – Caché naïve en memoria (process‑local)
// pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { Product } from '../../types';
import { fetchProductsFromDB } from '../../services/db';
// Cache simple en memoria (no compartido entre instancias)
let productsCache: Product[] | null = null;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Product[] | { error: string }>
) {
try {
// Si el caché está poblado, devolverlo directamente
if (productsCache) {
return res.status(200).json(productsCache);
}
// Caso contrario, consultar la base de datos
const products = await fetchProductsFromDB();
// Guardar en caché por la vida del proceso
productsCache = products;
return res.status(200).json(products);
} catch (err) {
console.error('Error fetching products:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
}
Análisis
- Qué cachea: lista completa de productos.
- Dónde: memoria del proceso Node.
- TTL: indefinido (hasta reinicio).
- Problemas: no funciona en entornos con auto‑scaling (cada instancia tiene su propio caché) y se pierde al cold start.
3.2 Versión 2 – LRU Cache con TTL en memoria (singleton)
// lib/lruCache.ts
import LRU from 'lru-cache';
export interface CacheEntry<T> {
value: T;
expiresAt: number; // timestamp en ms
}
// Configuración: 500 MB, TTL 5 min
const cache = new LRU<string, CacheEntry<any>>({
max: 500 * 1024 * 1024,
ttl: 5 * 60 * 1000, // 5 minutos
allowStale: true, // sirve datos stale mientras se refresca
});
/**
* Obtiene un valor del caché o ejecuta `loader` para poblarlo.
*/
export async function getOrSet<T>(
key: string,
loader: () => Promise<T>
): Promise<T> {
const entry = cache.get(key);
const now = Date.now();
if (entry && entry.expiresAt > now) {
return entry.value;
}
// Cargar datos frescos
const fresh = await loader();
cache.set(key, {
value: fresh,
expiresAt: now + (cache.options.ttl ?? 0),
});
return fresh;
}
// pages/api/products.ts (versión 2)
import type { NextApiRequest, NextApiResponse } from 'next';
import { Product } from '../../types';
import { fetchProductsFromDB } from '../../services/db';
import { getOrSet } from '../../lib/lruCache';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Product[] | { error: string }>
) {
try {
const products = await getOrSet<Product[]>('products:list', () =>
fetchProductsFromDB()
);
return res.status(200).json(products);
} catch (err) {
console.error('Error fetching products:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
}
Análisis
- Ventajas: TTL configurable, evita cache stampede con
allowStale. - Desventajas: sigue siendo local a la instancia; en un clúster cada nodo mantiene su propia LRU, lo que duplica memoria.
3.3 Versión 3 – Caché distribuido con Redis y política stale‑while‑revalidate
# docker-compose.yml (añadir servicio redis)
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
- REDIS_URL=redis://redis:6379
redis:
image: redis:7-alpine
ports:
- "6379:6379"
// lib/redisClient.ts
import { createClient, RedisClientType } from 'redis';
const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379';
export const redis: RedisClientType = createClient({ url: redisUrl });
redis.on('error', (err) => console.error('Redis error', err));
(async () => {
await redis.connect();
console.log('✅ Redis conectado');
})();
// lib/redisCache.ts
import { redis } from './redisClient';
import type { CacheEntry } from './lruCache';
const DEFAULT_TTL = 60; // segundos
/**
* Obtiene o genera la entrada en Redis usando SWR.
*/
export async function getOrSetRedis<T>(
key: string,
loader: () => Promise<T>,
ttl = DEFAULT_TTL
): Promise<T> {
// Intentar obtener del caché
const raw = await redis.get(key);
if (raw) {
const entry: CacheEntry<T> = JSON.parse(raw);
const now = Date.now();
// Si está fresh, devolver
if (entry.expiresAt > now) return entry.value;
// Si está stale, lanzar refresco en background y devolver stale
refreshInBackground(key, loader, ttl).catch((e) =>
console.error('Background refresh failed', e)
);
return entry.value;
}
// Cache miss total
const fresh = await loader();
await setRedis(key, fresh, ttl);
return fresh;
}
/** Guarda en Redis con expiración y marca de stale */
async function setRedis<T>(key: string, value: T, ttl: number): Promise<void> {
const entry: CacheEntry<T> = {
value,
expiresAt: Date.now() + ttl * 1000,
};
await redis.set(key, JSON.stringify(entry), {
EX: ttl * 2, // doble tiempo para permitir stale
});
}
/** Refresca la clave sin bloquear la petición original */
async function refreshInBackground<T>(
key: string,
loader: () => Promise<T>,
ttl: number
) {
const fresh = await loader();
await setRedis(key, fresh, ttl);
}
// pages/api/products.ts (versión 3)
import type { NextApiRequest, NextApiResponse } from 'next';
import { Product } from '../../types';
import { fetchProductsFromDB } from '../../services/db';
import { getOrSetRedis } from '../../lib/redisCache';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Product[] | { error: string }>
) {
try {
const products = await getOrSetRedis<Product[]>(
'products:list',
() => fetchProductsFromDB(),
120 // TTL 2 min
);
// Añadir cabecera para indicar que la respuesta puede estar stale
res.setHeader('Cache-Control', 'public, max-age=0, stale-while-revalidate=30');
return res.status(200).json(products);
} catch (err) {
console.error('Error fetching products:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
}
Análisis
- Qué cachea: la lista de productos completa.
- Dónde: Redis (capa distribuida).
- TTL: 2 min, con stale‑while‑revalidate de 30 s.
- Beneficios: coherencia entre instancias, mitigación de thundering herd y cache stampede.
- Coste: latencia de red (~1 ms en LAN) y gestión de clúster Redis.
3.4 Versión 4 – Caché en el cliente con SWR y Service Worker
// components/ProductList.tsx
import useSWR from 'swr';
import type { Product } from '../types';
import axios from 'axios';
// Función fetcher reutilizable
const fetcher = (url: string) =>
axios.get<Product[]>(url).then((res) => res.data);
export default function ProductList() {
const { data, error, isValidating } = useSWR<Product[]>(
'/api/products',
fetcher,
{
// 30 s de revalidación en background
revalidateOnFocus: false,
dedupingInterval: 15000,
refreshInterval: 30000,
}
);
if (error) return <p>❌ Error al cargar productos.</p>;
if (!data) return <p>⏳ Cargando...</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>
<strong>{p.name}</strong> – ${p.price.toFixed(2)}
</li>
))}
</ul>
);
}
// public/sw.js (Service Worker para cachear API)
self.addEventListener('install', (event) => {
// Precarga de la API de productos
event.waitUntil(
caches.open('api-cache-v1').then((cache) => {
return cache.add('/api/products');
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// Estrategia Cache First con fallback a red
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request)
.then((networkRes) => {
// Actualizar caché en background
caches.open('api-cache-v1').then((cache) => cache.put(event.request, networkRes.clone()));
return networkRes;
})
.catch(() => cached); // Si falla la red, servir stale
// Si hay caché, servirla inmediatamente
return cached || fetchPromise;
})
);
}
});
// pages/_app.tsx (registro del Service Worker)
import { useEffect } from 'react';
import type { AppProps } from 'next/app';
function MyApp({ Component, pageProps }: AppProps) {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then(() => console.log('✅ Service Worker registrado'))
.catch((e) => console.error('❌ SW registration failed', e));
}
}, []);
return <Component {...pageProps} />;
}
export default MyApp;
Análisis
- Qué cachea: respuesta de
/api/productsen el navegador. - Dónde: Cache API del Service Worker + SWR en memoria del cliente.
- TTL: controlado por
revalidateOnFocusyrefreshInterval(30 s). - Ventajas: tiempo de respuesta prácticamente instantáneo después del primer fetch, soporte offline.
- Desventajas: datos pueden estar ligeramente desactualizados; necesita estrategia de invalidación (p.ej., enviar
ETagyIf-None-Matchdesde el servidor).
3.5 Versión 5 – Caché a nivel de base de datos (Materialized View)
-- 1️⃣ Crear materialized view para productos con precios calculados
CREATE MATERIALIZED VIEW mv_product_summary AS
SELECT
p.id,
p.name,
p.category_id,
p.base_price,
COALESCE(d.discount, 0) AS discount,
(p.base_price * (1 - COALESCE(d.discount, 0))) AS final
