En 01-04, al enumerar las seis restricciones de REST, dijimos que cacheable era una de ellas y que volveríamos. Ha llegado el momento. La API de Tienda Aroma responde GET /v1/cafes en 40 milisegundos, lo cual está muy bien, salvo por un detalle: la SPA pide ese catálogo cada vez que un usuario abre la página de inicio, y el catálogo cambia una vez al día. Millones de peticiones idénticas, con la misma respuesta, consumiendo base de datos, CPU y ancho de banda para no aportar absolutamente nada nuevo.

La petición más rápida es la que no se hace. La segunda más rápida es la que se responde con un 304 Not Modified de 150 bytes. Esta lección convierte la restricción teórica en cabeceras concretas y en código dentro del proyecto: src/middleware/cache.js, Cache-Control calibrado recurso a recurso, ETag con peticiones condicionales, y el reencuentro que llevamos anunciando desde 03-05, cuando If-Match y el 412 cierren el círculo de la concurrencia optimista. Después iremos más allá de la caché —compresión, N+1, respuestas parciales, trabajo asíncrono— y terminaremos con lo que debería ir primero: medir antes de optimizar.

Contenido

  1. Los niveles de caché
  2. Cache-Control a fondo
  3. La política de caché de Tienda Aroma
  4. Validación condicional: ETag y Last-Modified
  5. El flujo completo del 304
  6. If-Match y el 412: se cierra el círculo con la concurrencia optimista
  7. Implementación: src/middleware/cache.js
  8. Vary, negociación de contenido y CORS
  9. Invalidación: el problema difícil
  10. Caché de servidor con Redis y cache-aside
  11. Stampede y el bloqueo
  12. Compresión
  13. Conexiones, HTTP/2 y latencia de red
  14. Base de datos: índices, N+1 y consultas lentas
  15. Respuestas parciales y paginación como medida de rendimiento
  16. Trabajo asíncrono con 202
  17. Medir antes de optimizar

  1. Los niveles de caché

Entre el usuario y los datos hay varias oportunidades de no hacer trabajo. Cada nivel que acierta ahorra todo lo que hay a su derecha.

graph LR
  U[Usuario] --> N[Cache del navegador<br/>privada]
  N -->|fallo| C[CDN / proxy<br/>compartida]
  C -->|fallo| A[API Express]
  A --> R[Cache de aplicacion<br/>Redis]
  R -->|fallo| D[Base de datos]
  D --> P[Cache de paginas<br/>del motor SQL]
Nivel Quién lo controla Alcance Ahorra
Navegador Cache-Control que emitimos Un usuario Todo: ni siquiera hay petición
CDN / proxy Cache-Control, s-maxage Todos los usuarios Red y servidor
Aplicación (Redis) Nuestro código Todas las instancias Base de datos
Base de datos El motor Disco

Dos conceptos que hay que distinguir bien porque de ellos depende una decisión de seguridad:

  • Caché privada: la del navegador. Guarda respuestas de un usuario.
  • Caché compartida: CDN, proxy corporativo. Guarda respuestas que sirve a muchos usuarios.

De ahí sale la regla más importante de toda la lección: cualquier respuesta que dependa de quién pregunta debe llevar Cache-Control: private como mínimo. Si GET /v1/pedidos de Marta acaba en una CDN sin esa directiva, la siguiente persona que pida /v1/pedidos podría recibir los pedidos de Marta. Es una fuga de datos causada por una cabecera ausente, y ha ocurrido en producción en empresas grandes más de una vez.

  1. Cache-Control a fondo

Es la cabecera que gobierna todo. Sus directivas, agrupadas por lo que hacen:

Quién puede guardar

Directiva Efecto
public Cualquier caché, incluidas las compartidas
private Solo la caché privada del navegador
no-store Nadie guarda nada, en ningún sitio

Cuánto tiempo

Directiva Efecto
max-age=N Válido N segundos para cualquier caché
s-maxage=N Igual, pero solo para cachés compartidas; tiene prioridad sobre max-age

Cómo se revalida

Directiva Efecto
no-cache Puede guardarse, pero hay que revalidar antes de cada uso
must-revalidate Cuando caduque, prohibido servirlo caducado
immutable No revalides nunca mientras esté fresco
stale-while-revalidate=N Sirve lo caducado hasta N s mientras refresca por detrás
stale-if-error=N Si el origen falla, sirve lo caducado hasta N s

no-cache no significa «no cachear». Es el error de lectura más repetido de HTTP. no-cache significa «guárdalo, pero pregúntame antes de usarlo». La que impide guardar es no-store. La diferencia importa mucho: con no-cache una respuesta que no ha cambiado se resuelve con un 304 de 150 bytes; con no-store se transfiere entera cada vez.

stale-while-revalidate es la directiva más infravalorada. Con max-age=60, stale-while-revalidate=300, durante los primeros 60 segundos se sirve de caché sin más; entre el segundo 60 y el 360, se sirve la copia caducada de inmediato y se refresca en segundo plano. El usuario nunca espera. Para un catálogo que cambia una vez al día, es exactamente el comportamiento deseable.

stale-if-error es resiliencia gratis: si tu API devuelve 503, la CDN sigue sirviendo la última copia buena en lugar de propagar el error. Combinada con lo que vimos en 04-04, convierte una caída parcial en una degradación invisible.

Combinación Significado práctico Ejemplo
public, max-age=300 Cualquiera lo guarda 5 minutos Catálogo público
public, max-age=60, s-maxage=600 Navegador 1 min, CDN 10 min Catálogo con CDN
private, max-age=0, must-revalidate Solo el navegador, y revalidando siempre Datos del usuario
no-store Nunca se guarda Tokens, pagos
public, max-age=31536000, immutable Un año sin preguntar Imagen con hash en el nombre
public, max-age=60, stale-while-revalidate=300 Sin esperas al refrescar Catálogo muy solicitado

  1. La política de caché de Tienda Aroma

Recurso Cache-Control Motivo
GET /v1/cafes public, max-age=60, s-maxage=300, stale-while-revalidate=600 Público, cambia poco, muy pedido
GET /v1/cafes/{id} public, max-age=300, stale-while-revalidate=600 Ídem, aún más estable
GET /v1/cafes/{id}/imagen public, max-age=31536000, immutable El nombre incluye un hash del contenido
GET /v1/cafes/{id}/resenas public, max-age=60 Público, cambia con cada reseña
GET /v1/pedidos private, no-cache Depende del usuario; se revalida siempre
GET /v1/pedidos/{id} private, no-cache Ídem
GET /v1/clientes/{id} private, no-cache Datos personales
GET /v1/carritos/{id} no-store Cambia constantemente; sin valor en caché
POST /v1/sesiones no-store Contiene tokens
Cualquier respuesta de error no-store Un 429 cacheado bloquearía al usuario de más
GET /salud no-store Debe reflejar el estado real ahora

Cuatro decisiones que merecen justificación:

no-cache en /v1/pedidos, no no-store. Los pedidos de Marta cambian poco entre visitas. Con no-cache, el navegador guarda la copia y en la siguiente visita manda un If-None-Match; si nada ha cambiado recibe un 304 sin cuerpo. Se ahorra todo el tráfico manteniendo la garantía de frescura, que es lo mejor de los dos mundos. Y private impide que una CDN lo guarde.

no-store en POST /v1/sesiones. La respuesta contiene el token de acceso. Que quede en el disco del navegador o en un proxy es exactamente lo que no queremos.

no-store en los errores. Un 429 cacheado durante cinco minutos convierte un límite de un minuto en cinco. Un 503 cacheado sobrevive a la recuperación del servicio.

