GhostPizza: strona restauracji z Lighthouse 100 i bez JavaScriptu na publicznych stronach

Przepisałem stronę pizzerii z Laravela na Bun i Hono tak, żeby właściciel nie zauważył żadnej zmiany wizualnej, a telefon klienta zauważył wszystkie.

· 3 min czytania

Spisane w listopadzie 2026 na podstawie notatek z tamtego czasu.

GhostPizza to pizzeria w Obornikach, dla której utrzymuję stronę ghost-pizza.pl z menu, galerią i panelem do edycji treści. Poprzednia wersja działała na Laravelu z frontendem w React i była wolna na telefonach: w Lighthouse na mobile strony dostawały 60–70 punktów za wydajność, a galeria potrafiła mieć największe malowanie treści (LCP) po około 15 sekundach. 18 października przełączyłem ją na nową wersję, która na wszystkich trzech publicznych stronach ma 100 punktów za wydajność, a LCP galerii spadło do około 1,2 sekundy. Sprawdź sam w PageSpeed Insights.

Założenie: zero JavaScriptu tam, gdzie nie jest potrzebny

Zacząłem od przejrzenia, co właściwie robił JavaScript na publicznych stronach. Okazało się, że prawie nic: każdy useState i useEffect służył do pobrania danych, a jedyna logika układu to sprawdzenie szerokości okna, żeby wybrać zdjęcie w nagłówku na telefon. Treści nie było w HTML-u z serwera, co szkodziło i szybkości, i wyszukiwarkom.

Nowa wersja renderuje strony główną, menu i galerię na serwerze przez hono/jsx, z komponentami przeniesionymi jeden do jednego. Wszystkie dane strony ładują się na serwerze w jednym przebiegu. JavaScript zastąpił zwykły HTML: <picture> z atrybutem media wybiera zdjęcie na telefon, kilka ikon to wbudowane SVG, a mapa Google ładuje się leniwie. Strony nie wysyłają żadnego własnego skryptu, a test Lighthouse w repozytorium pilnuje, żeby tak zostało.

Panel administracyjny to osobna aplikacja SvelteKit. Tam JavaScript ma sens, bo to narzędzie do pracy, a nie strona dla klientów. Konta przeszły ze starej wersji razem z hasłami, bo nowa weryfikuje stare skróty bcrypt, więc nikt nie musiał niczego resetować.

Wyrenderowane strony w pamięci

Treść zmienia się rzadko, raz na kilka miesięcy, ale właściciel może ją w każdej chwili poprawić sam w panelu. Dlatego każda wyrenderowana strona trafia do pamięci procesu, od razu w dwóch wersjach: zwykłej i skompresowanej gzipem. Trafienie w cache to jedno wyszukanie w mapie, bez renderowania i bez kompresji przy każdym żądaniu. Każdy zapis w panelu czyści cały cache.

Jest w tym subtelność. Render, który zaczął się tuż przed zapisem w panelu, mógłby skończyć się już po nim i włożyć do cache starą treść. Dlatego cache ma licznik wersji: render zapamiętuje wersję z chwili startu i nie zapisuje wyniku, jeśli w międzyczasie ktoś wyczyścił cache.

Cały mechanizm to kilkanaście linii:

export function createHtmlCache(): HtmlCache {
  const pages = new Map<string, CachedBody>();
  let version = 0;
  return {
    get: (key) => pages.get(key),
    // render zaczęty przed zapisem w panelu nie odkłada starej treści
    set: (key, body, renderedAt) => {
      if (renderedAt === version) pages.set(key, body);
    },
    invalidateAll: () => {
      pages.clear();
      version += 1;
    },
    get version() {
      return version;
    },
  };
}

To rozwiązanie działa, bo aplikacja ma jedną replikę. Przy kilku replikach każda trzymałaby własny cache i potrzebne byłoby wspólne unieważnianie. Zapisałem to w dokumencie decyzji, żeby nikt nie skalował jej w ciemno.

Czego się nauczyłem

  • Zanim przepiszesz, sprawdź, co kod naprawdę robi. Cały JavaScript publicznych stron dało się zastąpić HTML-em, bo jego jedynym zadaniem było pobieranie danych, które serwer mógł wstawić od razu.
  • Zapisuj ograniczenia razem z decyzją. Cache w pamięci jest prosty i szybki, ale działa tylko przy jednej replice. Bez tej notatki pierwsza próba skalowania skończyłaby się nieaktualnymi stronami.

Wszystkie wpisy