GhostPizza is a pizzeria in Oborniki. I look after its website, ghost-pizza.pl, with the menu, a gallery and a panel for editing content. The previous version ran on Laravel with a React front end and was slow on phones: mobile Lighthouse gave the pages 60–70 for performance, and the gallery’s largest contentful paint (LCP) could take about 15 seconds. On 18 October I switched it to the new version, which scores 100 for performance on all three public pages, with the gallery’s LCP down to about 1.2 seconds. Check it yourself in PageSpeed Insights.
The premise: no JavaScript where it is not needed
I started by looking at what JavaScript actually did on the public pages. Almost nothing, it turned out: every useState and useEffect was there to fetch data, and the only layout logic was checking the window width to pick the mobile header photo. The content was missing from the server’s HTML, which hurt both speed and search.
The new version renders the home page, the menu and the gallery on the server with hono/jsx, with the components ported one to one. All of a page’s data loads on the server in one pass. Plain HTML replaced the JavaScript: <picture> with a media attribute picks the mobile photo, the few icons are inline SVG, and the Google map loads lazily. The pages ship no script of their own, and a Lighthouse test in the repository makes sure it stays that way.
The admin panel is a separate SvelteKit app. JavaScript makes sense there, because it is a tool for work, not a page for customers. Accounts moved over from the old version together with their passwords: the new one verifies the old bcrypt hashes, so nobody had to reset anything.
Rendered pages kept in memory
The content changes rarely, once every few months, but the owner can still edit it in the panel at any time. So every rendered page goes into the process’s memory, in two forms at once: plain and gzipped. A cache hit is one map lookup, with no rendering and no compression per request. Every write in the panel clears the whole cache.
There is a subtlety. A render that started just before a write in the panel could finish just after it and put stale content into the cache. So the cache has a version counter: a render remembers the version it started at and does not store its result if someone cleared the cache in the meantime.
The whole mechanism fits in under twenty lines:
export function createHtmlCache(): HtmlCache {
const pages = new Map<string, CachedBody>();
let version = 0;
return {
get: (key) => pages.get(key),
// a render started before a panel write does not store old content
set: (key, body, renderedAt) => {
if (renderedAt === version) pages.set(key, body);
},
invalidateAll: () => {
pages.clear();
version += 1;
},
get version() {
return version;
},
};
}
This works because the app runs a single replica. With several replicas each would hold its own cache and a shared invalidation would be needed. I wrote that down in the decision record, so nobody scales it out blindly.
What I learned
- Before rewriting, check what the code really does. All of the public pages’ JavaScript could be replaced with HTML, because its only job was fetching data the server could have put in from the start.
- Write the limits down with the decision. An in-memory cache is simple and fast, but it only works with one replica. Without that note, the first attempt to scale out would have ended in stale pages.