Un año e immutable en las imágenes. Solo funciona porque el nombre del fichero contiene un hash del contenido (caf_001-a3f9.webp): si la imagen cambia, cambia la URL, así que la copia antigua nunca es incorrecta. Es el patrón de fingerprinting, y es la única forma segura de usar caducidades tan largas.

Implementado como middleware parametrizable:

// src/middleware/cache.js  (fichero NUEVO — primera parte)

/**
 * Fija Cache-Control en la respuesta. Se compone en la cadena de cada ruta,
 * porque la política depende del recurso.
 *
 * @param {object} opciones
 * @param {boolean} opciones.publica    ¿Puede guardarla una caché compartida?
 * @param {number}  opciones.maxEdad    Segundos de frescura para el navegador.
 * @param {number}  [opciones.maxEdadCompartida]  Segundos para la CDN (s-maxage).
 * @param {number}  [opciones.revalidarEnSegundoPlano]  stale-while-revalidate.
 * @param {boolean} [opciones.sinAlmacenar]  no-store: ni se guarda.
 */
export function cacheDe(opciones = {}) {
  const {
    publica = false,
    maxEdad = 0,
    maxEdadCompartida,
    revalidarEnSegundoPlano,
    sinAlmacenar = false,
  } = opciones;

  const directivas = [];
  if (sinAlmacenar) {
    directivas.push('no-store');
  } else {
    directivas.push(publica ? 'public' : 'private');
    // max-age=0 se expresa como no-cache: "guárdalo, pero revalida siempre".
    if (maxEdad === 0) directivas.push('no-cache');
    else directivas.push(`max-age=${maxEdad}`);
    if (maxEdadCompartida !== undefined) directivas.push(`s-maxage=${maxEdadCompartida}`);
    if (revalidarEnSegundoPlano) {
      directivas.push(`stale-while-revalidate=${revalidarEnSegundoPlano}`);
    }
  }

  const valor = directivas.join(', ');
  return (req, res, next) => {
    res.set('Cache-Control', valor);
    next();
  };
}

/** Atajos con las políticas ya decididas, para no repetir números por las rutas. */
export const cachePublicaCatalogo = cacheDe({
  publica: true, maxEdad: 60, maxEdadCompartida: 300, revalidarEnSegundoPlano: 600,
});
export const cachePrivadaRevalidada = cacheDe({ publica: false, maxEdad: 0 });
export const sinCache = cacheDe({ sinAlmacenar: true });
// src/rutas/cafes.js  (MODIFICADO)
router.get('/', cachePublicaCatalogo, validar(esquemaListarCafes, 'query'),
  asincrono(controladores.cafes.listar));

// src/rutas/pedidos.js  (MODIFICADO)
router.get('/', autenticar, cachePrivadaRevalidada, validar(esquemaListarPedidos, 'query'),
  asincrono(controladores.pedidos.listar));

// src/rutas/sesiones.js  (MODIFICADO)
router.post('/', limiteLogin, sinCache, validar(esquemaLogin, 'body'),
  asincrono(controladores.sesiones.crear));

  1. Validación condicional: ETag y Last-Modified

max-age responde a «¿puedo usar mi copia sin preguntar?». Cuando caduca, la pregunta pasa a ser «¿ha cambiado?», y ahí entran los validadores.

ETag

Un identificador opaco de la versión de un recurso.

Tipo Sintaxis Significa
Fuerte ETag: "a3f9c2e1" Byte a byte idéntico
Débil ETag: W/"a3f9c2e1" Semánticamente equivalente

Un ETag débil sirve para el 304 pero no para If-Match en escrituras ni para peticiones por rangos, precisamente porque no garantiza igualdad exacta. Como Tienda Aroma va a usar If-Match para la concurrencia optimista, necesitamos ETags fuertes.

Dos formas de generarlo:

Método Cómo Ventaja Inconveniente
Hash del cuerpo SHA-1/SHA-256 del JSON serializado Universal, no requiere modelo Hay que generar la respuesta entera
Campo version "v7" a partir de la columna que ya existe Barato: se sabe antes de serializar Solo para recursos con versión

Tienda Aroma usa las dos: el campo version de 03-05 para recursos individuales que lo tienen, y el hash para colecciones y para todo lo demás.

// src/middleware/cache.js  (segunda parte)
import crypto from 'node:crypto';

/** ETag fuerte a partir del cuerpo serializado. */
export function etagDeCuerpo(cuerpo) {
  const texto = typeof cuerpo === 'string' ? cuerpo : JSON.stringify(cuerpo);
  const hash = crypto.createHash('sha256').update(texto).digest('base64url').slice(0, 27);
  return `"${hash}"`;                        // comillas OBLIGATORIAS en la sintaxis
}

/** ETag a partir del campo version que ya llevan cafés y pedidos (03-05). */
export function etagDeVersion(recurso) {
  return `"v${recurso.version}"`;
}

Las comillas no son decorativas: forman parte de la sintaxis de la cabecera. Un ETag: a3f9 sin comillas es inválido y muchos intermediarios lo ignoran, con lo que la caché deja de funcionar sin dar ningún error.

Last-Modified

Una fecha HTTP:

Last-Modified: Sun, 02 Aug 2026 09:14:22 GMT

Es más débil que el ETag por dos razones: tiene resolución de un segundo —dos cambios en el mismo segundo son indistinguibles— y muchos recursos no tienen una fecha de modificación fiable.

ETag Last-Modified
Precisión Total 1 segundo
Cabecera de petición If-None-Match If-Modified-Since
Coste de cálculo Hash o versión Leer una fecha
Sirve para escrituras condicionales (If-Match) Poco fiable
Prioridad si están los dos Gana ETag

Tienda Aroma emite ambos cuando dispone de fechaActualizacion, pero el ETag es el mecanismo principal.

  1. El flujo completo del 304

Primera petición. El cliente no tiene nada:

GET /v1/cafes/caf_001 HTTP/1.1
Host: api.tiendaaroma.example
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Last-Modified: Sun, 02 Aug 2026 09:14:22 GMT
Vary: Accept, Accept-Language, Origin
Content-Length: 284

{"id":"caf_001","nombre":"Etiopía Yirgacheffe","origen":"Etiopía","tueste":"claro",
 "precioEuros":14.50,"stock":120,"version":7,"_links":{"self":{"href":"/v1/cafes/caf_001"}}}

Dentro de los 300 segundos: el navegador sirve su copia sin preguntar. Cero peticiones, cero latencia.

Pasados los 300 segundos: la copia está caducada, pero el navegador tiene el validador y pregunta:

GET /v1/cafes/caf_001 HTTP/1.1
Host: api.tiendaaroma.example
If-None-Match: "v7"
If-Modified-Since: Sun, 02 Aug 2026 09:14:22 GMT

Si nada ha cambiado:

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Vary: Accept, Accept-Language, Origin

Sin cuerpo. 284 bytes se convierten en unos 150 de cabeceras, y el navegador renueva la frescura de su copia otros 300 segundos.

Si el café cambió (version pasó a 8):

HTTP/1.1 200 OK
ETag: "v8"
Content-Type: application/json

{"id":"caf_001", ..., "precioEuros":15.20, "version":8, ...}
sequenceDiagram
  participant N as Navegador
  participant API as API

  N->>API: GET /v1/cafes/caf_001
  API->>N: 200 + ETag "v7" + max-age=300
  Note over N: Dentro de los 300 s: sirve de cache, sin peticion
  N->>API: GET con If-None-Match "v7" (ya caducado)
  API->>API: Compara "v7" con el ETag actual
  API->>N: 304 Not Modified, sin cuerpo
  Note over N: Frescura renovada otros 300 s
  N->>API: GET con If-None-Match "v7" (tras un cambio)
  API->>N: 200 + ETag "v8" + cuerpo nuevo

