- Opublikowano
Obsługa błędów ładowania chunków po deployu w Nuxt 3
- Autorzy
Objaw: losowe błędy 403 na plikach JS
Po deployu na produkcję użytkownicy zaczęli zgłaszać, że strona jest zepsuta. W konsoli widniało:
Failed to load resource: the server responded with a status of 403 ()
GET https://yoursite.com/_nuxt/chunk-abc123.js 403 (Forbidden)
Najdziwniejsze było to, że nie dotyczyło to wszystkich. Jednym działało normalnie, inni dostawali biały ekran. Odświeżenie strony zwykle pomagało.
Co się właściwie działo
Sekwencja wyglądała tak:
- Użytkownik wchodzi na stronę → przeglądarka cache'uje HTML
- Wypuszczamy nową wersję → nowe pliki chunków z nowymi hashami
- Użytkownik przechodzi dalej → przeglądarka próbuje pobrać stary chunk (z zacache'owanego HTML-a)
- Stary chunk już nie istnieje → CDN zwraca 403 albo 404
- Aplikacja się wywala → użytkownik widzi biały ekran albo rozjechane UI
Oś czasu:
Before deploy: chunk-abc123.js ✓
After deploy: chunk-def456.js ✓ (new hash)
chunk-abc123.js ✗ (deleted)
Dlaczego 403, a nie 404?
CDN-y często zwracają dla brakujących plików 403 zamiast 404. To praktyka bezpieczeństwa — nie zdradzaj, czy dany plik kiedykolwiek istniał. Tak czy inaczej efekt jest ten sam: przeglądarka nie jest w stanie pobrać chunka.
Dlaczego tylko część użytkowników?
- Świeże sesje → dostają nowy HTML z poprawnymi referencjami do chunków
- Sesje z zacache'owanym HTML-em → próbują pobrać stare chunki, których już nie ma
Unieważnianie cache'u na CDN nie jest natychmiastowe. Część serwerów edge ma już nowy HTML, część wciąż serwuje starą wersję. Użytkownicy trafiający na różne edge'e dostają różne doświadczenie.
Rozwiązanie: 5 linijek kodu
Nuxt 3 ma hook stworzony dokładnie do tego:
// plugins/chunk-error-handler.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:chunkError', () => {
window.location.reload()
})
})
I tyle. Gdy chunk nie daje się załadować, przeładuj stronę. Świeży request dostaje nowy HTML z poprawnymi referencjami do chunków.
Dlaczego to działa
- Wykryty błąd chunka → Nuxt odpala hook
app:chunkError - Plugin go łapie → wywołuje pełne przeładowanie strony
- Nowy request → pobiera z CDN aktualny HTML
- Nowe chunki się ładują → aplikacja działa poprawnie
Dla użytkowników, którzy jeszcze niczego nie kliknęli, przeładowanie jest niewidoczne. Dla tych w trakcie nawigacji to krótkie mrugnięcie — nieporównywalnie lepsze niż zepsuta aplikacja.
Wariant: łagodniejsza obsługa
Jeśli chcesz uniknąć gwałtownego przeładowania:
// plugins/chunk-error-handler.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:chunkError', ({ error }) => {
// Log for debugging
console.error('Chunk load failed:', error)
// Show a toast/notification
if (window.$toast) {
window.$toast.info('Updating to the latest version...')
}
// Delay reload slightly so user sees the message
setTimeout(() => {
window.location.reload()
}, 1000)
})
})
Albo zapisz stan przed przeładowaniem:
// plugins/chunk-error-handler.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:chunkError', () => {
// Preserve scroll position
sessionStorage.setItem(
'chunk-error-reload',
JSON.stringify({
scrollY: window.scrollY,
path: window.location.pathname,
})
)
window.location.reload()
})
})
// In your app.vue or layout
onMounted(() => {
const saved = sessionStorage.getItem('chunk-error-reload')
if (saved) {
sessionStorage.removeItem('chunk-error-reload')
const { scrollY } = JSON.parse(saved)
window.scrollTo(0, scrollY)
}
})
Zapobieganie problemowi
1. Dłuższa inwalidacja cache'u na CDN
Jeśli twój CDN to obsługuje, poczekaj z przełączeniem ruchu do czasu unieważnienia cache'u:
# vercel.json example
{
'headers':
[
{
'source': '/_nuxt/(.*)',
'headers': [{ 'key': 'Cache-Control', 'value': 'public, max-age=31536000, immutable' }],
},
],
}
Flaga immutable mówi przeglądarkom, że te pliki się nie zmienią. Przy deployu nowe pliki dostają nowe adresy (inne hashe).
2. Trzymaj stare chunki dłużej
Niektóre platformy pozwalają zachować artefakty ze starych buildów:
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
// Keep chunks from last 2 builds
payloadExtraction: true,
},
})
3. Strategia z service workerem
Przy agresywnym cache'owaniu użyj service workera z sensownym fallbackiem:
// service-worker.js (conceptual)
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/_nuxt/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return (
cached ||
fetch(event.request).catch(() => {
// Chunk missing, trigger reload
return new Response('', {
status: 302,
headers: { Location: '/' },
})
})
)
})
)
}
})
Bonus: błąd we wzorcu Vue, który przy okazji naprawiłem
Przy debugowaniu tej sprawy trafiłem na inny problem w naszym komponencie MegaMenu:
Źle: zagnieżdżone onUnmounted
// ❌ DON'T DO THIS
onMounted(() => {
const updateHeight = () => {
/* ... */
}
window.addEventListener('resize', updateHeight)
onUnmounted(() => {
// This doesn't work correctly!
window.removeEventListener('resize', updateHeight)
})
})
onUnmounted zagnieżdżone wewnątrz onMounted nie zachowuje się tak, jak byś tego oczekiwał. Wewnętrzny hook łapie niewłaściwy kontekst cyklu życia.
Dobrze: osobne hooki cyklu życia
// ✅ DO THIS
let updateHeight: (() => void) | null = null
onMounted(() => {
updateHeight = () => {
/* ... */
}
window.addEventListener('resize', updateHeight)
})
onUnmounted(() => {
if (updateHeight) {
window.removeEventListener('resize', updateHeight)
}
})
Trzymaj referencję do funkcji w zasięgu komponentu i użyj osobnych hooków cyklu życia. Sprzątanie poprawnie usuwa wtedy listener.
Jak przetestować poprawkę
Zasymuluj błąd chunka
// Temporarily add this to any component
onMounted(() => {
setTimeout(() => {
// @ts-ignore - testing only
import('./non-existent-chunk-abc123.js')
}, 2000)
})
Sprawdź raportowanie błędów
Jeśli masz error tracking (Sentry itp.), zobaczysz:
ChunkLoadError: Loading chunk "chunk-abc123" failed.
Po dodaniu pluginu liczba takich błędów powinna wyraźnie spaść — użytkownicy podnoszą się automatycznie, zanim błąd zdąży zostać zaraportowany.
Kompletny plugin
// plugins/chunk-error-handler.client.ts
export default defineNuxtPlugin((nuxtApp) => {
// Track if we've already reloaded to prevent loops
const hasReloaded = sessionStorage.getItem('chunk-reload-attempted')
nuxtApp.hook('app:chunkError', ({ error }) => {
// Log for monitoring
console.error('[Chunk Error]', error)
if (!hasReloaded) {
// Mark that we're reloading
sessionStorage.setItem('chunk-reload-attempted', 'true')
// Reload to get fresh chunks
window.location.reload()
} else {
// Already tried reloading, something else is wrong
sessionStorage.removeItem('chunk-reload-attempted')
console.error('Chunk error persists after reload')
}
})
// Clear the flag on successful load
nuxtApp.hook('app:mounted', () => {
sessionStorage.removeItem('chunk-reload-attempted')
})
})
Ta wersja chroni przed pętlą nieskończonych przeładowań — jeśli reload nie pomaga, zepsute jest coś innego.
Podsumowanie
Po każdym deployu część użytkowników zostaje ze starym HTML-em wskazującym na usunięte chunki. Lekarstwo:
- Dodaj plugin (5 linijek)
- Przeładuj przy błędzie chunka (automatyczne podniesienie się)
- Opcjonalnie: łagodniejszy UX (toast plus opóźniony reload)
Alternatywa to biały ekran u użytkownika do czasu, aż sam odświeży stronę. Ten plugin sprawia, że deploye stają się dla użytkowników niewidoczne.
Prawdziwa poprawka z produkcyjnej strony z agresywnym cache'owaniem na CDN. Po wdrożeniu tego pluginu zgłoszenia błędów spadły niemal do zera.