Назад в блог
13 августа 2026 г.Sergei Solod18 мин чтения

Почему старые вкладки Next.js ломаются после развёртывания: устаревший HTML, пропавшие чанки и рассинхронизация версий

После одного развёртывания мониторинг в продакшене зафиксировал сбой загрузки собственного чанка Next.js. Лог доказывал сам факт ошибки, но не её причину. На этом примере я разбираю старые вкладки, устаревший HTML, пропавшие ресурсы /_next/static, рассинхронизацию версий, хранение старых файлов, deploymentId, порядок публикации, мониторинг и безопасное восстановление.

Next.jsРазвёртываниеРассинхронизация версийВеб-кэшированиеНадёжность фронтендаСтатические ресурсы

Одна из самых полезных ошибок, которые я видел в продакшене после развёртывания, выглядела почти банально:

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

Она попала в тот же поток, что и сбои аналитики, рекламных скриптов, общие сообщения Script error. и прерванное воспроизведение видео. Большая часть этого потока была шумом. Но здесь ситуация отличалась: не загрузился ресурс моего собственного приложения Next.js. Если браузер действительно не смог его получить, часть страницы могла перестать работать.

По этому логу я не знал главного — почему чанк не загрузился. Причиной могла быть кратковременная проблема с сетью, сбой прокси или CDN, действительно отсутствующий файл. Либо старая страница могла запросить чанк предыдущего развёртывания уже после того, как сервер заменил ту сборку новой.

Последний сценарий легко недооценить, потому что само новое развёртывание при этом может быть полностью исправным. У всех новых посетителей новая версия открывается нормально, а вкладка, которая была открыта несколько часов, незаметно остаётся клиентом предыдущей версии.

Именно об этом разрыве совместимости эта статья: почему старые вкладки Next.js могут ломаться после развёртывания, как устаревший HTML и пропавшие ресурсы /_next/static создают рассинхронизацию версий, почему агрессивная очистка усугубляет проблему и как я бы строил публикацию, хранение старых файлов, мониторинг и восстановление, чтобы успешный выпуск не оставлял уже открывших приложение пользователей с неработающей страницей.

Первый вывод: не любой сбой скрипта означает ошибку развёртывания

В исходном потоке ошибок были смешаны совершенно разные классы сбоев. Сторонние скрипты аналитики и рекламы могут блокироваться фильтрами контента, DNS-фильтрацией, средствами приватности, региональными ограничениями, антивирусом или сетью пользователя. Обещание play() у видео может быть прервано последующим pause(), хотя само приложение не сломано. А общее междоменное сообщение Script error. часто содержит слишком мало данных для диагностики.

Сбой собственного чанка Next.js требует другого приоритета. Полезная граница проходит не между «есть ошибка JavaScript» и «ошибки JavaScript нет», а примерно так:

не загрузился сторонний ресурс
    -> обычно страдает телеметрия или необязательная функция