Tres detalles del 304 que se equivocan a menudo:

  • No lleva cuerpo. Enviar uno es un error de protocolo.
  • Sí lleva las cabeceras de caché (Cache-Control, ETag, Vary): son las que renuevan la validez de la copia guardada.
  • If-None-Match admite varios ETags separados por comas, y el comodín * que significa «si existe cualquier representación».

  1. If-Match y el 412: se cierra el círculo con la concurrencia optimista

Aquí está el reencuentro que anunciamos en 03-05. If-None-Match sirve para leer; If-Match sirve para escribir de forma segura.

El problema: la actualización perdida

sequenceDiagram
  participant E1 as Empleado 1
  participant API as API
  participant E2 as Empleado 2

  E1->>API: GET /v1/cafes/caf_001 → precio 14,50, version 7
  E2->>API: GET /v1/cafes/caf_001 → precio 14,50, version 7
  E1->>API: PUT precio 15,20
  API->>E1: 200, version 8
  E2->>API: PUT precio 13,90 (con datos de la version 7)
  API->>E2: 200, version 9
  Note over API: El cambio del empleado 1 ha desaparecido sin aviso

Nadie ha visto un error. El precio 15,20 nunca existió.

La solución con If-Match

PUT /v1/cafes/caf_001 HTTP/1.1
Host: api.tiendaaroma.example
Authorization: Bearer <token de empleado>
Content-Type: application/json
If-Match: "v7"

{"nombre":"Etiopía Yirgacheffe","origen":"Etiopía","tueste":"claro",
 "precioEuros":13.90,"stock":120,"notasCata":"Jazmín, bergamota"}

Si la versión actual ya no es la 7:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "v8"
Cache-Control: no-store

{
  "error": {
    "codigo": "conflicto_version",
    "mensaje": "El recurso ha cambiado desde que lo obtuviste. Vuelve a leerlo y reintenta.",
    "detalles": []
  }
}

El ETag: "v8" en la respuesta de error es un detalle de diseño valioso: le dice al cliente cuál es la versión actual, así que puede releer, mostrar el conflicto al usuario y reintentar sin una petición extra.

Por qué la cabecera es mejor que el campo version del cuerpo

En 03-05 implementamos la concurrencia optimista con version dentro del JSON. Funciona, pero If-Match es mejor por cinco razones:

version en el cuerpo If-Match en la cabecera
Estándar Convenio propio HTTP (RFC 9110): lo entiende cualquier cliente
Separación Mezcla metadato con datos El metadato viaja como metadato
Funciona con DELETE No: DELETE no tiene cuerpo
Los intermediarios lo entienden No Sí: proxies y gateways
Código de estado Un 409 ad hoc 412, que significa exactamente eso
Reutiliza el ETag de lectura No Sí: el mismo valor que ya recibió

La última fila es la clave conceptual: el cliente ya recibió el ETag al leer el recurso. No necesita entender qué es version, ni extraerlo del cuerpo: devuelve la etiqueta opaca que le dieron. Eso es exactamente el diseño de HTTP.

Y hay una decisión de contrato importante: exigir If-Match o no.

Política Comportamiento sin If-Match Cuándo
Opcional La escritura procede (último gana) Recursos poco disputados
Obligatoria 428 Precondition Required Recursos críticos: precio, stock

Tienda Aroma la exige en PUT /v1/cafes/{id} y en PATCH /v1/pedidos/{id}, porque un precio o un stock mal pisados tienen consecuencias comerciales reales. Eso obliga a añadir un código nuevo al catálogo: precondicion_requerida (428). Lo anotamos explícitamente, como exige la regla del proyecto —el catálogo solo crece—, y hay que documentarlo en openapi.yaml.

Situación Código Estado del catálogo
Falta If-Match donde es obligatoria 428 precondicion_requerida — NUEVO
If-Match no coincide 412 conflicto_version — ya existía
If-None-Match coincide en un GET 304 Sin cuerpo, sin error
If-None-Match: * en un POST que crearía un duplicado 412 conflicto
// src/middleware/cache.js  (tercera parte)
import { errores } from '../errores/error-api.js';

/**
 * Exige y comprueba If-Match en las escrituras.
 * Se compone en la ruta ANTES del controlador; el servicio recibe la versión
 * esperada ya extraída y no tiene que saber nada de HTTP.
 */
