Cuando mi obsesión con lo estático casi tumba mi propio servidor

5 de octubre de 2026
Cuando mi obsesión con el SSR estático casi tumba mi propio servidor
⚡ TL;DR
- Mi sitio pasó de 21,000 páginas y el build se volvió un infierno de 40+ minutos.
- Los timeouts eran un síntoma: la causa eran una tormenta de requests y un JOIN con OR que MySQL no podía indexar.
- La solución: ISR on-demand + revalidación por tags + reescribir el SQL con UNION.
- Resultado: de 21,000 a ~8,500 páginas en build, y un endpoint de +60 s a 0.68 s.
Llevo años construyendo sitios de turismo (catálogos de hoteles, restaurantes, noticias) y desde que migré a Next.js me obsesioné con una idea: todo debe ser estático.
Si una página se puede generar en build time, se genera. HTML puro, servido en milisegundos, perfecto para SEO y para el PageSpeed. ¿SSR bajo demanda? No, gracias. Mi servidor no iba a renderizar ni una sola página por usuario si yo podía evitarlo.
Y funcionó. Durante mucho tiempo.
Hasta que el sitio de Turista México pasó de 21,000 páginas.
🔥 Los síntomas
Cada deploy empezó a ser una tortura: 40+ minutos de build. Luego integré el módulo de restaurantes y todo empeoró: el build tardaba 22 minutos solo en "Collecting page data", lleno de errores como este:
Error fetching single page from .../restaurante-ciudades?page=1:
TypeError: fetch failed [cause]: HeadersTimeoutError
Y al final, páginas enteras fallando:
Failed to build /restaurantes/buscar because it took more than 60 seconds.
🕵️ El primer diagnóstico (equivocado)
Mi primera lectura fue: "el API está lento a veces, hay que poner timeouts". Agregué AbortSignal.timeout(60s) y reintentos con backoff. Ayudó (el build ya no se colgaba 5 minutos por request), pero no curó nada.
Porque el problema real era otro: una tormenta de requests. Al generar miles de páginas estáticas, cada página hacía sus propios fetch al API. Miles de llamadas concurrentes a un servidor PHP que terminaba asfixiado.
🎯 Mi obsesión con el estático estaba DDoSeando mi propio backend.
🚀 La solución de fondo: ISR on-demand
La respuesta no era abandonar lo estático, sino generarlo bajo demanda. Next.js lo soporta nativamente con ISR:
export const dynamic = 'force-static';
export const dynamicParams = true; // la clave: params fuera de la lista se generan al vuelo
export const revalidate = 604800; // 7 días
export async function generateStaticParams() {
return []; // NADA en el build
}
Con generateStaticParams vacío y dynamicParams = true, las páginas de detalle (hotel, restaurante, noticia) ya no se generan en el build. La primera visita las renderiza (~1-3 s) y quedan cacheadas como estáticas, idénticas a las de antes. Los listados sí siguen pre-generados.
| Antes | Después | |
|---|---|---|
| Páginas en build | ~21,000 | ~8,500 |
| Tiempo de build | 40+ minutos | Unos pocos minutos |
| Carga sobre el API | Tormenta de requests | Requests repartidos en el tiempo |
♻️ Invalidación: ¿y si cambia un precio?
Con revalidate = 604800, un cambio de precio en un hotel aparece solo... dentro de 7 días. Inaceptable.
La solución: on-demand revalidation. Un endpoint protegido por secreto:
GET /api/revalidate?secret=XXX&tag=hotel-123
Cada fetch de datos lleva un tag (hotel-123, restaurante-815, noticias). Cuando el extranet guarda un hotel, dispara ese endpoint y esa página, solo esa, se regenera en la siguiente visita.
✅ Cero rebuild. ✅ Cero espera.
💣 El plot twist: el verdadero cuello era SQL
Con todo lo anterior, el build seguía fallando. Un endpoint, /restaurante-ciudades (que lista ciudades con restaurantes), tardaba +60 s siempre. Abrí el mapper y encontré esto:
INNER JOIN res_restaurantes r
ON (r.ciudad_id = v.hviid OR r.ciudad_id = v2.hviid)
Un JOIN con OR entre dos columnas de tablas distintas. MySQL no puede usar un índice para eso, así que por cada vista (~800) hacía un barrido de restaurantes (~21k): un cartesiano de ~17 millones de comparaciones por cada request. Y encima, una subconsulta correlacionada por fila para calcular las cocinas populares.
El SHOW PROCESSLIST lo confirmó con crudeza:
Query 2391 Sending data SELECT `v`.`hviid` ...
Query 2390 Sending data SELECT `v`.`hviid` ...
...15 copias, todas con +2,300 segundos
🛠️ La reescritura
El clásico "divide el OR en un UNION": una tabla derivada que mapea restaurante → vista en dos pasadas indexadas (la ciudad asignada + su ciudad padre), agregada una sola vez con GROUP BY. La misma técnica para las cocinas. Dejo el patrón porque es reutilizable:
SELECT v.*, a.total, a.promedio, ct.cocinas
FROM hot_hotelesviews v
INNER JOIN (
SELECT m.vid, COUNT(DISTINCT m.restaurante_id) total, ...
FROM (
SELECT r.ciudad_id AS vid, r.restaurante_id, 1 AS is_direct, ...
FROM res_restaurantes r WHERE r.status IN ('active','trial')
UNION
SELECT h3.parentid, r.restaurante_id, 0, ...
FROM res_restaurantes r
JOIN hot_hotelesviews h3 ON h3.hviid = r.ciudad_id
WHERE r.status IN ('active','trial') AND h3.parentid > 0
) m GROUP BY m.vid
) a ON a.vid = v.hviid
LEFT JOIN (... GROUP_CONCAT de cocinas ...) ct ON ct.vid = v.hviid
🏁 El endpoint pasó de +60 s (o infinito bajo carga) a 0.68 segundos.
🎓 Las lecciones
- Estático sí, pero no en el build.
generateStaticParamsvacío +dynamicParamste da lo mismo que el SSG masivo (HTML estático servido en ms) sin pagar 40 minutos ni martirizar al API en cada deploy. - Los timeouts son síntoma, no causa. Un fetch que se cuelga casi siempre esconde una query patológica o una tormenta de requests.
- Un
JOIN ... ORes una bomba de relojería. Funciona con 100 filas; con concurrencia real genera queries de 40 minutos que se acumulan hasta tumbar MySQL (miALTER TABLEesperó metadata lock porque había 25 queries monstruosas en cola). - El paginador también paga. Un
COUNT(*)sobre una query cara la ejecuta completa: dos veces el dolor. - Caché de fetch en el framework.
next: { revalidate }hace que mil páginas compartan una sola llamada al API. Sin eso, el estático se convierte en arma contra ti mismo.
La obsesión con el performance no estaba mal. Estaba mal cuándo la aplicaba. El sitio sigue siendo 100% HTML estático para el usuario y para Google. Solo que ahora el estático se gana bajo demanda, no en un build de 40 minutos que casi tumba producción.
📚 Glosario técnico
| Término | Significado |
|---|---|
| TL;DR | Sigla de "Too Long; Didn't Read". Resumen breve al inicio de un texto largo. |
| Next.js | Framework de React para construir sitios web con renderizado en servidor y generación estática. |
| Build time | Momento en que se compila y genera el sitio, antes de publicarlo (en el deploy). |
| SSG (Static Site Generation) | Generar las páginas como HTML en el build, de modo que se sirvan ya listas. |
| SSR (Server-Side Rendering) | Generar el HTML en el servidor en cada visita. |
| ISR (Incremental Static Regeneration) | Técnica de Next.js que genera o regenera páginas estáticas después del build, bajo demanda o cada cierto tiempo. |
| On-demand revalidation | Invalidar manualmente una página cacheada (por ejemplo vía un endpoint) para que se regenere en la siguiente visita. |
generateStaticParams | Función de Next.js que define qué rutas dinámicas se pre-generan en el build. |
dynamicParams | Opción que permite generar al vuelo rutas que no estaban en generateStaticParams. |
revalidate | Tiempo (en segundos) tras el cual una página cacheada se considera vencida y puede regenerarse. |
| Tag (cache tag) | Etiqueta asociada a un fetch para invalidar solo los datos y páginas que dependen de ella. |
| Collecting page data | Fase del build de Next.js donde se recopilan los datos de cada página. |
| Timeout | Límite de tiempo de espera de una petición antes de cancelarla con error. |
| Backoff (reintentos con) | Estrategia de reintentos donde cada intento espera más que el anterior. |
HeadersTimeoutError | Error de fetch cuando el servidor no responde los headers dentro del plazo. |
| Tormenta de requests | Gran cantidad de peticiones simultáneas que saturan un servidor. |
| DDoS | Ataque de denegación de servicio por sobrecarga de peticiones; aquí, autoinfligido. |
| Extranet | Panel privado donde los clientes (por ejemplo hoteles) gestionan sus datos. |
| Mapper | Capa de código que traduce entre la base de datos y los objetos de la aplicación. |
| JOIN | Operación SQL que combina filas de dos tablas según una condición. |
| Producto cartesiano | Combinar cada fila de una tabla con todas las de otra, cuando no hay un índice que filtre. |
| Índice | Estructura de la base de datos que acelera las búsquedas por una columna. |
| Subconsulta correlacionada | Subconsulta que se ejecuta una vez por cada fila de la consulta externa. |
| UNION | Operación SQL que combina los resultados de dos consultas eliminando duplicados. |
| Tabla derivada | Subconsulta usada en el FROM como si fuera una tabla temporal. |
GROUP BY | Cláusula SQL que agrupa filas para calcular agregados (conteos, promedios). |
GROUP_CONCAT | Función de MySQL que une valores de varias filas en un solo texto. |
SHOW PROCESSLIST | Comando de MySQL que lista las consultas en ejecución. |
| Metadata lock | Bloqueo de MySQL que impide cambiar la estructura de una tabla mientras otras consultas la usan. |
COUNT(*) | Función SQL que cuenta filas; en una query costosa, la ejecuta completa. |
| PageSpeed | Herramienta de Google que mide el rendimiento de carga de una página. |
| SEO | Optimización para motores de búsqueda. |