Uno de los errores de producción más útiles que vi después de un despliegue parecía casi trivial:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
Apareció en el mismo flujo que fallos de analítica, scripts publicitarios, mensajes genéricos como Script error. y reproducciones de vídeo interrumpidas. La mayor parte de aquello era ruido. Este caso era distinto: el recurso que había fallado pertenecía a mi propia aplicación Next.js. Si el navegador realmente no podía cargarlo, una parte de la página podía dejar de funcionar.
Lo que el registro no me decía era por qué había fallado. Podía ser un problema transitorio de red, un fallo del proxy o del CDN, un archivo que de verdad no existía o una página antigua que seguía pidiendo un chunk de la versión anterior después de que el servidor ya hubiera reemplazado ese build.
Ese último escenario es fácil de subestimar porque el despliegue nuevo puede estar perfectamente sano. Todos los visitantes que llegan de cero reciben la versión actual y navegan sin problemas, mientras una pestaña que lleva abierta varias horas sigue siendo, en la práctica, un cliente de la versión anterior.
Este artículo trata de esa brecha de compatibilidad: por qué las pestañas antiguas de Next.js pueden romperse después de un despliegue, cómo el HTML obsoleto y los recursos eliminados de /_next/static provocan desfase entre versiones, por qué una limpieza demasiado agresiva empeora el problema y cómo diseñaría la retención, el despliegue, la observabilidad y la recuperación para no dejar tirados a los usuarios que ya tenían la aplicación abierta.
La primera lección: no todo fallo de script es un fallo de despliegue
El flujo de errores original contenía problemas de clases completamente distintas. Los scripts de analítica y publicidad de terceros pueden quedar bloqueados por extensiones, filtros DNS, funciones de privacidad, antivirus, restricciones regionales o la propia red del usuario. Una promesa de play() puede quedar interrumpida por un pause() posterior sin que la aplicación esté rota. Y un Script error. de origen cruzado suele aportar tan poca información que no sirve para diagnosticar nada por sí solo.
Un chunk propio de Next.js que no carga merece otra prioridad. La frontera útil no es “hay un error JavaScript o no lo hay”, sino algo más parecido a esto:
falla un recurso de terceros
-> normalmente telemetría o funcionalidad opcional
falla /_next/static/*.js de la propia aplicación
-> puede faltar código necesario para ejecutar la interfaz
La diferencia importa porque un sistema de alertas demasiado sensible puede enterrar los incidentes relevantes bajo cientos de mensajes irrelevantes. En mi caso, el recurso que merecía atención era /_next/static/chunks/9253.647385b4be0958e4.js. El registro demuestra que falló la carga de un script propio. No demuestra que la causa fuera un desfase de versiones debido al despliegue.
Prefiero mantener esa frontera probatoria muy clara: una causa plausible no es una causa confirmada.
Una pestaña abierta es, en la práctica, un cliente de una versión antigua
El modelo mental que me ayudó a entender el problema es sencillo: después de desplegar puede haber varias versiones de la aplicación vivas al mismo tiempo.
Imaginemos que la versión A está activa a las 10:00. Un usuario abre una página y recibe el HTML y el JavaScript necesarios para esa ruta. A las 10:30 se publica la versión B. Los visitantes nuevos reciben B, pero la pestaña ya abierta no se convierte automáticamente en B solo porque haya cambiado el servidor.
Esa pestaña puede conservar:
- el runtime de JavaScript cargado desde A;
- referencias a rutas y chunks generadas por A;
- datos de navegación precargados por A;
- estado de React creado mientras ejecutaba A;
- módulos divididos que ya se descargaron desde A;
- referencias a módulos de A que todavía no se han descargado.
El último punto es el que suele hacer visible el fallo.
Si todos los chunks que el usuario va a necesitar ya están en la caché del navegador, la sesión puede continuar sin problemas. Pero Next.js divide el código. Una navegación, un dynamic import, un modal, un editor o una función que se usa más tarde pueden requerir otro archivo JavaScript. El runtime antiguo pide entonces una URL que era válida para la versión A.
Si el servidor sigue conservando ese archivo, puede no pasar nada. Si el despliegue lo eliminó, el cliente antiguo puede recibir un 404 aunque la versión B funcione perfectamente para todo usuario nuevo.
Los chunks con hash de contenido están pensados para caché de larga duración
Next.js aplica deliberadamente una caché de larga duración a los recursos que son realmente inmutables. La documentación actual de self-hosting indica que los recursos inmutables cuyo nombre contiene un hash SHA se sirven con una política de un año como esta:
Cache-Control: public, max-age=31536000, immutable
La lógica es correcta: si cambia el contenido, cambia la URL. Por tanto, el navegador no necesita revalidar ese archivo en cada petición.
La consecuencia importante es esta: la URL antigua sigue siendo válida mientras exista algún documento o runtime antiguo capaz de referenciarla.
El hecho de que el navegador pueda conservar un recurso durante un año no ayuda si nunca llegó a descargar ese chunk concreto antes del despliegue y el origen lo borró justo antes de que el usuario lo necesitara.
Por eso “nuestros recursos son inmutables” no equivale a “podemos borrar inmediatamente los recursos de la versión anterior”. La inmutabilidad hace seguro conservarlos. No hace que los clientes antiguos dejen de pedirlos.
La guía actual de autoalojamiento de Next.js menciona expresamente los recursos JavaScript o CSS ausentes como un síntoma posible de desfase de versiones durante despliegues con múltiples servidores o despliegues progresivos. Es la misma familia de problema aunque el desfase sea entre una pestaña antigua y un servidor ya actualizado.
Las versiones pueden separarse de varias formas
Decir “es un problema de caché” es demasiado impreciso. Yo separo al menos cuatro mecanismos porque requieren soluciones distintas.
1. Una pestaña antigua pide un chunk que aún no había cargado
Es el caso clásico de una sesión larga. El documento y el runtime son de A. B sustituye los archivos en el servidor. Más tarde el usuario activa una parte de la interfaz que necesita un chunk diferido de A. Si ese archivo fue eliminado, la petición falla.
2. HTML obsoleto apunta a chunks que ya no existen
Un CDN, un proxy inverso, un Service Worker, la caché del navegador o una capa de alojamiento estático pueden conservar HTML antiguo más tiempo del previsto. Ese HTML sigue apuntando a recursos de A mientras el origen solo tiene B.
El problema se agrava si por error damos al HTML la misma política larga de immutable que a un archivo con hash. Un chunk sí puede almacenarse durante mucho tiempo porque su URL versiona su contenido. El HTML es precisamente el objeto que decide qué conjunto de URLs forma una versión coherente.
3. Un despliegue gradual o con varias instancias mezcla versiones
Imaginemos dos instancias de Next.js detrás de un balanceador. Una ya está en B y otra sigue en A. El documento puede venir de una versión y una navegación posterior caer en la otra. La documentación actual de Next.js llama a esto desfase de versiones y señala como posibles efectos los recursos ausentes, incompatibilidades en Server Functions y fallos de navegación.
La base más segura es construir una vez y ejecutar el mismo artefacto en todas las instancias que pertenecen al mismo despliegue lógico. La documentación de autoalojamiento de Next.js también recomienda usar el mismo build y un identificador de build coherente entre contenedores, en lugar de reconstruir cada réplica por separado.
4. El propio despliegue publica los archivos en un orden incorrecto
Incluso sin pestañas antiguas, una publicación no atómica puede crear temporalmente un estado imposible:
el HTML nuevo ya es visible
+
los chunks nuevos todavía no están disponibles
o al revés:
el HTML antiguo sigue visible
+
los chunks antiguos ya fueron eliminados
Una ventana de pocos segundos basta. Un usuario solo tiene que caer en ella una vez.
El patrón peligroso es reemplazar todo y borrar el árbol anterior
Muchos scripts de despliegue empiezan con algo parecido a:
build
rsync --delete new-output/ production/
restart
Resulta atractivo porque el directorio de producción queda exactamente igual al build más reciente. Pero es hostil para los clientes de larga duración.
Con recursos cuyo nombre contiene un hash, los archivos antiguos no suelen interferir con los nuevos porque sus URLs son diferentes. Borrarlos de inmediato aporta sobre todo ahorro de almacenamiento. A cambio, cualquier referencia que siga viva en un cliente antiguo puede convertirse en un 404.
Ahora considero los chunks antiguos como material de compatibilidad del despliegue, no como basura.
Eso no significa conservar todas las versiones para siempre. Significa que la limpieza debe ser una política de retención separada, no un efecto secundario de promover la versión más nueva.
La retención ayuda, pero ninguna ventana finita es una solución completa
En un sistema autoalojado podemos conservar recursos antiguos de /_next/static durante un periodo de gracia. La duración adecuada depende del producto. Una página que se visita durante dos minutos no tiene el mismo perfil que una aplicación que puede permanecer abierta toda la jornada.
Una forma útil de razonar sobre el mínimo es:
ventana de retención >=
antigüedad máxima esperable del HTML
+ duración realista de pestañas abiertas
+ ventana de rollback
+ margen de propagación del despliegue
No es una garantía matemática. Una pestaña puede permanecer abierta semanas. Ningún número finito de horas elimina por completo esta clase de fallos.
Por eso prefiero combinar varias capas:
- conservar los recursos inmutables anteriores el tiempo suficiente para que las sesiones normales continúen;
- detectar el desfase de versión y permitir que el cliente pase a la versión actual;
- ofrecer una recarga controlada de un solo intento o una ruta de recuperación visible cuando el archivo de verdad ya no existe;
- medir los chunks propios ausentes para ajustar la retención con datos reales.
La retención evita la mayoría de los fallos. La recuperación cubre la cola de casos que ninguna ventana finita puede eliminar al cien por cien.
No conviene recolectar chunks antiguos con una simple regla de edad
Una limpieza como “borra todo lo que tenga más de siete días” también puede ser incorrecta. Una versión actual puede reutilizar un archivo antiguo cuyo contenido no cambió y, por tanto, mantiene el mismo hash aunque su fecha de modificación en disco sea vieja.
Una recolección más robusta es consciente de las versiones:
- conservar manifiestos o inventarios de recursos de todas las versiones dentro de la ventana de compatibilidad;
- calcular la unión de todos los paths referenciados;
- proteger de borrado todo archivo de ese conjunto;
- eliminar solo recursos no referenciados después de un periodo adicional.
Si esto resulta demasiado complejo para una instalación pequeña, un directorio de recursos algo más grande suele ser mucho más barato que investigar fallos raros de clientes.
La regla que evitaría es sencilla: no ejecutar --delete sobre el árbol compartido de /_next/static en la misma operación que pone el nueva versión en producción.
Next.js ya tiene protección explícita contra desfase de versiones, pero no almacena tus archivos antiguos
Las versiones actuales de Next.js permiten configurar deploymentId para proteger frente a diferencias entre despliegues. Un ejemplo:
// next.config.js
const nextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
}
module.exports = nextConfig
Según la documentación actual de deploymentId, al configurarlo Next.js añade el parámetro ?dpl=<deploymentId> a las URLs de recursos estáticos gestionados por el framework, incluye información del despliegue en las solicitudes de navegación del cliente y señala su identificador de despliegue en las respuestas del servidor. Si detecta una discrepancia durante la navegación, puede forzar una navegación completa en lugar de continuar con una transición suave usando datos incompatibles.
?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-idEs útil, pero conviene no atribuirle más capacidades de las que tiene. La documentación dice expresamente que Next.js no utiliza el parámetro entrante ?dpl= para enrutar hacia una versión antigua del servidor; sirve para invalidar cachés. Si un origen autoalojado ha borrado físicamente un archivo antiguo, añadir una query no lo hace reaparecer.
Por eso trato deploymentId como mecanismo de detección y recuperación, no como sustituto de una estrategia de retención y publicación ordenada.
Algunas plataformas aportan una capa adicional. La documentación actual de Skew Protection de Vercel, por ejemplo, describe un bloqueo de versión para que las peticiones gestionadas por el framework continúen resolviendo contra el despliegue que sirvió al cliente. Eso es una capacidad de la plataforma, no algo que exista automáticamente en cualquier Nginx o CDN propio.
Build ID y deployment ID están relacionados, pero no son lo mismo
Next.js genera además un identificador de build durante next build. Si varios contenedores deben servir el mismo despliegue, no conviene que acaben siendo builds diferentes porque cada uno ejecutó su propio proceso de construcción.
Se puede vincular un identificador determinista a un commit o versión de versión:
// next.config.js
const nextConfig = {
generateBuildId: async () => process.env.GIT_SHA,
deploymentId: process.env.GIT_SHA,
}
module.exports = nextConfig
Este código es ilustrativo, no una copia de mi configuración de producción. La regla arquitectónica es lo importante: una versión lógica debería tener un único artefacto coherente y una identidad de despliegue consistente en todas las instancias que lo sirven.
generateBuildId identifica el build de Next.js. deploymentId está documentado específicamente para protección contra desfase de versiones e invalidación de caché. Tratarlos como sinónimos complica la investigación de problemas.
Publicaría los recursos antes de cambiar el tráfico al documento nuevo
Un despliegue seguro es deliberadamente asimétrico. Los recursos nuevos pueden existir antes de que nadie los referencie. El HTML nuevo no debería ser visible hasta que sus recursos estén disponibles.
Conceptualmente, quiero este orden:
1. construir la versión B una sola vez
2. subir los recursos de B a /_next/static
3. comprobar que los recursos requeridos realmente responden
4. preparar el servidor o runtime de B
5. ejecutar el health check
6. cambiar de forma atómica las nuevas peticiones de documentos a B
7. mantener disponibles los recursos estáticos de A
8. observar B
9. limpiar los recursos antiguos más tarde
Con un export estático aplica la misma idea: primero se publican los recursos versionados y después el HTML que los referencia. Con SSR detrás de un proxy, se prepara el servidor nuevo y se valida antes de mover tráfico.
El rollback debería ser simétrico. Mantener el directorio de la versión anterior y sus recursos permite volver atrás sin intentar reconstruir archivos antiguos bajo presión.
Eso no convierte cualquier rollback en seguro. Una migración de base de datos incompatible o un contrato de API cambiado puede impedir que la versión anterior funcione aunque su JavaScript siga disponible. La retención de recursos resuelve el componente estático de compatibilidad, no todos los riesgos de la versión.
Un directorio compartido de recursos inmutables funciona bien en autoalojamientos sencillos
En una instalación pequeña con Nginx, un patrón práctico es separar la versión actual de un almacén compartido para recursos versionados.
Una estructura ilustrativa:
/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/
/srv/app/shared/_next/static/...
Cada despliegue añade los nuevos archivos de /_next/static al directorio común sin borrar los pertenecientes a versiones que todavía están dentro del periodo de retención. Nginx puede servir ese path con una política inmutable:
location ^~ /_next/static/ {
root /srv/app/shared;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Es un ejemplo, no una afirmación sobre mi configuración exacta de Nginx. En producción hay que revisar permisos, tipos MIME, variantes comprimidas, comportamiento del CDN y la estructura de salida concreta.
La idea importante es arquitectónica: el puntero mutable a la versión actual y el almacén casi solo aditivo de recursos con hash tienen ciclos de vida distintos.
El HTML necesita una política de caché diferente a la de los chunks con hash
Una forma sencilla de recrear el problema es cachear HTML como si fuera un recurso inmutable cuyo nombre ya contiene su versión.
En páginas dinámicas, Next.js normalmente emplea semántica de no reutilización para respuestas dinámicas específicas de usuario. Las páginas estáticas e ISR siguen políticas distintas y pueden almacenarse legítimamente en un CDN. En un export estático servido por Nginx, la política depende todavía más de las cabeceras configuradas por el operador.
Por eso no pienso en una única regla de caché para “el sitio”. Distingo clases de objetos:
recurso con hash en /_next/static
max-age largo
immutable
seguro de conservar
HTML / documento de ruta
debe poder avanzar a la nueva versión
la política depende del modelo de renderizado
no debe sobrevivir a los recursos que referencia
RSC / navegación / API
reglas independientes de frescura y compatibilidad
Si hay un CDN, puede ser necesario invalidar rutas de documentos tras el despliegue según el diseño. En cambio, purgar chunks antiguos con hash solo porque hay una versión nueva suele ser contraproducente: si el origen también los borró, eliminamos la última copia que quizá podía mantener viva una pestaña antigua.
La guía de caché CDN de Next.js es útil precisamente porque separa la caché de páginas de la política de larga duración de /_next/static.
La recarga automática es una herramienta de recuperación, no la estrategia principal
Ante un error de chunk es común decir “recarga la página”. A menudo funciona porque una navegación completa obtiene el documento actual, que referencia el build actual.
Pero recargar ante cualquier error de script crea problemas nuevos:
- un fallo de terceros puede provocar una recarga inútil;
- un caída real puede generar un bucle infinito;
- se puede perder un formulario no guardado;
- se pierde estado local de componentes React;
- si la versión actual está roto, el fallo se repetirá tras recargar.
La propia documentación actual de Next.js advierte que una hard navigation usada para recuperarse de desfase de versiones puede perder estado como useState, mientras que los datos persistidos en URL o almacenamiento del navegador pueden sobrevivir.
Si implemento recuperación del lado del cliente, prefiero que sea estrecha y de un solo intento. Un ejemplo ilustrativo:
const RECOVERY_KEY = 'next-chunk-recovery-attempted'
function isOwnNextAsset(url: string) {
try {
const parsed = new URL(url, window.location.href)
return (
parsed.origin === window.location.origin &&
parsed.pathname.startsWith('/_next/static/')
)
} catch {
return false
}
}
window.addEventListener(
'error',
(event) => {
const target = event.target
if (!(target instanceof HTMLScriptElement)) return
if (!isOwnNextAsset(target.src)) return
reportChunkFailure({
page: window.location.href,
asset: target.src,
})
if (sessionStorage.getItem(RECOVERY_KEY)) return
sessionStorage.setItem(RECOVERY_KEY, '1')
window.location.reload()
},
true,
)
Es solo un ejemplo. En un sistema real también habría que contemplar chunks CSS, firmas conocidas de errores del framework, flujos donde recargar destruye trabajo y cómo limpiar la marca de recuperación después de un arranque sano.
En un editor, checkout o formulario largo puede ser mejor mostrar “Hay una versión nueva; guarda tu trabajo y recarga” que forzar una recarga inmediata.
La telemetría debería decirme si de verdad estoy ante desfase de versiones
Un mensaje que solo dice “failed to load script” no basta. Para diferenciar un chunk antiguo eliminado de un fallo aleatorio de red necesito contexto del despliegue.
Campos útiles:
- URL del recurso fallido;
- URL de la página actual;
- si el recurso es propio o de terceros;
- identificador de versión o despliegue visible al cliente;
- navegador y sistema operativo;
navigator.onLinecomo señal débil, no como prueba;- tiempo transcurrido desde la carga de la página;
- si el error ocurrió cerca de un despliegue;
- si ya hubo un intento de recuperación;
- código HTTP cuando puede observarse en servidor;
- versión que sirvió realmente la petición en origen o proxy.
Entonces los patrones son mucho más útiles.
Si muchos usuarios de redes distintas solicitan URLs con hash antiguas justo después de una versión y el origen devuelve 404, el borrado prematuro de recursos se vuelve una explicación fuerte. Si un único usuario ve un fallo de red sin respuesta HTTP, desfase de versiones es mucho menos seguro. Si el chunk responde 200 pero contiene HTML o un MIME incorrecto, el problema está en enrutamiento o proxy.
También separaría las alertas de chunks propios de las fallas de recursos de terceros. Esa es la mejora más directamente respaldada por mis registros originales: la señal importante estaba mezclada con muchísimo ruido del navegador.
La prueba de reproducción es sencilla, pero la pestaña antigua debe seguir siendo antigua
Este tipo de fallo suele escaparse del QA normal porque, tras desplegar, el desarrollador recarga inmediatamente. Al hacerlo destruye precisamente el estado que hay que comprobar.
Una prueba manual mejor:
- desplegar A;
- abrir una pestaña con comportamiento de caché realista;
- usar solo parte de la aplicación para dejar rutas o funciones diferidas sin cargar;
- mantener la pestaña abierta;
- desplegar B;
- no recargar la pestaña antigua;
- abrir una ruta o función dinámica que necesite código aún no descargado;
- observar Network y Console;
- comprobar si las URLs antiguas siguen devolviendo 200;
- comprobar si la detección de desfase produce una navegación completa controlada cuando corresponde.
Repetiría la misma prueba con CDN, con varias instancias durante un despliegue progresivo y después de vencer la ventana de retención.
Un error sutil es activar “Disable cache” en DevTools para todo. Puede ser útil en otras investigaciones, pero modifica el sistema que intentamos probar. El escenario de pestañas de larga duración también debe probarse con caché realista.
No todo fallo de chunk se arregla conservando archivos antiguos
La retención es útil precisamente porque soluciona un mecanismo concreto. No debería convertirse en la nueva explicación universal.
Un chunk propio puede fallar porque:
- la petición nunca llegó al servidor;
- la conexión se interrumpió;
- una extensión del navegador lo bloqueó;
- un nodo del CDN tuvo un fallo transitorio;
- Nginx enrutó mal la URL;
- el servidor devolvió una página HTML de error en lugar de JavaScript;
- la compresión o
Content-Encodingse corrompió; - los permisos del archivo eran incorrectos;
- un despliegue parcial nunca subió ese chunk;
- el archivo existía pero se borró demasiado pronto;
- cliente y servidor pertenecían a versiones incompatibles.
El estado HTTP y el momento importan. Un 404 repetido para una URL antigua con hash después de cada despliegue cuenta una historia muy distinta de ERR_CONNECTION_RESET en una sola red móvil.
Por eso no reescribiría mi incidente como “demostré que el HTML obsoleto rompió el sitio”. No lo demostré. Observé un fallo real de un chunk propio y, a partir de él, identifiqué el desfase de versiones como un modo de fallo serio que merece diseñarse explícitamente.
El despliegue más seguro considera a los clientes antiguos parte de la versión
El error conceptual más profundo es imaginar que un despliegue sustituye A por B en un instante.
El symlink o el orquestador pueden hacerlo parecer así en el servidor. En el sistema completo, sin embargo, pueden quedar objetos antiguos en el CDN, documentos A activos en navegadores, ambas versiones del servidor durante una transición y A de nuevo tras un rollback.
La versión real ocupa un intervalo de tiempo, no un punto.
Mis reglas para desplegar Next.js salen de ese modelo:
- Construir una vez por versión lógica. Las réplicas no deberían producir builds distintos en silencio.
- Publicar recursos inmutables antes que los documentos que los referencian.
- Conservar recursos antiguos con hash durante una ventana de compatibilidad deliberada.
- No aplicar al HTML mutable la misma política de caché que a los chunks versionados.
- Usar
deploymentIdcuando el modelo de despliegue pueda generar desfase de versiones. - Usar protección de la plataforma solo cuando realmente exista enrutamiento consciente de la versión.
- Diseñar la recuperación como un único intento y respetando el estado del usuario.
- Monitorizar por separado los fallos de chunks propios.
- Probar cada despliegue con una pestaña antigua todavía abierta.
- Recolectar recursos antiguos más tarde, no durante la promoción de la versión.
La regla que sigo ahora
Un build verde y una página recién cargada funcionando no demuestran que el despliegue sea seguro para los usuarios que ya estaban dentro.
La pestaña antigua no es residuo. Es un cliente real que ejecuta una versión anterior real.
Cuando empecé a pensar así, los fallos de chunks dejaron de parecer misteriosos. El hash da identidad estable al recurso. La caché larga hace eficiente esa identidad. Pero el despliegue debe respetarla durante tiempo suficiente o proporcionar un camino controlado para que el cliente avance.
No necesito mantener todas las versiones antiguas para siempre. Necesito que el sistema sobreviva al periodo en el que clientes antiguos y servidores nuevos coexisten legítimamente.
Ese es el contrato de despliegue que me importa ahora: los usuarios nuevos reciben la versión nueva, los usuarios antiguos no pierden los archivos que su versión todavía sabe solicitar y cualquier incompatibilidad restante termina en una recuperación deliberada en lugar de una página rota.