export function exigirIfMatch(req, res, next) {
  const ifMatch = req.get('If-Match');

  if (!ifMatch) {
    return next(
      errores.precondicionRequerida(
        'precondicion_requerida',
        'Esta operación exige la cabecera If-Match con el ETag obtenido al leer el recurso.'
      )
    );
  }

  if (ifMatch.trim() === '*') {
    req.versionEsperada = null;      // '*' = "existe cualquier versión": vale cualquiera
    return next();
  }

  // Se admite una lista: If-Match: "v7", "v8"
  const versiones = ifMatch
    .split(',')
    .map((e) => e.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
    .map((e) => (e.startsWith('v') ? Number(e.slice(1)) : Number.NaN))
    .filter((n) => Number.isInteger(n));

  if (versiones.length === 0) {
    return next(errores.datosInvalidos('datos_invalidos', 'La cabecera If-Match no es válida.'));
  }

  req.versionesAceptadas = versiones;
  return next();
}
// src/errores/error-api.js  (MODIFICADO)
export const errores = {
  // ... resto
  precondicionRequerida: (codigo, mensaje) => new ErrorApi(428, codigo, mensaje),
  precondicionFallida: (codigo, mensaje) => new ErrorApi(412, codigo, mensaje),
};
// src/servicios/cafes.js  (MODIFICADO — fragmento)
export async function actualizarCafe(id, datos, versionesAceptadas) {
  const actual = await repositorios.cafes.porId(id);
  if (!actual) throw errores.noEncontrado('cafe_no_encontrado', `No existe el café ${id}.`);

  // null = If-Match: * → basta con que exista.
  if (versionesAceptadas && !versionesAceptadas.includes(actual.version)) {
    throw errores.precondicionFallida(
      'conflicto_version',
      'El recurso ha cambiado desde que lo obtuviste. Vuelve a leerlo y reintenta.'
    );
  }

  // La escritura sigue siendo condicional en SQL (03-05): entre la comprobación
  // anterior y este UPDATE puede colarse otra transacción. El WHERE version = ?
  // es lo que hace la operación atómica de verdad.
  const filas = repositorios.cafes.actualizarSiVersion(id, datos, actual.version);
  if (filas === 0) {
    throw errores.precondicionFallida('conflicto_version', 'Conflicto de versión al escribir.');
  }

  return repositorios.cafes.porId(id);
}

El comentario del medio es el punto fino: comprobar la versión en el servicio no basta; entre la lectura y la escritura hay una ventana. La garantía real la da el WHERE version = ? del UPDATE, que es atómico. If-Match aporta la semántica HTTP correcta y el error temprano; la corrección la sigue dando la base de datos.

// src/rutas/cafes.js  (MODIFICADO)
router.put(
  '/:id',
  autenticar,
  exigirRol('empleado', 'administrador'),
  exigirIfMatch,                                   // ← 428 si falta
  validar(esquemaActualizarCafe, 'body'),
  asincrono(controladores.cafes.reemplazar)
);

Y Accept-Patch, que ya emitíamos desde 02-05, acompaña a esto: dice al cliente qué formato de parche acepta el recurso, igual que ETag le dice qué versión tiene.

  1. Implementación: src/middleware/cache.js

Falta la pieza que responde el 304 automáticamente. La estrategia: envolver res.json para calcular el ETag justo antes de enviar y comparar con If-None-Match.

// src/middleware/cache.js  (cuarta parte)

/**
 * Calcula el ETag de la respuesta y responde 304 si el cliente ya la tiene.
 *
 * Se registra globalmente antes de las rutas: envuelve res.json para
 * interceptar el cuerpo justo antes de serializarlo.
 */
export function etagCondicional(req, res, next) {
  // Solo tiene sentido en lecturas.
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();

  const jsonOriginal = res.json.bind(res);

  res.json = function (cuerpo) {
    // 1. Nunca se pone ETag a los errores: no representan al recurso.
    if (res.statusCode >= 400) return jsonOriginal(cuerpo);

    // 2. Si el controlador ya fijó un ETag (por ejemplo, el de version), se respeta.
    const etag = res.get('ETag') ?? etagDeCuerpo(cuerpo);
    res.set('ETag', etag);

    // 3. Comparación con lo que el cliente dice tener.
    const ifNoneMatch = req.get('If-None-Match');
    if (ifNoneMatch) {
      const coincide =
        ifNoneMatch.trim() === '*' ||
        ifNoneMatch
          .split(',')
          .map((e) => e.trim())
          .some((e) => e === etag || e === `W/${etag}`);   // W/ para ETags débiles

      if (coincide) {
        // 304: sin cuerpo. Se eliminan las cabeceras de entidad que sobran.
        res.removeHeader('Content-Type');
        res.removeHeader('Content-Length');
        return res.status(304).end();
      }
    }

    return jsonOriginal(cuerpo);
  };

  return next();
}
// src/app.js  (extracto tras 04-06)
app.disable('x-powered-by');                                 // 1
app.set('trust proxy', 1);
app.use(asignarTrazaId);                                     // 2
app.use(cabecerasSeguridad);                                 // 3  helmet
app.use(cors(opcionesCors));                                 // 4
// (5) registro estructurado → 04-07
app.use(limiteGlobal);                                       // 6
app.use(compression(opcionesCompresion));                    // 7  ← NUEVO (apartado 12)
app.use(express.json({ limit: '100kb', /* ... */ }));        // 8
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salud', ...);                                      // 9
app.use(etagCondicional);                                    // 10 ← NUEVO
app.use('/v1', rutasV1);                                     // 11
app.use(manejadorNoEncontrado);                              // 12
app.use(manejadorErrores);                                   // 13

Por qué etagCondicional en la posición 10, justo antes de las rutas. Tiene que envolver res.json antes de que ningún controlador lo llame, y a la vez estar después de todo lo que pueda responder por su cuenta (rate limiting, parser), porque a esas respuestas no queremos ponerles ETag. Y por qué compression en la 7, antes: la compresión debe envolver la escritura de la respuesta lo antes posible, y además no debe intentar comprimir un 304 que no tiene cuerpo.

Una nota práctica: Express trae su propio ETag automático (app.set('etag', ...)), pero es débil por defecto, lo que lo inutiliza para If-Match. Nuestro middleware lo sustituye con ETags fuertes y con control explícito sobre cuándo se emiten.

  1. Vary, negociación de contenido y CORS

Vary declara de qué cabeceras de la petición depende la respuesta. Es lo que impide que una caché sirva la respuesta equivocada.

Vary: Accept, Accept-Language, Origin
Cabecera en Vary Por qué Lección
Accept La representación puede diferir según el tipo pedido 02-05
Accept-Language Las notas de cata están traducidas 02-05
Origin La respuesta lleva Access-Control-Allow-Origin reflejado 04-05
Authorization No se pone: se usa private en su lugar Abajo

El caso de Authorization merece explicación porque parece la respuesta obvia y no lo es. Poner Vary: Authorization haría que la caché guardara una entrada por cada token distinto: como los tokens cambian cada 15 minutos, la tasa de acierto sería prácticamente cero y el consumo de memoria de la CDN, enorme. La forma correcta de proteger contenido personalizado es Cache-Control: private, que impide directamente que una caché compartida lo guarde.

Y la advertencia de coste: cada cabecera en Vary multiplica las variantes almacenadas. Vary: Accept, Accept-Language, Origin con 2 tipos, 3 idiomas y 3 orígenes son 18 copias del mismo recurso. Declara solo lo que realmente cambia la respuesta.

  1. Invalidación: el problema difícil

«Solo hay dos cosas difíciles en informática: la invalidación de caché y poner nombres a las cosas.» — Phil Karlton

La dificultad es real: has distribuido copias de tus datos por navegadores y CDN de todo el mundo, y ahora el precio de caf_001 ha cambiado.

Estrategia Cómo Ventaja Inconveniente
TTL corto max-age=60 y esperar Trivial Hasta 60 s de datos obsoletos
Purga explícita Llamar a la API de la CDN al cambiar Inmediata Acoplamiento; la del navegador no se purga
Clave versionada La URL incluye un hash Perfecta, sin purga Solo para recursos con URL controlable
Revalidación no-cache + ETag Siempre fresco Una petición por uso (aunque barata)
Por evento Un webhook dispara la purga Precisa y desacoplada Requiere infraestructura de eventos

La caché del navegador no se puede purgar. Una vez enviado max-age=3600, ese navegador servirá la copia durante una hora y no hay nada que hacer. Es la razón de que los TTL para el navegador sean cortos (60 s) y los de la CDN largos (300 s con s-maxage): la CDN sí se puede purgar.

// src/servicios/cache-invalidacion.js  (fichero NUEVO)
import { redis } from '../config/redis.js';
import { entorno } from '../config/entorno.js';

/**
 * Invalida un recurso en todos los niveles que controlamos.
 * La caché del navegador NO se puede invalidar: por eso su TTL es corto.
 */
export async function invalidarCafe(cafeId) {
  // 1. Caché de aplicación (Redis): borrado directo.
  await redis.del(`aroma:cache:cafe:${cafeId}`);
  // 2. Las listas dependen del elemento: se invalidan por patrón de etiqueta.
  await redis.del('aroma:cache:cafes:lista');

  // 3. CDN: purga por etiqueta (surrogate key), no URL a URL.
  if (entorno.CDN_PURGA_URL) {
    await fetch(entorno.CDN_PURGA_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${entorno.CDN_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ tags: [`cafe-${cafeId}`, 'catalogo'] }),
      signal: AbortSignal.timeout(3000),
    }).catch((e) => {
      // Una purga fallida NO debe romper la escritura. Se registra y el TTL
      // acabará resolviéndolo: degradación aceptable.
      console.error('Fallo al purgar la CDN:', e.message);
    });
  }
}

Las etiquetas de purga (surrogate keys) son el mecanismo que hace esto manejable. Cada respuesta declara a qué grupos pertenece:

Surrogate-Key: cafe-caf_001 catalogo origen-etiopia

Y al cambiar caf_001 se purga por etiqueta, en vez de tener que enumerar las docenas de URLs afectadas (la ficha, la lista, la lista filtrada por origen, la ordenada por precio...). Sin etiquetas, la invalidación de colecciones es prácticamente imposible de hacer bien.

Por qué la invalidación por evento encaja con los webhooks. Tienda Aroma ya emite eventos firmados hacia RápidoEnvíos (pedido.pagado, pedido.enviado). El mismo mecanismo sirve para la caché: cuando el servicio de catálogo cambia un precio, publica cafe.actualizado y quien esté suscrito —la CDN, otra instancia, el panel— purga lo suyo. La ventaja es el desacoplamiento: el servicio que escribe no necesita conocer todas las cachés que existen, solo anunciar que algo cambió.

  1. Caché de servidor con Redis y cache-aside

La caché HTTP evita peticiones. La caché de servidor evita consultas a la base de datos para las peticiones que sí llegan.

Qué merece la pena cachear en Tienda Aroma:

