Kamil Owczarek
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:

  1. Użytkownik wchodzi na stronę → przeglądarka cache'uje HTML
  2. Wypuszczamy nową wersję → nowe pliki chunków z nowymi hashami
  3. Użytkownik przechodzi dalej → przeglądarka próbuje pobrać stary chunk (z zacache'owanego HTML-a)
  4. Stary chunk już nie istnieje → CDN zwraca 403 albo 404
  5. Aplikacja się wywala → użytkownik widzi biały ekran albo rozjechane UI

Oś czasu:

Before deploy: chunk-abc123.jsAfter 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

  1. Wykryty błąd chunka → Nuxt odpala hook app:chunkError
  2. Plugin go łapie → wywołuje pełne przeładowanie strony
  3. Nowy request → pobiera z CDN aktualny HTML
  4. 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:

  1. Dodaj plugin (5 linijek)
  2. Przeładuj przy błędzie chunka (automatyczne podniesienie się)
  3. 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.