Перезапрос упавшей статики
- пятница, 20 февраля 2026 г. в 00:00:06
Современное фронтенд-приложение после сборки - это не один большой JS-файл, а главный entry-point и набор чанков: файлов, которые подгружаются по требованию при динамическом импорте (import(), React.lazy() и т.д.). Пользователь открывает страницу, грузится основной бандл, переходит в раздел - браузер по запросу качает ещё один файл, например chunk-abc123.js.
И вот этот запрос чанка может упасть. Причины бывают разные:
Временный обрыв сети или таймаут;
Прокси, файрвол, расширения браузера режут или подменяют ответ;
Деплой: на сервере уже новая версия, старые чанки удалены, а у пользователя в кэше старый index.html — он просит chunk-old.js, которого уже нет (404);
CDN отдал ошибку или устаревший/битый ответ;
Субресурсная целостность (SRI): файл подменили — checksum не сошёлся.
В итоге пользователь видит белый экран или «Loading chunk failed» / ChunkLoadError, хотя само приложение и сервер в порядке - просто один запрос статики не прошёл. И тут хочется автоматически повторить загрузку этого же (или того же по смыслу) ресурса, прежде чем показывать ошибку.
На статическом сайте написанном без фреймворков и библиотек мы сами расставляем <script> и <link>. Логика перезапроса могла бы быть такой:
Подписываемся на window.onerror или error на конкретных тегах
При ошибке загрузки скрипта/стиля - через некоторое время создаём новый тег с тем же src (возможно, с cache-bust query) и вставляем в документ
Ограничиваем число попыток и задержку между ними
То есть точка входа одна: мы контролируем, как и когда создаются теги. Перехватить ошибку и повторить запрос. Тобеж нужно написать свою небольшую обвязку в HTML/JS.
В реальности статику генерирует бандлер Webpack, Vite, Rollup, esbuild, Rsbuild и т.д. Он сам:
решает имена и пути чанков;
генерирует код, который в рантайме запрашивает эти чанки.
То есть кто и как создаёт запрос на чанк - определяется не нашим разметкой, а рантаймом бандлера, вшитым в сгенерированный JS. Чтобы добавить перезапрос при ошибке, нужно либо:
как-то встроить логику retry в этот рантайм (подменить функцию загрузки),
или же обрабатывать ошибки на уровне приложения (обёртки над import(), Error Boundary, «перезагрузить страницу»).
Оба пути имеют смысл; возможность первого сильно зависит от того, как устроен бандлер.
Идея такая:
Перехватить момент загрузки чанка
Вместо «запросил один раз → упало → показали ошибку» делаем: запросили → ошибка → ждём (опционально с задержкой) → повторяем запрос (с тем же URL или с cache-bust) → повторяем до N раз.
Cache-bust при повторе
Чтобы не тащить из кэша тот же битый ответ, к URL при повторных попытках добавляют query, например ?cache-bust=... или &retry=2. Так мы не полагаемся на то, что браузер сам решит не кэшировать ошибку.
Поведение после исчерпания попыток
Либо пробрасываем ошибку дальше (пользователь видит ошибку/Error Boundary), либо выполняем «последний шанс»: например, редирект на страницу с просьбой обновить или на fallback-страницу.
Чтобы это встроить в рантайм бандлера, нужно, чтобы у сборщика была единая точка, через которую все чанки запрашиваются, и чтобы в неё можно было вклиниться (плагин, патч шаблона и т.п. Отсюда разница между бандлерами.
Vite (в production) и Rollup собирают выход в ES-модули. Динамический импорт остаётся в виде нативного import(...) в сгенерированном коде. То есть загрузку чанков выполняет сам браузер, по спецификации ES modules. Нет единого рантайм-объекта вроде __webpack_require__.e с одной функцией «загрузи чанк», которую можно подменить плагином на этапе сборки. Рантайм минимальный (preload polyfill и т.п.), и он не рассчитан на кастомную логику retry.
Поэтому:
встроить retry в сгенерированный код без хаков и обхода дерева модулей - практически некуда;
типичный подход для Vite/Rollup - не трогать бандлер, а делать retry на уровне приложения: обёртка над динамическим импортом (например, lazyWithRetry для React), повторный вызов import() при ошибке
esbuild тоже не предоставляет единого рантайм-хука для загрузки чанков; динамические импорты уходят в нативный import(). Ситуация похожая: retry проще реализовать в коде приложения, а не внутри сборки.
Итого: в Vite, Rollup, esbuild «плагин сборки», который бы один раз подменил загрузчик чанков и добавил retry всем чанкам автоматически, в стандартной архитектуре не предусмотрен. Тут нужны обёртки в коде и/или отдельный скрипт в HTML, который перехватывает ошибки загрузки тегов.
У Webpack есть свой рантайм: общая логика загрузки чанков живёт в сгенерированном коде в виде глобальных функций, в частности:
__webpack_require__.e (или аналог) - «ensure chunk», по сути загрузка скрипта чанка;
функция, которая по chunkId возвращает URL скрипта.
Плагин может на этапе компиляции подменить этот рантайм: добавить в бандл код, который перехватывает вызов загрузки чанка, при ошибке (через .catch) делает паузу, добавляет к URL cache-bust и снова вызывает загрузку, и так до N раз.
webpack-retry-chunk-load-plugin делает именно это:
В хуке thisCompilation подключается к mainTemplate.hooks.localVars и дописывает в рантайм кусок кода;
Сохраняет ссылки на «старые» getChunkScriptFilename и ensureChunk;
Подменяет их: при вызове загрузки чанка к URL может добавляться query (cache-bust), а при ошибке загрузки возвращается Promise, который через setTimeout уменьшает счётчик попыток, обновляет query (например, retry-attempt=1) и снова вызывает ensureChunk;
Опции:
maxRetries, retryDelay (число или строка с функцией от номера попытки),
cacheBust (строка с функцией, возвращающей строку для query), chunks (в какие чанки внедрять),
lastResortScript (что выполнить в браузере, если все попытки провалились - например, редирект на страницу ошибки).
Пример конфига:
const { RetryChunkLoadPlugin } = require('webpack-retry-chunk-load-plugin'); plugins: [ new RetryChunkLoadPlugin({ cacheBust: `function() { return Date.now(); }`, retryDelay: 3000, maxRetries: 5, lastResortScript: "window.location.reload();", }), ],
Так мы получаем единую точку перезапроса для всех асинхронных чанков Webpack без правок кода приложения.
Rsbuild (сборщик из экосистемы Rspack/Web Infra) поддерживает плагин @rsbuild/plugin-assets-retry. Он работает на уровне тегов в HTML: перехватывает ошибки загрузки ресурсов (script, link, img и т.д.) и повторяет запрос, с опциями по количеству попыток, задержке, добавлению query при повторе. Может подставлять резервные домены (domain fallback). Runtime плагина по умолчанию инлайнится в HTML, чтобы не зависеть от загрузки ещё одного скрипта. Ограничение: в микрофрондах и кастомных шаблонах, где скрипты подключаются не через стандартную разметку, поведение может не сработать без дополнительной настройки.
Для любого стека (Vite, Rollup, CRA, Next и т.д.) можно не трогать сборку и реализовать retry только в коде. Два варианта:
Функция, которая вызывает import(url), при ошибке ждёт, при необходимости добавляет к URL cache-bust и повторяет вызов. В React так делают lazyWithRetry: внутри React.lazy() подставляют эту функцию вместо обычного import().
Либо без автоматического retry: при ChunkLoadError показываем экран с кнопкой «Обновить» / «Повторить загрузку», по нажатию — снова вызов динамического импорта или window.location.reload().
Оба подхода не требуют поддержки со стороны бандлера и работают везде, но покрывают только те места, где вы явно подключаете такую логику.
Браузер и его кэш. От чего зависит, пойдёт ли браузер за новым содержимым или достанет старую копию? От многих факторов: тип и версия браузера, платформа, пользовательские настройки, заголовки с сервера, прокси на пути. Есть ли над всем этим хозяйством какой-то контроль? Можно ли дать всем команду «забить на кэш» и дружно пойти за новым? Короткий ответ - нет. Но есть варианты: Cache-Control, Last-Modified (и ETag), переименование файла, переименование URL. В контексте чанков это значит следующее.
Браузер и промежуточные прокси решают, идти ли за новым содержимым или отдать из кэша, по множеству факторов: тип и версия браузера, платформа, настройки, HTTP-заголовки (Cache-Control, Last-Modified, ETag), переименование файла/URL и т.д. Жёстко приказать «всем игнорировать кэш» нельзя, но можно снизить вероятность ситуации «старый HTML просит уже несуществующий чанк»:
Не кэшировать (или кэшировать очень коротко) index.html и entry-point: например, Cache-Control: no-cache или max-age=0, чтобы при каждом заходе браузер хотя бы перепроверял главную страницу и получал актуальные имена чанков.
Чанки с хешем в имени (например, chunk-abc123.js) кэшировать надолго: файл с новым хешем = новый деплой, старые URL просто перестают запрашиваться после обновления HTML.
Тогда после деплоя пользователь скорее получит свежий HTML и новые пути к чанкам, а retry нужен в основном для сетевых сбоев и единичных сбоев CDN, а не для массового «все ходят со старым HTML».
Проблема: чанки (JS/CSS при code splitting) могут не загрузиться из-за сети, деплоя или кэша — пользователь видит ChunkLoadError;
Идея решения: перехватить ошибку загрузки и повторить запрос (с ограничением попыток и опционально cache-bust);
На статическом сайте это делается своей обвязкой вокруг тегов <script>/<link>;
В проекте с бандлером нужно либо встроить retry в рантайм загрузки чанков (где он есть), либо реализовать retry в коде приложения;
Webpack даёт такую возможность через рантайм и плагин webpack-retry-chunk-load-plugin;
Vite, Rollup, esbuild не дают единой точки подмены загрузки чанков в сгенерированном коде, поэтому там разумно использовать обёртки над import() и Error Boundary;
Rsbuild предлагает plugin-assets-retry на уровне тегов в HTML;
Настройка кэша (не кэшировать HTML, долго кэшировать хешированные чанки) уменьшает класс проблем «старый HTML — старые чанки» и дополняет retry.
webpack-retry-chunk-load-plugin - npm, пример конфига и опции
Исходный код плагина (Webpack) - как подмена getChunkScriptFilename и ensureChunk встраивает retry в рантайм
How to Solve the Chunk Load Error in JavaScript (Rollbar) - причины ChunkLoadError, рекомендации по кэшу и обходы (lazyWithRetry и т.д.)
Rsbuild: plugin-assets-retry - опции, domain fallback, ограничения (микрофронды, кастомные шаблоны)