Dato TTL Por qué
Catálogo completo (primera página) 60 s Petición más frecuente con diferencia
Ficha de café por id 300 s Muy repetida, cambia poco
total de una colección 300 s El COUNT(*) es caro y tolera estar algo obsoleto
Puntuación media de un café 600 s Agregación cara, precisión no crítica
Pedidos de un cliente No Personal, cambia, poco repetido
Stock No Debe ser exacto: cachearlo provoca sobreventa

Las dos últimas filas son tan importantes como las primeras: saber qué no cachear evita incidentes. Cachear el stock 60 segundos significa vender café que no existe.

El patrón cache-aside (o lazy loading):

// src/servicios/cache.js  (fichero NUEVO)
import { redis } from '../config/redis.js';

/**
 * Cache-aside: mira la caché; si no está, calcula, guarda y devuelve.
 *
 * @param {string} clave     Clave completa, con prefijo de espacio de nombres.
 * @param {number} ttl       Segundos de vida.
 * @param {Function} calcular  Función que obtiene el dato real.
 */
export async function conCache(clave, ttl, calcular) {
  try {
    // 1. ¿Está en caché? (hit)
    const guardado = await redis.get(clave);
    if (guardado !== null) return JSON.parse(guardado);
  } catch (e) {
    // 2. Si Redis falla, NO se rompe la petición: se sigue contra la base de datos.
    //    La caché es una optimización, nunca una dependencia dura.
    console.error('Caché no disponible, se consulta el origen:', e.message);
  }

  // 3. Fallo de caché (miss): se calcula.
  const valor = await calcular();

  // 4. Se guarda sin esperar y sin romper si falla.
  redis.set(clave, JSON.stringify(valor), 'EX', ttl).catch(() => {});

  return valor;
}
// src/servicios/cafes.js  (MODIFICADO — fragmento)
export async function obtenerCafe(id) {
  return conCache(`aroma:cache:cafe:${id}`, 300, async () => {
    const fila = await repositorios.cafes.porId(id);
    if (!fila) throw errores.noEncontrado('cafe_no_encontrado', `No existe el café ${id}.`);
    return mapeadores.aCafePublico(fila);
  });
}

Cuatro reglas para no meterse en problemas:

  1. Cachea el resultado ya mapeado, no la fila cruda. Así el objeto en Redis no contiene columnas internas: si un día alguien lo vuelca para depurar, no filtra precio_centimos ni activo.
  2. La caché nunca es una dependencia dura. Si Redis cae, la API funciona más lenta, no falla.
  3. Espacio de nombres en las claves (aroma:cache:), separado del aroma:rl: del rate limiting de 04-04.
  4. Invalida al escribir, en la misma operación que modifica el dato.

Un aviso importante: no cachees objetos de error ni excepciones. En el ejemplo, si el café no existe se lanza antes de guardar nada; si se cacheara el null, un café recién creado tardaría cinco minutos en aparecer.

  1. Stampede y el bloqueo

Escenario: caf_001 es el café más pedido y su entrada de caché caduca a las 12:00:00. En ese instante hay 500 peticiones en vuelo. Las 500 fallan la caché, las 500 consultan la base de datos y las 500 escriben el mismo valor.

Eso es la estampida de caché (cache stampede o thundering herd), y es especialmente cruel porque ocurre justo en los recursos más populares, es decir, en el peor momento posible.

Tres defensas, de menos a más eficaz:

TTL con jitter. En lugar de 300 segundos exactos, un valor aleatorio entre 270 y 330. Reparte las caducidades y evita que miles de claves expiren a la vez. Es una línea de código:

const ttlConJitter = (base) => Math.floor(base * (0.9 + Math.random() * 0.2));

Bloqueo (lock). Solo un proceso recalcula; el resto espera brevemente y reintenta la caché:

// src/servicios/cache.js  (segunda parte)

/**
 * Cache-aside con bloqueo: evita que N peticiones simultáneas recalculen lo mismo.
 */
export async function conCacheBloqueada(clave, ttl, calcular) {
  const guardado = await redis.get(clave);
  if (guardado !== null) return JSON.parse(guardado);

  const claveBloqueo = `${clave}:lock`;
  // SET NX: solo tiene éxito si la clave NO existía. Es una operación atómica,
  // así que exactamente un proceso obtiene el bloqueo.
  // EX 10: el bloqueo caduca solo, para que un proceso caído no lo retenga eternamente.
  const obtenido = await redis.set(claveBloqueo, '1', 'NX', 'EX', 10);

  if (obtenido) {
    try {
      const valor = await calcular();
      await redis.set(clave, JSON.stringify(valor), 'EX', ttl);
      return valor;
    } finally {
      await redis.del(claveBloqueo);      // siempre se libera, haya fallado o no
    }
  }

  // Otro proceso está recalculando: esperar un poco y volver a mirar.
  await new Promise((r) => setTimeout(r, 50));
  const reintento = await redis.get(clave);
  if (reintento !== null) return JSON.parse(reintento);

  // Si sigue sin estar, se calcula igualmente: mejor duplicar trabajo que fallar.
  return calcular();
}

Refresco anticipado. Guardar junto al valor su instante de caducidad y refrescarlo antes de que expire, de forma probabilística. Nunca hay un momento en que la clave no exista. Es lo que hace stale-while-revalidate en la caché HTTP, aplicado al servidor.

  1. Compresión

npm install compression
// src/app.js (fragmento)
import compression from 'compression';

const opcionesCompresion = {
  // Por debajo de 1 KB, comprimir cuesta más CPU de lo que ahorra en red.
  threshold: 1024,

  filter(req, res) {
    // Permite desactivarla por petición, útil para depurar.
    if (req.get('Aroma-Sin-Comprimir')) return false;
    // Las imágenes ya están comprimidas: recomprimir es tiempo perdido.
    const tipo = res.get('Content-Type') ?? '';
    if (tipo.startsWith('image/') || tipo.startsWith('video/')) return false;
    return compression.filter(req, res);
  },

  level: 6,    // 1 = rápido, 9 = máximo. 6 es el equilibrio habitual.
};

app.use(compression(opcionesCompresion));    // posición 7

El ahorro en JSON es notable porque es texto con mucha repetición de claves:

Respuesta Sin comprimir gzip Brotli
GET /v1/cafes (20 elementos) 8,4 kB 1,9 kB 1,6 kB
GET /v1/cafes/caf_001 284 B (no se comprime: bajo el umbral)
GET /v1/pedidos (20 con expandir) 42 kB 6,1 kB 5,2 kB

Cuándo no comprimir:

  • Respuestas pequeñas (por debajo de ~1 kB): la sobrecarga supera al ahorro.
  • Contenido ya comprimido: imágenes, vídeo, PDF, ZIP.
  • Cuando la CPU es el cuello de botella y la red va sobrada.
  • Históricamente, respuestas con secretos junto a datos controlados por el atacante, por los ataques BREACH/CRIME. Con Authorization: Bearer en vez de cookies el riesgo práctico es mucho menor, pero conviene conocerlo.

Brotli comprime mejor que gzip y lo soportan todos los navegadores modernos; la mayoría de las CDN lo aplican por sí solas. Si tienes CDN, lo habitual es dejar que ella comprima y ahorrar esa CPU en tu proceso.

  1. Conexiones, HTTP/2 y latencia de red

Recuperando 01-03 con perspectiva de rendimiento:

Mecanismo Qué ahorra Ganancia típica
Keep-alive Handshake TCP + TLS por petición 100–300 ms por petición evitada
HTTP/2 multiplexado La cola de espera del navegador (6 conexiones) Mucho con muchas peticiones paralelas
HTTP/2 compresión de cabeceras Repetir Authorization en cada petición ~500 bytes por petición
HTTP/3 / QUIC Bloqueo por pérdida de paquete Notable en redes móviles
CDN cerca del usuario Distancia física 50–200 ms

