- Opublikowano
3842 linie stałych z przekierowaniami ukrywały się w naszym client bundle
- Autorzy
Odkrycie, którego nikt się nie spodziewał
Badaliśmy zupełnie niezwiązane problemy z pamięcią na naszej platformie e-commerce, kiedy otworzyliśmy bundle analyzer. Zakładka JavaScript pokazała coś dziwnego: trzy pliki ze stałymi, każdy eksportujący pojedynczy literał obiektu, razem odpowiadały za 316 KB client bundle'a.
To były mapy przekierowań. Tysiące par klucz-wartość mapujących stare URL-e na nowe, ładowane do przeglądarki każdego pojedynczego odwiedzającego, który wszedł na stronę. Przekierowania istniały, bo kilka miesięcy wcześniej migrowaliśmy strukturę URL-i — tłumaczyliśmy slugi kategorii, ścieżki artykułów w aktualnościach i naprawialiśmy błędy crawlowania zgłoszone przez Google Search Console. Same mapy robiły swoją robotę. Problemem było to, gdzie ją robiły.
Każde przekierowanie obsługiwał navigation guard Vue Routera — kawałek client-side middleware, który uruchamia się w przeglądarce po załadowaniu początkowego JavaScriptu, parsuje trasę, sprawdza ją w trzech gigantycznych tablicach lookupowych i wywołuje navigateTo() ze statusem 301. Przekierowanie działało, ale dopiero po tym, jak przeglądarka pobrała, sparsowała i wykonała 316 KB JavaScriptu, którego jedynym zadaniem było powiedzieć "powinieneś być gdzie indziej".
Anatomia tego balastu
Stałe z przekierowaniami mieszkały w trzech plikach w katalogu app/constants/:
| Plik | Linie | Do czego służył |
|---|---|---|
category-slug-redirects.constant.ts | 2101 | Tłumaczenia URL-i kategorii w 12 językach |
gsc-error-redirects.constant.ts | 968 | Poprawki URL-i oznaczonych jako błędy w Search Console |
news-slug-redirects.constant.ts | 357 | Mapowania starych slugów artykułów |
| Razem | 3426 | Statyczne mapowania przekierowań |
Do tego dochodził 416-linijkowy plik client middleware, który importował wszystkie trzy mapy, łączył je z dodatkowymi przekierowaniami opartymi na prefiksach, obsługą przypadków szczególnych i fallbackowym wywołaniem API. Łącznie: 3842 linie kodu wykonujące się w przeglądarce.
Sam plik ze slugami kategorii miał ponad dwa tysiące linii. Tak wyglądał typowy fragment — jedna kategoria, dwanaście języków:
export const CATEGORY_SLUG_REDIRECTS: Record<string, string> = {
"/en/kitchen/taps": "/en/kuchnia/baterie",
"/de/kuche/armaturen": "/de/kuchnia/baterie",
"/uk/kukhnya/zmishuvachi": "/uk/kuchnia/baterie",
"/ru/kuhnya/smesiteli": "/ru/kuchnia/baterie",
"/hu/konyha/csaptelepek": "/hu/kuchnia/baterie",
"/ro/bucatarie/baterii": "/ro/kuchnia/baterie",
"/fr/cuisine/robinets": "/fr/kuchnia/baterie",
"/sl/kuhinja/armature": "/sl/kuchnia/baterie",
"/es/cocina/griferia": "/es/kuchnia/baterie",
"/it/cucina/rubinetteria": "/it/kuchnia/baterie",
"/ar/kitchen/taps": "/ar/kuchnia/baterie",
"/pt/cozinha/torneiras": "/pt/kuchnia/baterie",
// ... repeat for every category in the product tree
};
Pomnóż ten wzorzec przez każdą podkategorię kuchenną (baterie, zlewozmywaki, zestawy zlewozmywak plus bateria, akcesoria, syfony, środki czystości), każdą podkategorię łazienkową (baterie, prysznice, wanny, umywalki, toalety, meble, akcesoria, kabiny prysznicowe, stelaże) i każdą podpodkategorię w środku. Potem dorzuć dwanaście języków do każdej z nich. Eksplozja kombinatoryczna była porażająca — i każdy jej bajt lądował u klienta.
Dlaczego do tego doszło
Stałe z przekierowaniami nie powstały jako jedna pomyłka na 3842 linie. Rosły przyrostowo, w wyniku trzech osobnych decyzji inżynierskich, z których każda z osobna miała sens.
Faza 1: migracja URL-i. Zmieniliśmy strukturę URL-i tak, żeby slugi we wszystkich lokalizacjach używały jednego kanonicznego języka. Zamiast /en/kitchen/taps i /de/kuche/armaturen wskazujących na osobne strony, obie ścieżki zaczęły przekierowywać na jedną, wspólną. Wymagało to wygenerowania wpisów przekierowań dla każdej kombinacji kategoria-język. Ktoś napisał skrypt, który wyciągnął stare slugi z bazy, wygenerował mapę przekierowań i wyeksportował ją jako stałą TypeScriptu. Działało natychmiast, bez żadnych zapytań do bazy w runtimie.
Faza 2: sprzątanie po Search Console. Google Search Console oznaczyło blisko tysiąc URL-i jako soft 404 albo błędy crawlowania. Były to stare adresy produktów, zniekształcone ścieżki z zewnętrznych linków i przestarzałe trasy z poprzedniej platformy. Najszybszą poprawką był kolejny plik ze stałymi — następne 968 linii wrzucone do bundle'a.
Faza 3: migracja slugów aktualności. Ten sam wzorzec. Stare URL-e artykułów musiały przekierowywać na nowe ścieżki. Kolejny plik ze stałymi, 357 linii.
Każde z tych rozszerzeń zajmowało niecałą godzinę. Nikt nie kwestionował podejścia, bo każdy plik z osobna wydawał się dostatecznie mały. Ale bundlery nie oceniają plików pojedynczo. Sklejają, minifikują i wysyłają. Trzy "małe" pliki zamieniły się w jeden 316-kilobajtowy kawałek JavaScriptu, który każdy odwiedzający pobierał przy każdym wejściu na stronę.
Prawdziwy koszt
Wydajność po stronie klienta
316 KB zminifikowanego JavaScriptu nie jest za darmo. Na średniopółkowym urządzeniu mobilnym parsowanie i kompilacja 316 KB JS-a zajmuje mniej więcej 150-300 ms. To 150-300 ms blokowania głównego wątku, zanim strona stanie się interaktywna — a mapy przekierowań nie robią absolutnie nic dla ogromnej większości odwiedzających, którzy wchodzą na poprawne URL-e.
Nasza analityka pokazała, że jakiekolwiek przekierowanie uruchamiało się na mniej niż 0,3% odsłon. Pozostałe 99,7% odwiedzających pobierało, parsowało i wykonywało 316 KB JavaScriptu, który robił jeden lookup w obiekcie, nie znajdował dopasowania i kończył pracę.
Wpływ na SEO
Przekierowania po stronie klienta są fundamentalnie czymś innym niż przekierowania po stronie serwera i dla wyszukiwarek ta różnica ma znaczenie.
Kiedy serwer odpowiada statusem 301, przekierowanie następuje zanim wyśle się jakikolwiek HTML. Crawler dostaje nagłówek przekierowania, podąża za nim i indeksuje docelowy URL. Czysto, szybko, bez dwuznaczności.
Kiedy przekierowanie dzieje się w JavaScripcie po stronie klienta, serwer najpierw wysyła odpowiedź 200 OK z pełną stroną HTML wraz z całym JavaScriptem. Przeglądarka pobiera wszystko, wykonuje aplikację Vue, uruchamia navigation guard i dopiero wtedy inicjuje nową nawigację. Googlebot radzi sobie z przekierowaniami w JavaScripcie, ale jest opóźnienie — czasem dni albo tygodnie — zanim przekierowanie zostanie rozpoznane, a kanoniczny URL zaktualizowany.
Co gorsza, przekierowania client-side potrafią tworzyć chwilowe sygnały duplicate content. Serwer odpowiedział 200 OK i wyrenderowaną stroną dla starego URL-a. Dopóki JavaScript nie wykona przekierowania, ten stary URL ma własną treść, własny tag title, własny meta description. Wyszukiwarki przetwarzające początkową odpowiedź HTML widzą poprawną stronę, a nie przekierowanie.
Problem z navigateTo()
W podejściu client-side kryła się jeszcze subtelniejsza wpadka. Navigation guard używał funkcji navigateTo() z Vue Routera z parametrem redirectCode: 301:
if (staticSlugTarget) {
return navigateTo(staticSlugTarget, { redirectCode: 301 })
}
Wygląda poprawnie. Tyle że navigateTo() z kodem przekierowania generuje faktyczne HTTP 301 wyłącznie podczas server-side renderingu (SSR). Przy nawigacjach po stronie klienta — użytkownik klika link, wpisuje URL w pasku przeglądarki po załadowaniu aplikacji — kod przekierowania jest po cichu ignorowany. Nawigacja się odbywa, ale jest to zmiana trasy po stronie klienta, a nie przekierowanie HTTP. Żaden nagłówek 301 nigdy nie zostaje wysłany.
Oznaczało to, że zachowanie przekierowań było niespójne. Jeśli Googlebot renderował stronę przez SSR, mógł zobaczyć prawidłowe 301. Jeśli poszedł za linkiem wewnątrz już załadowanej aplikacji SPA, widział nawigację po stronie klienta. Dwa różne zachowania dla tej samej reguły przekierowania, zależnie od tego, jak dotarło się na stronę.
Rozwiązanie
Przeniesienie przekierowań na serwer wyeliminowało wszystkie problemy naraz: zero JavaScriptu po stronie klienta, prawidłowe odpowiedzi HTTP 301, natychmiastowe rozstrzygnięcie przekierowania jeszcze przed wysłaniem jakiegokolwiek HTML-a i żadnej niespójności między SSR a nawigacją client-side.
Migracja rozbiła logikę przekierowań na dwie warstwy.
Warstwa 1: server middleware do przekierowań wzorcowych
Część przekierowań nie może mieszkać w bazie, bo dopasowuje prefiksy, a nie dokładne ścieżki. URL-e produktów, URL-e kolekcji i kilka przypadków szczególnych obejmuje nieskończenie wiele kombinacji slugów doklejanych do stałego prefiksu. Te zostały kodem, ale przeniosły się z client middleware do server middleware:
// server/middleware/0003-redirects.ts
import { sendRedirect, getRequestURL } from 'h3'
const OLD_ROUTE_PREFIXES: Record<string, string> = {
'/en/product': '/en/produkt',
'/fr/produit': '/fr/produkt',
'/it/prodotto': '/it/produkt',
// ... one entry per language
}
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const path = url.pathname
const search = url.search
// Skip non-page requests
if (path.startsWith('/api/') || path.startsWith('/_nuxt/') ||
path.includes('.')) return
// Trailing slash normalization
if (path !== '/' && path.endsWith('/')) {
return sendRedirect(event, path.slice(0, -1) + search, 301)
}
// Prefix-based redirects
for (const [oldPrefix, newPrefix] of Object.entries(OLD_ROUTE_PREFIXES)) {
if (path === oldPrefix || path.startsWith(oldPrefix + '/')) {
return sendRedirect(event, newPrefix + path.slice(oldPrefix.length) + search, 301)
}
}
})
Kluczowa różnica: sendRedirect() z h3 zwraca prawdziwą odpowiedź HTTP 301. Żaden HTML nie zostaje wysłany. Żaden JavaScript nie jest ładowany. Przeglądarka (albo crawler) dostaje nagłówek Location i od razu za nim podąża. Przekierowanie kończy się na serwerze w mniej niż 5 ms, w porównaniu z ponad 500 ms w podejściu client-side (pobranie JS-a, parsowanie, wykonanie, nawigacja).
Warstwa 2: lookup w bazie dla przekierowań exact-match
Wszystkie 3426 wpisów ze stałych — slugi kategorii, slugi aktualności, poprawki z Search Console — miało jedną wspólną cechę: były dopasowaniami dokładnej ścieżki. Żadnych wildcardów, żadnych prefiksów, żadnego pattern matchingu. Po prostu "jeśli ścieżka to X, przekieruj na Y". To jest lookup w bazie danych.
Dodaliśmy do schematu tabelę Redirect:
model Redirect {
from String @id
to String
lastUsed DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([from])
}
Pole from jest kluczem głównym — każde źródło przekierowania jest z definicji unikalne. Pole lastUsed śledzi moment ostatniego uruchomienia danego przekierowania, dzięki czemu z czasem możemy wyłapywać martwe wpisy i je sprzątać.
Server middleware odpytuje tę tabelę w ostateczności, po sprawdzeniu wszystkich przekierowań prefiksowych i przypadków szczególnych:
// DB lookup -- last resort
try {
const result = await prisma.redirect.findFirst({
where: { from: path },
select: { to: true },
})
if (result?.to) {
return sendRedirect(event, result.to, 301)
}
} catch {
// DB unavailable -- continue to Vue Router (renders 404)
}
Jeśli baza jest nieosiągalna, middleware po cichu leci dalej. Odwiedzający zobaczy stronę 404 zamiast utknąć na wiecznym ekranie ładowania — łagodna degradacja, która przy podejściu client-side była nieosiągalna, bo nieudane wywołanie $fetch do API przekierowań rzucało błędem w aplikacji Vue.
Edge case z dekodowaniem URL-i
Jeden szczegół ugryzł nas w trakcie testów: ścieżki zakodowane w URL-u. Przeglądarka odwiedzająca arabską trasę w rodzaju /%D8%AD%D9%85%D8%A7%D9%85 wysyła formę zakodowaną, ale baza przechowuje formę zdekodowaną. Middleware radzi sobie z tym, próbując obu lookupów:
let decodedPath = path
try { decodedPath = decodeURIComponent(path) } catch { decodedPath = path }
const result = await prisma.redirect.findFirst({
where: decodedPath !== path
? { from: { in: [path, decodedPath] } }
: { from: path },
select: { to: true },
})
To jedno findFirst z klauzulą in pokrywa jednym zapytaniem zarówno formę zakodowaną, jak i zdekodowaną. Bez tego każde arabskie, ukraińskie i rosyjskie przekierowanie po cichu by nie zadziałało.
Migracja
Sama migracja zmieściła się w jednym commicie: 142 linie dodane, 3842 usunięte.
| Zmiana | Linie |
|---|---|
Usunięty category-slug-redirects.constant.ts | -2101 |
Usunięty gsc-error-redirects.constant.ts | -968 |
Usunięty news-slug-redirects.constant.ts | -357 |
Usunięty 001-redirects.global.ts (client middleware) | -416 |
Dodany 0003-redirects.ts (server middleware) | +141 |
Dodane pole lastUsed w schemacie Redirect | +1 |
| Bilans | -3700 |
3426 wpisów przekierowań z plików ze stałymi trafiło do tabeli Redirect w bazie. Przekierowania prefiksowe (produkty, kolekcje) i przypadki szczególne (kody QR, marketingowe skrócone URL-e landingów) zostały kodem w server middleware — mniej więcej 50 wpisów dopasowujących wzorce zamiast dokładnych ścieżek.
Dzień przed główną migracją posprzątaliśmy też 36 zduplikowanych wpisów przekierowań, zadeklarowanych jednocześnie w statycznej mapie i w przekierowaniach prefiksowych. Ta sama ścieżka była sprawdzana dwa razy — raz jako dokładne dopasowanie, raz jako prefiks. Duplikaty były nieszkodliwe, ale myliły każdego, kto czytał ten kod.
Wyniki
| Metryka | Przed | Po |
|---|---|---|
| Rozmiar client bundle'a (kod przekierowań) | 316 KB | 0 KB |
| Linie kodu przekierowań po stronie klienta | 3842 | 0 |
| Linie kodu przekierowań po stronie serwera | 0 | 141 |
| Typ odpowiedzi przekierowania | Mieszany (SSR: 301, klient: zmiana trasy) | Zawsze HTTP 301 |
| Czas przekierowania (serwer) | brak (client-side) | poniżej 5 ms |
| Czas przekierowania (klient, stare) | ponad 500 ms (pobranie + parsowanie + wykonanie) | brak |
| Wpisy przekierowań w bazie | Częściowe (tylko fallback API) | 3426 |
| Śledzenie martwych przekierowań | Brak | Automatyczne przez lastUsed |
Najbardziej znacząca liczba to 316 KB mniej JavaScriptu po stronie klienta. To 316 KB, których każdy odwiedzający już nie pobiera, nie parsuje i nie wykonuje. Na łączu 3G to mniej więcej 2-3 sekundy pobierania mniej. Na szybkim łączu to nadal 150-300 ms parsowania i kompilacji zdjęte z głównego wątku.
Efekt w Lighthouse
Po deployu nasze wyniki wydajności w Lighthouse mierzalnie się poprawiły. Total Blocking Time (TBT) spadł, bo 316 KB synchronicznego JavaScriptu nie musiało już zostać sparsowane, zanim strona stanie się interaktywna. First Contentful Paint nie zmienił się (był już renderowany po stronie serwera), ale Time to Interactive poprawił się dokładnie o tyle, ile przeglądarka wcześniej traciła na parsowanie stałych z przekierowaniami.
Crawl budget
Dla wielojęzycznego sklepu e-commerce z dziesiątkami tysięcy stron produktowych crawl budget to realny problem. Każde żądanie Googlebota do URL-a przekierowywanego po stronie klienta zjada dwa sloty crawl budgetu: jeden na stronę początkową (która zwraca 200 OK z pełną aplikacją) i jeden na URL docelowy po wykonaniu JavaScriptu. Serwerowe 301 zjada jeden slot. Przy blisko tysiącu błędnych URL-i z Search Console przekierowywanych prawidłowo na serwerze efektywnie podwoiliśmy wydajność crawlowania tych ścieżek.
Co powinniśmy byli zrobić od początku
Odpowiedź z perspektywy czasu jest oczywista: przekierowania to sprawa serwera. Nigdy nie powinny były znaleźć się w kodzie client-side.
Wylądowały tam dla wygody. Navigation guard Vue Routera i tak już obsługiwał zmiany tras. Dodanie tablicy lookupowej to był jednolinijkowy import. Nikt nie musiał ruszać konfiguracji serwera, stawiać middleware ani pisać zapytań do bazy. Ścieżka najmniejszego oporu prowadziła prosto do client bundle'a.
Tego wzorca warto wypatrywać w każdej aplikacji single-page. Logika routingu w SPA ma tendencję do zbierania odpowiedzialności, które należą do serwera: przekierowania, sprawdzanie uwierzytelnienia, wykrywanie języka, przypisywanie do testów A/B. Każde rozszerzenie wydaje się małe. Każde zwiększa payload JavaScriptu. I w przeciwieństwie do kodu komponentów logika routingu wykonuje się przy każdym wejściu na stronę, a nie tylko na stronach korzystających z konkretnego komponentu. Tree-shaking Ci nie pomoże, bo navigation guard importuje wszystko bezwarunkowo.
Pułapka przyrostowego wzrostu
Najgroźniejszy był właśnie przyrostowy charakter tego wzrostu. Nikt nie wypuścił 3842 linii stałych z przekierowaniami w jednym commicie. Rosło to miesiącami:
- Migracja URL-i dodaje 200 przekierowań. Wygląda w porządku.
- Kolejne kategorie zostają przetłumaczone. Następne 500 linii. Nadal do ogarnięcia.
- Podkategorie zostają przetłumaczone. Kolejne 800 linii. Plik robi się duży, ale działa.
- Sprzątanie po Search Console dodaje drugi plik. Blisko 1000 linii.
- Migracja aktualności dodaje trzeci plik.
W żadnym momencie nikt nie usiadł i nie zdecydował: "wyślijmy 316 KB map przekierowań do każdej przeglądarki". To się stało, jedna rozsądna decyzja po drugiej.
Luka w monitoringu
Nie mieliśmy w CI monitoringu rozmiaru bundle'a. Gdybyśmy mieli GitHub Action porównujący rozmiar bundle'a każdego PR-a z branchem bazowym, pierwszy duży plik ze stałymi wywołałby dyskusję na review. Prosty próg — "alarmuj, jeśli PR zwiększa client JS o więcej niż 10 KB" — wyłapałby to, zanim trafiło na produkcję.
Kiedy stosować które podejście
Server middleware (kod) do:
- Przekierowań prefiksowych, gdzie końcówka jest dynamiczna (slugi produktów, slugi artykułów)
- Dopasowywania wzorców, którego nie da się wyrazić klauzulą
WHEREw bazie - Przekierowań potrzebujących dostępu do kontekstu żądania (parametry query, nagłówki)
- Małych, stabilnych zbiorów przekierowań, które rzadko się zmieniają
Tabela w bazie do:
- Dużych wolumenów przekierowań exact-match
- Przekierowań generowanych albo importowanych hurtowo
- Przekierowań, które często się zmieniają (redakcyjne zmiany URL-i, eksperymenty SEO)
- Sytuacji, gdy potrzebujesz śledzenia (ostatnie użycie, licznik trafień) na potrzeby sprzątania
Nigdy w JavaScripcie po stronie klienta do:
- Jakiegokolwiek przekierowania, nigdy
Ten ostatni punkt to nie hiperbola. Nie ma scenariusza, w którym przekierowanie client-side jest lepsze od serwerowego. Przekierowania po stronie klienta są wolniejsze (najpierw musi się załadować cały bundle JS), mniej niezawodne (JavaScript może się nie wykonać), gorsze dla SEO (crawlery mogą nie wykonywać JS-a albo zobaczyć treść sprzed przekierowania) i zwiększają payload dla każdego odwiedzającego, niezależnie od tego, czy w ogóle zostanie przekierowany.
Dywidenda ze sprzątania
Pole lastUsed w tabeli Redirect już się nam odpłaca. Trzy dni po deployu odpytaliśmy bazę o przekierowania, które od migracji ani razu się nie uruchomiły:
SELECT COUNT(*) FROM "Redirect"
WHERE "lastUsed" < '2026-04-03';
Ponad 40% wpisów nie zostało trafionych nawet raz. To adresy, które Google już zdeindeksowało, do których nie linkuje żadna zewnętrzna strona i których nie odwiedził żaden użytkownik. Są martwym balastem w bazie — ale przynajmniej martwy balast w bazie jest darmowy. Martwy balast w client bundle kosztuje wydajność przy każdym pojedynczym wejściu na stronę.
Planujemy comiesięczne sprzątanie: każde przekierowanie nieuruchomione od 90 dni trafia do archiwum. Baza pozostaje szczupła, a my mamy jasny obraz tego, które stare URL-e nadal generują ruch.
Podsumowanie
Trzy pliki ze stałymi. 3842 linie. 316 KB JavaScriptu wysyłane każdemu odwiedzającemu. Zero wartości dla 99,7% wejść. Naprawa zajęła 141 linii server middleware i jedną tabelę w bazie.
Morał nie dotyczy przekierowań jako takich. Dotyczy grawitacji wygody. Kod client-side łatwo się pisze, łatwo testuje, łatwo wdraża. Kod serwerowy wymaga ruszenia middleware, konfiguracji bazy, eleganckiej obsługi awarii. Ścieżka najmniejszego oporu prowadzi do client bundle'a, client bundle prowadzi do przeglądarek Twoich użytkowników, a przeglądarki Twoich użytkowników mają ograniczoną cierpliwość.
Każda linia JavaScriptu, którą wysyłasz, jest obietnicą złożoną każdemu odwiedzającemu: "to jest Ci potrzebne". Trzy tysiące osiemset czterdzieści dwie linie stałych z przekierowaniami były obietnicą, której nigdy nie powinniśmy byli składać.