не загрузился собственный /_next/static/*.js
    -> код приложения может стать недоступен

Это различие важно: слишком шумный сборщик ошибок способен спрятать события, которые действительно совпадают с поломкой страницы. В моём случае важным был запрос к /_next/static/chunks/9253.647385b4be0958e4.js. Лог доказывал, что собственный скрипт не загрузился. Но он не доказывал, что причиной была рассинхронизация после развёртывания.

Я сознательно сохраняю эту границу доказательности: правдоподобная причина ещё не подтверждённая причина.

Открытая вкладка браузера фактически остаётся клиентом старого выпуска

Модель, которая помогла мне иначе посмотреть на проблему, проста: после развёртывания одновременно могут продолжать жить несколько версий приложения.

Предположим, выпуск A работает с 10:00. Пользователь открывает страницу и получает HTML вместе с JavaScript, нужным для этого маршрута. В 10:30 его сменяет выпуск B. Новые посетители получают B. Но уже открытая вкладка не превращается автоматически в B только потому, что на сервере появилась новая версия.

В этой вкладке могут оставаться:

  • среда выполнения JavaScript, загруженная из выпуска A;
  • ссылки на маршруты и чанки, сформированные выпуском A;
  • предварительно загруженные навигационные данные выпуска A;
  • состояние React, созданное при работе выпуска A;
  • модули разделённого кода из A, которые уже были скачаны;
  • ссылки на модули из A, которые ещё не были скачаны.

Последний пункт как раз и делает проблему заметной.

Если все чанки, которые когда-либо понадобятся этой странице, уже попали в кэш браузера, пользователь может ничего не заметить. Но современные приложения Next.js разделяют код. Переход на другой маршрут, динамический импорт, модальное окно, редактор или функция, которую пользователь открыл позже, могут потребовать ещё один файл JavaScript. Старое окружение тогда запрашивает ресурс по адресу, который был действителен для выпуска A.

Если сервер всё ещё хранит этот ресурс, работа может продолжиться как обычно. Если при развёртывании файл удалили, старый клиент получит 404, хотя сам выпуск B полностью исправен.

Чанки с хешем содержимого рассчитаны на длительное кэширование

Next.js намеренно задаёт по-настоящему неизменяемым ресурсам долгий срок кэширования. В актуальной документации по самостоятельному размещению указано, что неизменяемые ресурсы с SHA-хешем в имени отдаются, например, с годовой политикой:

Cache-Control: public, max-age=31536000, immutable

Это разумно: при изменении содержимого меняется и URL. Файл, имя которого зависит от содержимого, не нужно перепроверять при каждом запросе. Если следующая сборка создаёт другие байты, она должна получить другой адрес ресурса.

Но из этого следует важная вещь: старый URL остаётся значимым всё время, пока на него может ссылаться старый документ или старая среда выполнения.

Способность браузера хранить хешированный ресурс целый год не поможет, если именно этот файл до развёртывания ни разу не скачивался, а к моменту первого запроса сервер уже успел его удалить.

Поэтому утверждения «наши статические файлы неизменяемы» и «статические файлы предыдущего выпуска можно сразу удалить» не равнозначны. Неизменяемость делает хранение старых ресурсов безопасным. Но она не заставляет старые клиенты перестать их запрашивать.

Актуальное руководство Next.js по самостоятельному размещению прямо называет отсутствующие файлы JavaScript или CSS одним из проявлений рассинхронизации версий при работе нескольких серверов или постепенном развёртывании. Это тот же класс проблемы, даже если рассинхронизация возникла между старой вкладкой и уже обновлённым сервером, а не между двумя одновременно работающими серверами.

Версии могут разойтись несколькими способами

Фраза «проблема с кэшем» слишком расплывчата для диагностики. Я разделяю как минимум четыре механизма, потому что исправлять их нужно по-разному.

1. Старая вкладка запрашивает ресурс, который не успела загрузить до развёртывания

Это классический случай долгоживущей вкладки. Документ и среда выполнения относятся к выпуску A. Выпуск B заменяет файлы на сервере. Позже пользователь выполняет действие, которое требует лениво загружаемый чанк из A. Если ресурс A уже удалён, запрос завершается ошибкой.

2. Устаревший HTML ссылается на чанки, которых уже нет

CDN, обратный прокси, Service Worker, кэш браузера или слой статического размещения могут держать старый HTML дольше, чем предполагалось. Такой документ всё ещё содержит ссылки выпуска A, а на исходном сервере остались только файлы B.

Особенно опасно, если HTML по ошибке получает долгую политику immutable. Хешированный JavaScript и HTML нельзя считать одинаковыми объектами кэширования. Чанк может быть неизменяемым, потому что его URL версионируется содержимым. Именно HTML определяет, какой набор адресов чанков должен работать вместе.

3. Постепенное развёртывание или несколько экземпляров смешивают разные выпуски

Представим два экземпляра Next.js за балансировщиком нагрузки. Один уже работает на выпуске B, второй всё ещё на A. Документ может прийти от одной версии, а следующий навигационный запрос — попасть на другую. Актуальная документация Next.js называет это рассинхронизацией версий и отмечает среди последствий пропавшие ресурсы, несовпадение Server Functions и сбои навигации.

Самый безопасный вариант по умолчанию — один раз собрать выпуск и запускать один и тот же артефакт сборки на всех экземплярах этого развёртывания. Документация Next.js по самостоятельному размещению также рекомендует использовать одну и ту же сборку и согласованный идентификатор сборки во всех контейнерах, а не собирать каждый экземпляр независимо.

4. Само развёртывание публикует файлы в неправильном порядке

Даже без старых вкладок неатомарная загрузка может на короткое время создать невозможное состояние:

новый HTML уже доступен
+
новые файлы чанков ещё недоступны

или наоборот:

старый HTML ещё доступен
+
старые файлы чанков уже удалены

Достаточно очень короткого окна. Пользователю нужно попасть в него всего один раз.

Опасный шаблон развёртывания — «заменить всё и удалить старое дерево»

Простой сценарий развёртывания часто начинается примерно так:

build
rsync --delete new-output/ production/
restart

Это выглядит привлекательно: каталог продакшена всегда в точности соответствует самой свежей сборке. Но для долгоживущих клиентов такой подход враждебен.

При хешированных статических ресурсах очистка каталога до единственного выпуска почти ничего не даёт браузеру. Старые файлы не конфликтуют с новыми, потому что у них разные URL. Их удаление в основном экономит место на диске, но одновременно превращает каждую ещё действительную ссылку у старого клиента в потенциальный 404.

Теперь я воспринимаю старые чанки как материал для совместимости развёртываний, а не как мусор.

Это не значит, что нужно хранить каждую сборку вечно. Очистка должна быть отдельной политикой хранения, а не случайным побочным эффектом публикации нового выпуска.

Хранить старые ресурсы полезно, но конечного срока хранения недостаточно для полной защиты

При самостоятельном размещении можно сохранять старые ресурсы /_next/static на некоторый период совместимости. Его длина зависит от характера нагрузки. У сайта, где человек открывает страницу, читает две минуты и уходит, профиль риска отличается от приложения, которое держат открытым весь рабочий день.

Минимальный срок хранения удобно оценивать так:

период хранения >=
    ожидаемое время жизни устаревшего HTML
    + реалистичный срок жизни открытой вкладки
    + окно отката
    + запас на распространение развёртывания

Это не математическая гарантия. Вкладка браузера может оставаться открытой неделями. Никакое конечное число часов не делает сбои старых клиентов невозможными.

Поэтому я предпочитаю многоуровневую защиту:

  1. хранить неизменяемые ресурсы предыдущих выпусков достаточно долго, чтобы обычные старые сеансы продолжали работать;
  2. обнаруживать рассинхронизацию версий, чтобы клиент мог перейти на текущий выпуск;
  3. давать безопасную одноразовую перезагрузку или понятный пользователю путь восстановления, если нужный ресурс действительно недоступен;
  4. отслеживать пропавшие собственные чанки и настраивать срок хранения по реальным данным.

Слой хранения предотвращает большинство сбоев. Слой восстановления покрывает редкие случаи, которые невозможно полностью исключить конечным сроком хранения.

Не удаляйте старые чанки по слепому правилу возраста

Наивное правило вроде «удалять все файлы старше семи дней» тоже может быть ошибочным. Текущий выпуск способен повторно использовать старый хешированный файл, время изменения которого давно в прошлом просто потому, что его содержимое не менялось.

Более надёжная сборка мусора должна учитывать выпуски:

  1. сохранять манифесты или списки ресурсов всех выпусков, которые ещё находятся в окне совместимости;
  2. строить объединение путей ресурсов, на которые ссылаются эти выпуски;
  3. никогда не удалять файлы из этого защищённого множества;
  4. удалять ресурсы, на которые больше никто не ссылается, только после дополнительного периода ожидания.

Если для небольшого развёртывания это слишком сложная система, намеренно просторный каталог статических ресурсов часто обходится дешевле, чем расследование редких сбоев у клиентов. Хешированные файлы особенно удобны для этого: одинаковое содержимое естественным образом повторно использует стабильные URL или, как минимум, не может перезаписать несвязанное содержимое под тем же хешированным именем.

Правило, которого я бы избегал, простое: не включать --delete для общего дерева /_next/static в ту же операцию, которая переводит приложение на новый выпуск.

В Next.js есть явная защита от рассинхронизации версий, но это не хранилище старых ресурсов

Современный Next.js поддерживает deploymentId для защиты от рассинхронизации версий. Настройка может выглядеть так:

// next.config.js
const nextConfig = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
}

module.exports = nextConfig

Согласно актуальной документации Next.js по deploymentId, при такой настройке к адресам статических ресурсов, которыми управляет фреймворк, добавляется параметр ?dpl=<deploymentId>, клиентские навигационные запросы несут сведения о развёртывании, а сервер передаёт идентификатор развёртывания в ответах. Если Next.js обнаруживает несовпадение при навигации, он может выполнить полную навигацию вместо продолжения мягкого перехода с несовместимыми данными.

?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id

Это полезно, но важно не приписывать механизму лишних возможностей. В документации прямо сказано, что Next.js не использует входящий параметр ?dpl= для маршрутизации на нужную версию. Он нужен для сброса кэша. Если самостоятельно размещённый исходный сервер физически удалил старый ресурс, параметр запроса этот файл не восстановит.

Поэтому я рассматриваю deploymentId как механизм обнаружения рассинхронизации и восстановления, а не как замену аккуратному развёртыванию и хранению старых ресурсов.

На платформах с маршрутизацией по версиям инфраструктура может делать больше. Например, актуальная документация Vercel о защите от рассинхронизации описывает привязку к версии, благодаря которой запросы, которыми управляет фреймворк, могут продолжать обслуживаться тем развёртыванием, которое изначально отдало клиент. Это возможность платформы, и я не предполагаю её наличие в произвольной конфигурации Nginx или CDN.

Идентификатор сборки и идентификатор развёртывания решают связанные, но разные задачи

Next.js также создаёт идентификатор сборки во время next build. Если несколько контейнеров должны обслуживать одно развёртывание, они не должны незаметно превращаться в разные сборки только потому, что каждый сервер самостоятельно выполнил свой этап сборки.

Детерминированный идентификатор сборки можно привязать к идентификатору выпуска, например к коммиту Git:

// next.config.js
const nextConfig = {
  generateBuildId: async () => process.env.GIT_SHA,
  deploymentId: process.env.GIT_SHA,
}

module.exports = nextConfig

Этот пример иллюстративный, он не скопирован из моего продакшен-кода. Главное архитектурное правило: один логический выпуск должен иметь один согласованный артефакт сборки и одну идентичность развёртывания на всех обслуживающих его экземплярах.

generateBuildId идентифицирует сборку Next.js. deploymentId документирован именно для защиты от рассинхронизации версий и сброса кэша. Эти понятия связаны, но считать их названия синонимами — только усложнять диагностику.

Я бы публиковал ресурсы до переключения трафика на новый документ

Более безопасная последовательность развёртывания намеренно асимметрична. Новые неизменяемые ресурсы могут существовать ещё до того, как на них кто-то сошлётся. А новый HTML не должен ссылаться на файлы, которые пока недоступны.

В общем виде мне нужен такой порядок:

1. один раз собрать выпуск B
2. загрузить ресурсы B в /_next/static
3. убедиться, что нужные ресурсы действительно скачиваются
4. запустить или подготовить сервер/среду выполнения B
5. проверить работоспособность B
6. атомарно переключить новые запросы документов на B
7. оставить статические ресурсы A доступными
8. наблюдать за B
9. удалить старые ресурсы позже

Для статического экспорта принцип тот же: сначала загрузить версионированные ресурсы, потом опубликовать HTML, который на них ссылается. Для SSR за обратным прокси — подготовить новый сервер и переключать трафик только после успешной проверки его состояния.

Откат должен быть симметричным. Если сохранить каталог предыдущего выпуска и его статические ресурсы, приложение можно вернуть назад, не пытаясь заново восстановить старые файлы по памяти.

Но это не делает безопасным абсолютно любой откат. Миграция базы данных или несовместимый контракт серверной части могут не позволить старой версии приложения работать, даже если её JavaScript всё ещё существует. Хранение статических ресурсов решает проблему статической совместимости, а не все возможные проблемы совместимости выпусков.

Общий каталог неизменяемых ресурсов хорошо подходит для простой самостоятельной установки

Для небольшого развёртывания на Nginx один из простых вариантов — отделить текущий выпуск приложения от общего хранилища статических ресурсов.

Пример структуры:

/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/...

Каждое развёртывание добавляет новые файлы /_next/static в общий каталог, не удаляя файлы ранее сохранённых выпусков. Nginx может отдавать этот путь с политикой неизменяемого кэширования:

location ^~ /_next/static/ {
    root /srv/app/shared;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Это пример конфигурации, а не утверждение о том, что именно такую конфигурацию Nginx я использовал. В реальном развёртывании нужно учитывать права доступа, MIME-типы, варианты сжатия, поведение CDN и точную структуру выходных файлов.

Важен архитектурный принцип: изменяемый указатель на текущий выпуск и преимущественно дополняемое хранилище версионированных ресурсов имеют разные жизненные циклы.

HTML нужен другой режим кэширования, чем хешированным чанкам

Проще всего воспроизвести проблему, если кэшировать HTML так, будто это ресурс с хешем содержимого.

Для динамически формируемых страниц Next.js обычно использует некэшируемую семантику ответа для пользовательского динамического содержимого. Для статических страниц и ISR действуют другие правила, и CDN вполне может корректно их кэшировать. Статический экспорт, который отдаёт Nginx, ещё сильнее зависит от заголовков, настроенных оператором.

Поэтому я не применяю одно универсальное правило кэширования ко всему «сайту». Я разделяю объекты по классам:

хешированный ресурс /_next/static
    большой max-age
    immutable
    можно безопасно хранить

HTML / документ маршрута
    должен иметь возможность перейти на новый выпуск
    политика зависит от способа рендеринга
    не должен жить дольше ресурсов, на которые ссылается

RSC / навигационные данные / данные API
    отдельные правила совместимости и актуальности

Если используется CDN, после развёртывания может потребоваться очистить путь нового документа — это зависит от схемы кэширования. А удалять из CDN старые хешированные чанки только потому, что появился новый выпуск, обычно вредно: если исходный сервер их тоже удалил, такая очистка уничтожает последнюю копию, которая могла спасти старый клиент.

Руководство Next.js по кэшированию через CDN здесь полезно именно тем, что отделяет кэширование страниц от годовой политики immutable для ресурсов /_next/static.

Автоматическая перезагрузка — средство восстановления, а не основная стратегия развёртывания

Типичная реакция на сбой чанка — «просто перезагрузить страницу». Часто это действительно помогает: полная навигация получает текущий документ, который ссылается уже на текущую сборку.

Но слепая перезагрузка при любой ошибке скрипта создаёт новые проблемы:

  • сбой стороннего скрипта может вызвать бессмысленную перезагрузку;
  • настоящая авария сервера способна создать бесконечный цикл перезагрузок;
  • несохранённая форма может потерять введённые пользователем данные;
  • при полной навигации исчезает состояние компонентов React;
  • та же сломанная версия после перезагрузки может снова упасть.

Актуальная документация Next.js отдельно предупреждает: полная навигация, используемая для восстановления при рассинхронизации, может потерять состояние компонентов, например useState, тогда как состояние в URL или постоянном хранилище браузера может сохраниться.

Если добавлять клиентское восстановление, я хочу, чтобы оно было узким и одноразовым. Пример реализации:

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,
)

Это намеренно лишь пример. В продакшен-реализации нужно также учитывать чанки таблиц стилей, известные формы ошибок фреймворка, пользовательские сценарии, где перезагрузка разрушительна, и способ очистки отметки о восстановлении после успешной загрузки.

Для редактора, оформления заказа или длинной формы я скорее покажу баннер «Доступна новая версия; сохраните работу и перезагрузите страницу», чем принудительно обновлю вкладку.

Данные мониторинга должны показывать, действительно ли это рассинхронизация версий

Сообщения «не удалось загрузить скрипт» недостаточно. Чтобы отличить удалённый старый чанк от случайного сетевого сбоя, мне нужен контекст конкретного развёртывания.

Полезные поля:

  • URL ресурса, который не загрузился;
  • URL текущей страницы;
  • принадлежит ли ресурс самому приложению;
  • идентификатор выпуска или развёртывания, известный клиенту;
  • браузер и операционная система;
  • navigator.onLine как слабый сигнал, а не доказательство наличия связи;
  • время с момента загрузки страницы;
  • произошла ли ошибка вскоре после развёртывания;
  • была ли это первая попытка восстановления;
  • HTTP-статус, если его можно увидеть на стороне сервера;
  • выпуск, который в этот момент обслуживал запрос на исходном сервере или прокси.

После этого закономерности становятся гораздо информативнее.

Если множество пользователей из разных сетей запрашивают старые хешированные адреса чанков и сразу после выпуска исходный сервер отвечает 404, отсутствие сохранённых старых ресурсов становится сильным объяснением. Если сетевой сбой без HTTP-ответа видит только один пользователь, уверенности в рассинхронизации гораздо меньше. Если чанк возвращается с кодом 200, но с неправильным MIME-типом или в виде HTML-страницы ошибки, проблема уже в маршрутизации или настройке прокси, а не только в сроке хранения.

Я бы также отделил оповещения о сбоях собственных чанков от ошибок сторонних ресурсов. Именно это изменение мониторинга напрямую следует из моих исходных логов: полезный сигнал был смешан с большим количеством браузерного шума, не связанного с поломкой приложения.

Тест воспроизведения прост, но старую вкладку нужно оставить открытой

Этот класс ошибок легко пропустить при обычной проверке выпуска, потому что после развёртывания инженеры обычно сразу обновляют страницу. Тем самым они уничтожают именно то условие, которое нужно проверить.

Лучший ручной тест выглядит так:

  1. развернуть выпуск A;
  2. открыть вкладку в условиях, близких к продакшену, с включённым кэшем браузера;
  3. посетить только часть приложения, чтобы некоторые маршруты или ленивые функции остались незагруженными;
  4. оставить вкладку открытой;
  5. развернуть выпуск B;
  6. не обновлять старую вкладку;
  7. открыть маршрут или динамическую функцию, которой нужен ранее не загруженный код;
  8. проверить вкладки Network и Console;
  9. убедиться, что старые адреса ресурсов по-прежнему возвращают 200;
  10. проверить, выполняет ли механизм обнаружения рассинхронизации управляемую полную навигацию, когда это необходимо.

Я бы повторил тот же тест с CDN перед приложением, с двумя экземплярами сервера во время постепенного развёртывания и после окончания настроенного срока хранения.

Есть и неочевидная ошибка тестирования: включить «Disable cache» в DevTools для всего сразу. Для отдельных диагностик это полезно, но поведение браузера меняется. Сценарий долгоживущей вкладки обязательно нужно проверять и с реалистичным кэшированием, потому что кэш браузера — часть системы.

Не каждый сбой чанка исправляется хранением старых файлов

Сила хранения старых ресурсов именно в том, что оно решает один конкретный механизм. Не стоит превращать его в очередное универсальное объяснение.

Собственный чанк может не загрузиться, потому что:

  • запрос вообще не дошёл до сервера;
  • соединение оборвалось;
  • его заблокировало расширение браузера;
  • на узле CDN произошёл кратковременный сбой;
  • Nginx неправильно направил путь;
  • сервер вернул HTML-документ ошибки вместо JavaScript;
  • сжатие или Content-Encoding оказались повреждены;
  • права доступа к файлу были неверными;
  • при частичном развёртывании чанк вообще не загрузили на сервер;
  • файл существовал, но его удалили слишком рано;
  • клиент и сервер оказались на несовместимых выпусках.

Код ответа и время события имеют значение. Повторяющийся 404 для старого URL с хешем содержимого после каждого выпуска рассказывает совсем другую историю, чем ERR_CONNECTION_RESET в одной мобильной сети.

Поэтому я не стал бы переписывать исходный инцидент как «я доказал, что сайт сломал устаревший HTML». Я этого не доказал. Я наблюдал реальный сбой собственного чанка и выделил рассинхронизацию версий как один серьёзный сценарий, который стоит предотвращать архитектурно.

Самое безопасное развёртывание считает старых клиентов частью поверхности выпуска

Более глубокая ошибка — представлять, будто развёртывание в один момент заменяет версию A версией B.

На сервере так действительно может выглядеть смена символической ссылки или решение оркестратора контейнеров. Но в сети старые объекты CDN могут продолжать существовать. В браузерах документы выпуска A могут работать ещё долго после выхода B. Во время постепенного развёртывания одновременно активны обе серверные версии. При откате B может исчезнуть, а A снова стать текущей.

Поэтому реальная поверхность выпуска — это интервал времени, а не одна точка.

Теперь мои правила развёртывания приложения Next.js строятся вокруг этой идеи:

  • Один раз собирать каждый логический выпуск. Не позволять экземплярам незаметно создавать независимые сборки.
  • Публиковать неизменяемые ресурсы до публикации ссылок на них.
  • Хранить старые хешированные ресурсы в течение осознанно выбранного окна совместимости.
  • Не задавать изменяемому HTML ту же политику кэширования, что и хешированным чанкам.
  • Использовать deploymentId, если модель развёртывания допускает рассинхронизацию версий.
  • Использовать защиту платформы от рассинхронизации только там, где платформа действительно умеет маршрутизировать запросы с учётом версии.
  • Делать восстановление одноразовым и учитывать состояние пользователя.
  • Отслеживать сбои собственных чанков как отдельный продакшен-сигнал.
  • Проверять развёртывание со старой вкладкой, которая всё ещё открыта.
  • Удалять старые ресурсы позже, а не во время переключения на новый выпуск.

Правило, которым я пользуюсь теперь

Успешная сборка и исправная свежая страница ещё не доказывают, что развёртывание безопасно для пользователей, которые уже были в приложении.

Старая вкладка — не ненужный остаток. Это реальный клиент, на котором выполняется реальный предыдущий выпуск.

Когда я начал смотреть на развёртывания именно так, проблема чанков стала гораздо понятнее. Хеш содержимого даёт ресурсу стабильную идентичность. Долгое кэширование позволяет эффективно ей пользоваться. Но система развёртывания должна уважать эту идентичность достаточно долго либо дать клиенту управляемый способ перейти вперёд.

Мне не нужно хранить каждый старый выпуск вечно. Нужно, чтобы система переживала период, когда старые клиенты и новые серверы совершенно законно существуют одновременно.

Именно такой контракт развёртывания для меня важен теперь: новые пользователи получают новый выпуск, старые не теряют файлы, которые их текущая версия всё ещё умеет запрашивать, а оставшаяся несовместимость версий приводит к предусмотренному восстановлению, а не к сломанной странице.