Recordatorio de 04-04: keepAliveTimeout de Node debe ser mayor que el del balanceador, o aparecerán 502 esporádicos imposibles de reproducir.

Y la perspectiva que ordena las prioridades: en una petición móvil típica, la latencia de red domina sobre todo lo demás. Si tu API responde en 40 ms y el usuario está a 150 ms de distancia, optimizar la consulta SQL para bajar a 30 ms mejora el total un 5 %. Reducir de cinco peticiones a una (04-01) o servir desde una CDN mejora mucho más. La optimización más rentable casi nunca está en el servidor.

  1. Base de datos: índices, N+1 y consultas lentas

Índices. Cada filtro del contrato necesita el suyo:

-- migraciones/004-indices-rendimiento.sql
CREATE INDEX IF NOT EXISTS idx_cafes_origen        ON cafes(origen);
CREATE INDEX IF NOT EXISTS idx_cafes_tueste        ON cafes(tueste);
CREATE INDEX IF NOT EXISTS idx_cafes_precio        ON cafes(precio_centimos);
-- Compuesto: cubre el filtro por cliente Y la ordenación por fecha a la vez.
CREATE INDEX IF NOT EXISTS idx_pedidos_cliente_fecha ON pedidos(cliente_id, fecha_creacion DESC);
CREATE INDEX IF NOT EXISTS idx_resenas_cafe        ON resenas(cafe_id);

El orden de las columnas en un índice compuesto importa: (cliente_id, fecha_creacion) sirve para filtrar por cliente y ordenar por fecha, pero no para ordenar por fecha sin filtrar por cliente. Y los índices no son gratis: aceleran las lecturas y ralentizan las escrituras, porque hay que mantenerlos.

Cómo verificar que se usan:

EXPLAIN QUERY PLAN
SELECT * FROM pedidos WHERE cliente_id = 'cli_842' ORDER BY fecha_creacion DESC LIMIT 20;
-- Bien: SEARCH pedidos USING INDEX idx_pedidos_cliente_fecha (cliente_id=?)
-- Mal:  SCAN pedidos    ← recorre la tabla entera

El N+1, ahora en la base de datos (en 04-01 lo vimos sobre HTTP):

// ❌ 1 consulta + N consultas: con 20 pedidos y 3 líneas cada uno, 61 consultas.
const pedidos = repositorios.pedidos.listar(filtros);
for (const pedido of pedidos) {
  pedido.lineas = repositorios.lineas.porPedido(pedido.id);
}

// ✅ 2 consultas, siempre, sea cual sea el número de pedidos.
const pedidos = repositorios.pedidos.listar(filtros);
const ids = pedidos.map((p) => p.id);
const marcadores = ids.map(() => '?').join(',');       // ?,?,? según el número real
const todasLasLineas = db
  .prepare(`SELECT * FROM lineas_pedido WHERE pedido_id IN (${marcadores})`)
  .all(...ids);

// Se agrupan en memoria: O(n), mucho más barato que N idas a la base de datos.
const porPedido = new Map();
for (const linea of todasLasLineas) {
  if (!porPedido.has(linea.pedido_id)) porPedido.set(linea.pedido_id, []);
  porPedido.get(linea.pedido_id).push(linea);
}
for (const pedido of pedidos) pedido.lineas = porPedido.get(pedido.id) ?? [];

Nota de seguridad sobre el IN: los marcadores ? se generan a partir del número de elementos, y los valores van como parámetros. Nunca se interpolan los ids en el SQL (04-02). Y hay que acotar el tamaño de la lista, porque los motores tienen un límite de parámetros; con limite máximo 100 estamos muy por debajo.

Consultas lentas. Un registro sencillo detecta lo que las pruebas nunca ven:

// src/config/base-datos.js  (MODIFICADO)
const UMBRAL_MS = 50;

export function consultaInstrumentada(sql, parametros, ejecutar) {
  const inicio = performance.now();
  const resultado = ejecutar();
  const duracion = performance.now() - inicio;
  if (duracion > UMBRAL_MS) {
    // El SQL es plantilla fija, sin datos del usuario: seguro de registrar.
    // Los PARÁMETROS no se registran: pueden contener datos personales (04-02).
    registrador.warn({ sql, duracionMs: Math.round(duracion) }, 'consulta lenta');
  }
  return resultado;
}

El comentario señala una decisión de RGPD: la plantilla SQL se registra, los valores no.

  1. Respuestas parciales y paginación como medida de rendimiento

Dos mecanismos del módulo 2 que ahora se leen como optimizaciones de primer orden.

campos (02-05): si la lista de Aroma Móvil solo muestra nombre, precio y tueste, pedir el objeto entero desperdicia ancho de banda y serialización.

GET /v1/cafes?campos=id,nombre,precioEuros,tueste&limite=20
Petición Tamaño Reducción
GET /v1/cafes?limite=20 8,4 kB
GET /v1/cafes?limite=20&campos=id,nombre,precioEuros,tueste 2,1 kB 75 %

Paginación (02-06): además de ser obligatoria por diseño (04-01), es la defensa más efectiva contra la degradación con el crecimiento. Y el cursor de /pedidos no es un capricho: con desplazamiento=100000, el motor tiene que localizar y descartar 100.000 filas antes de devolver 20. Con cursor, salta directamente por índice.

Método Coste con 1M de filas, página 5.000 Consistencia con escrituras
desplazamiento=100000 Lineal: descarta 100.000 filas Puede duplicar o saltar filas
cursor=eyJmZWNoYSI6... Constante: salta por índice Estable

  1. Trabajo asíncrono con 202

Algunas operaciones no caben en el ciclo de una petición. Generar la factura PDF de un pedido con veinte líneas, o la exportación de datos del ejercicio de 04-02, tardan segundos.

Mantener la conexión abierta es mala idea: agota los procesos, choca con los timeouts del balanceador y hace que un fallo de red obligue a repetir todo el trabajo. El patrón correcto:

POST /v1/pedidos/ped_5001/factura HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 8c2f...
HTTP/1.1 202 Accepted
Location: /v1/tareas/tar_9f3a
Cache-Control: no-store
Content-Type: application/json

{
  "id": "tar_9f3a",
  "estado": "en_curso",
  "recurso": "/v1/pedidos/ped_5001/factura",
  "_links": { "self": { "href": "/v1/tareas/tar_9f3a" } }
}

El cliente consulta el recurso de tarea:

GET /v1/tareas/tar_9f3a HTTP/1.1
HTTP/1.1 200 OK
Retry-After: 2
Content-Type: application/json

{ "id": "tar_9f3a", "estado": "en_curso", "progreso": 0.4 }

Y al terminar:

HTTP/1.1 303 See Other
Location: /v1/pedidos/ped_5001/factura

Cuatro detalles de diseño:

  • 202 significa «aceptado, aún no hecho», y es distinto de 201 («creado»). El árbol de decisión de 02-04 lo contemplaba.
  • La tarea es un recurso, con su URI, su representación y sus _links. No es un mecanismo aparte.
  • Retry-After en la consulta evita que el cliente pregunte cada 50 ms. Es el mismo mecanismo de 04-04, aplicado a algo que no es un error.
  • 303 See Other al terminar redirige al recurso final. Y el código operacion_en_curso del catálogo cubre el caso de pedir la factura mientras se está generando.

Para el panel interno, en lugar de sondear se usa SSE, que ya forma parte de la arquitectura de Tienda Aroma: el servidor empuja el cambio de estado cuando ocurre.

  1. Medir antes de optimizar

Todo lo anterior es inútil —o contraproducente— si se aplica a ciegas. El orden correcto es siempre: medir, encontrar el cuello de botella real, arreglar eso, volver a medir.

Por qué la media engaña

Diez peticiones: nueve de 20 ms y una de 2.000 ms.

Estadístico Valor Qué dice
Media 218 ms Un número que ningún usuario ha experimentado
p50 (mediana) 20 ms La mitad va así de rápido
p95 2.000 ms 5 de cada 100 peticiones son terribles
p99 2.000 ms Ídem

La media es un promedio de dos poblaciones distintas y no describe a ninguna. Los percentiles son lo que hay que mirar, y el p99 es el más importante, porque una página que hace 10 peticiones tiene ~10 % de probabilidad de que al menos una caiga en el p99. Tu p99 es la experiencia habitual de tus usuarios más activos.

Presupuesto de latencia

Se decide antes de optimizar, y se convierte en el criterio para saber cuándo parar:

Endpoint p50 p95 p99
GET /v1/cafes 30 ms 80 ms 150 ms
GET /v1/cafes/{id} 15 ms 40 ms 80 ms
POST /v1/pedidos 80 ms 200 ms 400 ms
POST /v1/sesiones 150 ms 300 ms 500 ms

El login es deliberadamente el más lento: bcrypt con coste 12 tarda ~100 ms a propósito (03-06). Ese no es un problema de rendimiento que haya que arreglar, es una defensa funcionando. Sin el presupuesto escrito, alguien acabará «optimizándolo» bajando el coste de bcrypt.

Prueba de carga con autocannon

npm install --save-dev autocannon
# 50 conexiones concurrentes durante 30 segundos.
npx autocannon -c 50 -d 30 http://localhost:3000/v1/cafes

# Con autenticación y una ruta que no se cachea.
npx autocannon -c 20 -d 30 \
  -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/v1/pedidos

# Comprobar el efecto real del ETag: la segunda tanda debe ser mucho más rápida.
npx autocannon -c 50 -d 20 -H 'If-None-Match: "v7"' \
  http://localhost:3000/v1/cafes/caf_001

Salida típica:

┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%  │ Avg     │ Stdev   │ Max    │
├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼────────┤
│ Latency │ 8 ms │ 14 ms│ 46 ms │ 89 ms│ 17.2 ms │ 12.4 ms │ 210 ms │
└─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴────────┘

2894 requests/sec, 1.2 MB/sec read

Cómo leerlo sin engañarse:

  • Mira el p99 (columna 99 %), no la media.
  • Compara siempre contra una línea base tomada antes del cambio. Un número absoluto solo no dice nada.
  • Una sola máquina no es producción: no hay latencia de red real, ni CDN, ni otras instancias, ni datos de verdad.
  • Con pocos datos, todo va rápido. Prueba con un volumen realista: una tabla de 100 filas cabe en memoria y esconde la falta de índices.
  • Vigila la CPU y la memoria durante la prueba. Si la CPU está al 100 %, el cuello es tuyo; si está al 20 % y la latencia sube, el cuello está en la base de datos o en un tercero.

El orden de la optimización

  1. Mide y localiza el endpoint lento real, con datos de producción.
  2. ¿Se puede evitar la petición? Caché HTTP. Es la mayor ganancia posible.
  3. ¿Se puede reducir el número de peticiones? expandir (04-01).
  4. ¿Se puede evitar la consulta? Caché de servidor.
  5. ¿Se puede hacer la consulta más barata? Índices, N+1, campos.
  6. ¿Se puede hacer después? 202 y trabajo asíncrono.
  7. Vuelve a medir y comprueba contra el presupuesto.

Y una advertencia final: la caché añade complejidad y una clase nueva de bugs —datos obsoletos, invalidación incompleta, incoherencias entre niveles—. Si un endpoint responde en 15 ms y se llama diez veces al día, cachearlo no aporta nada y sí añade una forma más de fallar.

Errores Comunes y Consejos

Creer que no-cache significa «no cachear». Significa «revalida siempre». La que impide guardar es no-store.

Olvidar private en respuestas personalizadas. Una CDN puede servir los pedidos de un usuario a otro. Es la fuga de datos más grave de esta lección.

Cachear respuestas de error. Un 429 cacheado cinco minutos convierte un bloqueo de un minuto en cinco.

Usar ETags débiles para If-Match. No garantizan igualdad exacta y el estándar no lo permite. El ETag automático de Express es débil.

Devolver cuerpo en un 304. Es un error de protocolo; además anula el ahorro.

Olvidar Vary. Con negociación de contenido o CORS, provoca respuestas cruzadas intermitentes e imposibles de reproducir.

Cachear el stock. Provoca sobreventa. Hay datos que deben ser exactos siempre.

Poner TTL largos en el navegador. No se pueden purgar. Cortos en el navegador, largos en la CDN con s-maxage.

Optimizar sin medir. La mayoría del tiempo se pierde donde nadie mira, y el trabajo se dedica donde ya era rápido.

Consejo: empieza por el catálogo. Es el 80 % del tráfico y el más cacheable. Bien resuelto, resuelve casi todo.

Consejo: haz que la caché sea observable. Sin métricas de aciertos y fallos no sabes si funciona. Es tema de 04-07.

Consejo: prueba el 304 explícitamente. Es fácil que el middleware deje de funcionar en un refactor y nadie se entere: solo se nota en la factura de ancho de banda.

Ejercicios

Ejercicio 1: decidir la política de caché

Para cada recurso, decide Cache-Control completo y si emitirías ETag. Justifica cada elección:

  1. GET /v1/cafes?origen=Etiopía — catálogo filtrado, público.
  2. GET /v1/clientes/cli_842/preferencias — preferencias del usuario autenticado.
  3. GET /v1/cafes/caf_001/imagen — imagen cuyo nombre incluye un hash.
  4. POST /v1/sesiones — devuelve tokens.
  5. GET /v1/pedidos/ped_5001 — pedido del propio cliente, consultado a menudo.
  6. GET /v1/cafes/caf_001/resenas?ordenar=-fechaCreacion — reseñas públicas.

Ejercicio 2: implementar If-Match en DELETE

Implementa el borrado condicional de una reseña: DELETE /v1/resenas/{id} debe exigir If-Match, devolver 204 si la versión coincide, 412 conflicto_version si no, 428 precondicion_requerida si falta la cabecera y 404 si no existe. Escribe la ruta, el servicio y una prueba de integración de los cuatro casos.

Ejercicio 3: diagnosticar un problema de rendimiento

GET /v1/pedidos?limite=20&expandir=lineas.cafe tiene un p50 de 45 ms y un p99 de 1.800 ms. La CPU del servidor está al 25 % durante la prueba de carga. Enumera al menos cuatro causas posibles, ordenadas por probabilidad, y di cómo verificarías y corregirías cada una.

Soluciones

Solución 1

Cache-Control ETag Justificación
1 public, max-age=60, s-maxage=300, stale-while-revalidate=600 Sí (hash) Público y estable. Necesita Vary: Accept-Language porque las notas de cata se traducen. La CDN aguanta más porque se puede purgar
2 private, no-cache Sí (hash o version) Depende del usuario: private obligatorio. no-cache permite el 304, que es lo mejor para datos personales estables
3 public, max-age=31536000, immutable No hace falta El hash en el nombre garantiza que la URL cambia si cambia el contenido. Con immutable, el navegador ni revalida
4 no-store No Contiene tokens. No debe quedar en ningún disco ni proxy
5 private, no-cache Sí (version) Personal, se consulta a menudo y cambia poco: el 304 ahorra mucho. El ETag de versión sirve además para If-Match
6 public, max-age=60 Sí (hash) Público. TTL corto porque una reseña nueva debe aparecer pronto. Vary: Accept-Language

Solución 2

// src/rutas/resenas.js
router.delete(
  '/:id',
  autenticar,
  exigirRol('cliente', 'empleado', 'administrador'),
  exigirIfMatch,                                  // 428 si falta
  asincrono(controladores.resenas.eliminar)
);
// src/controladores/resenas.js
export async function eliminar(req, res) {
  await servicios.resenas.eliminar(req.params.id, req.versionesAceptadas, req.usuario);
  res.status(204).end();                          // 204: sin cuerpo
}
// src/servicios/resenas.js
export async function eliminar(id, versionesAceptadas, solicitante) {
  const resena = await repositorios.resenas.porId(id);

  // 404 también si es ajena: no confirmamos existencia (04-02).
  if (!resena) throw errores.noEncontrado('resena_no_encontrada', `No existe la reseña ${id}.`);
  const esPropietario = resena.clienteId === solicitante.id;
  const esPersonal = ['empleado', 'administrador'].includes(solicitante.rol);
  if (!esPropietario && !esPersonal) {
    throw errores.noEncontrado('resena_no_encontrada', `No existe la reseña ${id}.`);
  }

  if (versionesAceptadas && !versionesAceptadas.includes(resena.version)) {
    throw errores.precondicionFallida(
      'conflicto_version',
      'La reseña ha cambiado desde que la obtuviste. Vuelve a leerla y reintenta.'
    );
  }

  // Borrado condicional atómico: si otra transacción se coló, filas será 0.
  const filas = repositorios.resenas.eliminarSiVersion(id, resena.version);
  if (filas === 0) {
    throw errores.precondicionFallida('conflicto_version', 'Conflicto de versión al borrar.');
  }
}
// pruebas/integracion/resenas-condicional.prueba.js
describe('DELETE /v1/resenas/:id condicional', () => {
  it('204 cuando la versión coincide', async () => {
    const r = await request(app)
      .delete('/v1/resenas/res_101')
      .set('Authorization', `Bearer ${tokenEmpleado}`)
      .set('If-Match', '"v3"');
    assert.equal(r.status, 204);
    assert.equal(r.text, '');
  });

  it('412 conflicto_version cuando la versión no coincide', async () => {
    const r = await request(app)
      .delete('/v1/resenas/res_102')
      .set('Authorization', `Bearer ${tokenEmpleado}`)
      .set('If-Match', '"v1"');                 // la actual es v3
    assert.equal(r.status, 412);
    assert.equal(r.body.error.codigo, 'conflicto_version');
    assert.deepEqual(r.body.error.detalles, []);
  });

  it('428 precondicion_requerida cuando falta If-Match', async () => {
    const r = await request(app)
      .delete('/v1/resenas/res_102')
      .set('Authorization', `Bearer ${tokenEmpleado}`);
    assert.equal(r.status, 428);
    assert.equal(r.body.error.codigo, 'precondicion_requerida');
  });

  it('404 cuando no existe, aunque el If-Match sea correcto', async () => {
    const r = await request(app)
      .delete('/v1/resenas/res_999')
      .set('Authorization', `Bearer ${tokenEmpleado}`)
      .set('If-Match', '"v1"');
    assert.equal(r.status, 404);
    assert.equal(r.body.error.codigo, 'resena_no_encontrada');
  });
});

Nota de diseño: el 404 se comprueba antes que el If-Match, porque no tiene sentido hablar de la versión de algo que no existe, y porque responder 412 en un recurso inexistente filtraría información sobre su existencia.

Solución 3

Que la CPU esté al 25 % descarta que el cuello sea el propio proceso Node. La distancia entre p50 y p99 (40×) apunta a algo que ocurre solo a veces, no a un coste constante.

Causa probable Cómo verificarla Corrección
1 N+1 al expandir lineas.cafe: una consulta por línea Contar las consultas de una petición; EXPLAIN QUERY PLAN; el registro de consultas lentas Consulta agrupada con IN (?,?,?) y agrupación en memoria (apartado 14)
2 Falta de índice en pedidos(cliente_id, fecha_creacion): con pocos pedidos el escaneo es rápido y con muchos no EXPLAIN QUERY PLAN muestra SCAN en vez de SEARCH Crear el índice compuesto
3 Contención de escritura en SQLite: las lecturas esperan a que termine un INSERT Correlacionar los picos de latencia con las escrituras concurrentes Modo WAL, transacciones más cortas, o migrar a un motor cliente-servidor
4 Distribución de datos desigual: un cliente con 500 pedidos frente a la mayoría con 3 Comparar la latencia por clienteId; medir con datos realistas Paginación por cursor, límite de expandir, caché del caso pesado
5 Serialización de respuestas grandes: expandir multiplica el tamaño Comparar el tamaño de la respuesta con la latencia campos para adelgazar; compresión
6 Pausas del recolector de basura por respuestas grandes en memoria Métricas del proceso; --trace-gc Reducir el tamaño de las respuestas; hacer streaming si son enormes

Procedimiento: primero el registro de consultas lentas del apartado 14, que en cinco minutos distingue entre 1-2-3 (base de datos) y 5-6 (proceso). Después medir contra el presupuesto (p99 de 400 ms para escrituras; para esta lectura debería estar en el entorno de 150 ms) y parar cuando se cumpla, no antes ni después.

Conclusión

La restricción «cacheable» de 01-04 es ya implementación. Conoces los cuatro niveles de caché y la regla que evita la fuga más grave —private en todo lo que dependa de quién pregunta—; dominas Cache-Control directiva a directiva, incluido ese no-cache que no significa «no cachear» y ese stale-while-revalidate que elimina la espera del refresco; tienes la política de Tienda Aroma recurso a recurso, con el catálogo cacheado y /v1/pedidos revalidado siempre. Has implementado la validación condicional con ETag fuerte —del hash del cuerpo o del campo version que ya existía— y el flujo completo del 304. Y sobre todo se ha cerrado el círculo que quedó abierto en 03-05: If-Match y el 412 resuelven la actualización perdida con la semántica estándar de HTTP, funcionan con DELETE, los entienden los intermediarios y reutilizan el mismo ETag que el cliente recibió al leer; a cambio, el catálogo de errores crece con precondicion_requerida (428), que hay que documentar en openapi.yaml. En el proyecto tienes src/middleware/cache.js con cacheDe, etagCondicional y exigirIfMatch, src/servicios/cache.js con cache-aside y bloqueo, src/servicios/cache-invalidacion.js, y compression en la posición 7 con etagCondicional en la 10 de src/app.js. Más allá de la caché: compresión, keep-alive, índices, el N+1 resuelto con una consulta agrupada, campos, paginación por cursor y el 202 con recurso de tarea para la factura. Y, lo primero aunque se cuente al final, el presupuesto de latencia, los percentiles frente a la media y autocannon para medir antes de tocar nada.

Solo queda una cosa antes de cerrar el módulo, y es la que hace posible todo lo demás: saber qué está pasando. En 04-07, Observabilidad: logs, métricas y trazas, sustituiremos por fin el console.log de la posición 5 de src/app.js por logs estructurados con pino, con un logger hijo por petición que arrastra el trazaId que emitimos desde 03-02, los campos que hay que registrar siempre y los que nunca —contraseñas, tokens, datos personales— con su redacción automática. Instrumentaremos Tienda Aroma con prom-client y las cuatro señales de oro, exponiendo /metricas protegido y fuera de /v1, con la advertencia sobre la cardinalidad que revienta los sistemas de métricas. Veremos las trazas distribuidas con traceparent y OpenTelemetry sobre un POST /v1/pedidos completo —validación, SQL, gRPC de inventario y webhook—, la diferencia entre liveness y readiness en /salud, y cómo se alerta sobre síntomas y SLO en lugar de sobre la CPU. Y cerraremos el módulo con el balance de todo lo que se ha endurecido.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados