All chapters Part IV · Building
Chapter 17

Frontend and Web Development

The frontend is where users experience every other decision you've made. It's also where a small number of specific mistakes, a rendering model that doesn't match your content, an uncached font, a service worker that caches an error page, cause damage far out of proportion to their size.


The concept

Modern web frontend is a set of choices about where and when your HTML is produced, and everything else follows from that.

The rendering models

ModelHTML producedBest forCost
Static (SSG)At build timeMarketing, docs, blogs, contentRebuild to change content
Server-rendered (SSR)Per request, on the serverPersonalised, SEO-critical, fresh dataServer cost and latency
Client-rendered (CSR/SPA)In the browserApp-like tools behind a loginPoor SEO, slow first paint
Incremental (ISR)Static, revalidated periodicallyContent that changes sometimesComplexity; stale windows
Islands / partial hydrationStatic + interactive pocketsContent sites with some interactivityFramework-specific

Choose by content freshness and SEO need, not by fashion:

   Does it need to rank in search?
        │
   YES ─┴─ NO ──────────────────▶ CSR is fine (app behind a login)
    │
   Does the content change per user?
        │
   NO ──┴── YES
    │         │
  STATIC    SSR (or static shell + client fetch)

The default mistake is a SPA for a content site. You get slow first paint, weak SEO, and a large JS bundle, for a page that could have been static HTML.

Performance: the three numbers that matter

Google's Core Web Vitals are the industry standard and correlate with real user behaviour:

MetricMeasuresGoodUsual cause of failure
LCP (Largest Contentful Paint)When the main content appears< 2.5sHuge hero image, render-blocking CSS/JS
INP (Interaction to Next Paint)Responsiveness to input< 200msHeavy JS on the main thread
CLS (Cumulative Layout Shift)Visual stability< 0.1Images without dimensions, late-loading fonts/ads

The highest-leverage fixes, in order:

  1. Images. Usually 60-80% of page weight. Modern format (WebP/AVIF), correct dimensions, loading="lazy" below the fold, and explicit width/height to prevent shift. A photographic PNG is typically 10-20× larger than it needs to be.
  2. Fonts. font-display: swap, preload only the critical face, subset if you can. Late fonts cause both invisible text and layout shift.
  3. JavaScript. Ship less. Code-split by route. Anything not needed for first paint should not be on the critical path.
  4. Third-party scripts. Each one is someone else's performance problem running on your page. Audit them; load async/defer.

Caching, and the one that can't be undone

Caching is the most powerful and most dangerous tool here.

AssetHeaderWhy
Hashed static assets (app.a1b2c3.js)max-age=31536000, immutableContent-addressed; safe forever
HTMLno-cache (revalidate)Must reflect deploys
API responsesShort max-age or stale-while-revalidateFreshness vs load
Service worker (sw.js)no-store⚠️ See below

💀 Never cache your service worker. A cached service worker cannot update itself. Users are stuck on an old version of your app permanently, and you have no channel to reach them. It is one of the few genuinely unrecoverable mistakes in web deployment.

Service workers in general are powerful and easy to get wrong. Two rules beyond the above:

State management

Most apps need far less than they think:

 Server state (data from your API)   → a query library (TanStack Query, SWR, RTK Query)
 URL state (filters, page, tab)      → the URL. Shareable, back-button-correct.
 Form state                          → a form library or plain local state
 Local UI state (open/closed)        → component state
 Truly global client state           → context, or a small store (Zustand, Jotai)

Most "global state" is actually server state, and treating it as such, with caching, revalidation and loading states handled by a library, removes the majority of state-management complexity.

Put filters and tabs in the URL. It makes state shareable, bookmarkable, and correct with the back button, for free.

Progressive enhancement and failure

Assume the network is bad and things fail:


📐 Best practice

Match the rendering model to the content, not to the framework's default.

Set image dimensions always. It's the cheapest CLS fix there is.

Ship modern image formats and lazy-load below the fold.

Preload only critical fonts; use font-display: swap.

Put shareable state in the URL.

Use a query library for server state. It handles caching, dedup, revalidation and loading states you'd otherwise hand-roll badly.

Set cache headers deliberately, especially no-store on the service worker.

Never cache non-ok responses in a service worker.

Test on a real mid-range phone on throttled network. Your dev machine lies about performance.

Run Lighthouse in CI so regressions fail the build rather than being discovered by users.


💀 Common mistakes

A SPA for a content site. Slow, unindexable, heavy.

PNG for photographs. 10-20× the necessary bytes.

Images without dimensions. Guaranteed layout shift.

Caching the service worker. Unrecoverable, users stranded on an old build.

Caching error responses. A bad deploy becomes permanent for anyone who visited during it.

Not bumping the cache key on release. Users keep the old assets and your fix never reaches them.

Hand-rolled data fetching in useEffect, with no caching, dedup or error handling.

Global state for everything. Most of it is server state or URL state.

Testing only on your machine. A fast laptop on fibre hides everything.

Fetching in a waterfall, each request waiting on the last. Parallelise or move to the server.

Blocking render on non-critical JS.


The professional workflow

 1. CHOOSE THE RENDERING MODEL from content freshness + SEO need

 2. SET A PERFORMANCE BUDGET
    e.g. LCP < 2.5s on a mid-range phone, JS < 200KB gzipped

 3. IMAGE PIPELINE FIRST
    modern format · responsive sizes · explicit dimensions · lazy below fold

 4. FONTS
    preload critical only · font-display: swap · subset

 5. STATE
    server → query library · shareable → URL · rest → local

 6. CACHE HEADERS, deliberately
    hashed assets immutable · HTML no-cache · sw.js NO-STORE

 7. FAILURE STATES
    loading · error boundaries · offline · form-safe retries

 8. MEASURE on a real device, throttled

 9. LIGHTHOUSE IN CI — fail the build on regression

Tools, websites & costs

NeedToolCost
FrameworksNext.js, Astro, Remix, SvelteKit$0
HostingVercel, Netlify, Cloudflare Pages$0 tiers
Server stateTanStack Query, SWR$0
Client stateZustand, Jotai$0
FormsReact Hook Form, Zod$0
Image optimisationSquoosh, sharp, cwebp, framework built-ins$0
Performance testingLighthouse, WebPageTest, PageSpeed Insights$0
Real-user monitoringVercel Analytics, SpeedCurve$0-$$$
Bundle analysis@next/bundle-analyzer, Bundlephobia$0
Accessibilityaxe DevTools, Lighthouse$0

Alternatives & trade-offs

Framework vs vanilla. Frameworks give routing, state, components and, critically, automatic escaping. Vanilla gives zero build tooling and full transparency, and is defensible for small content-heavy sites. You must re-add escaping and structure yourself (Chapter 15).

CSS approach. Tailwind (fast, consistent, verbose markup) vs CSS Modules (scoped, familiar) vs CSS-in-JS (dynamic, runtime cost) vs plain CSS with custom properties (zero tooling). All are fine; consistency matters more than choice.

Component library vs custom. Libraries give accessible, tested primitives, use them for dropdowns, dialogs, comboboxes and date pickers, where accessibility is genuinely hard. Go custom on the surfaces that carry your identity.

SPA vs MPA. SPAs give app-like transitions and cost SEO and initial load. MPAs are simpler and now nearly as smooth with view transitions.

Monolith frontend vs micro-frontends. Micro-frontends solve an organisational problem. Solo, they're pure cost.


Checklist


📓 Case Study: the caching mistakes that are hardest to undo

Project: SOLIS. The app shipped as a static web app wrapped for iOS, with a service worker for offline support, and the service worker produced two of the longest-lived defects in the project.

The hosting config got one thing exactly right:

{ "source": "/sw.js",
  "headers": [{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }] }

That single line prevents an unrecoverable failure. A cached service worker cannot update itself; users would be permanently stranded on an old build with no way to reach them. It's three words of configuration standing between a normal product and a stranded user base.

⚠️ Deviation 1: the service worker cached error responses, flagged on day 1, still open on day 24.

The fetch handler cached every resolved response without checking res.ok:

fetch(req).then(res => {
  const copy = res.clone();
  caches.open(CACHE).then(c => c.put(req, copy));   // ← no res.ok check
  return res;
})

A day-one review described the consequence precisely:

A botched deploy causes index.html or the content bundle to be served as a 404 error page. All users who load the app during that window get the error page cached and continue receiving it indefinitely, even after the deploy is fixed.

The fix is one conditional:

fetch(req).then(res => {
  if (res.ok) {                                    // ← the whole fix
    const copy = res.clone();
    caches.open(CACHE).then(c => c.put(req, copy));
  }
  return res;
})

It was never applied. A one-line fix to an unrecoverable failure mode remained open for the entire project, not because it was hard, but because the finding lived in a chat message rather than a tracked list (Chapter 30).

A second, related defect from the same review: the cross-origin handler's .catch(() => r) resolved to undefined when there was no cache hit and the network failed, handing respondWith a resolved-undefined instead of a response. Also still open.

One thing handled correctly: the cache name was bumped on release (solis-v1solis-v2) specifically so clients would drop stale assets after a large content change. That's the discipline that stops "my fix didn't reach users."

⚠️ Deviation 2: images were optimised individually rather than as a pipeline.

The landing page hero was a 1.9 MB PNG, a photographic image in a lossless format. Converting to JPEG took it to 117 KB: 16× smaller, visually identical.

sips -s format jpeg -s formatOptions 80 arena.png --out arena.jpg
# or: cwebp -q 80 arena.png -o arena.webp

But the fix stopped at one file. The same asset folder still contained a 1.9 MB logo, two ~1.8 MB scene images, and a 1.5 MB avatar.

📐 Optimising one image is a fix; optimising the pipeline is a solution. Add a pre-deploy check that fails on any image over ~300 KB. Otherwise you fix the one you noticed and ship the rest.

⚠️ Deviation 3: the app bundled 525 MB of images.

Bundling art locally was a deliberate, defensible choice, it fixed genuine reliability problems with hotlinked images and made the app work fully offline. But 525 MB is an install-conversion problem before it's a cost problem: slow downloads, cellular-download limits, and abandonment on the store's size line.

The correct architecture is hybrid, bundle the first portion, lazy-download the rest with a local cache. About a day of work, and it was never done.

A verification trap worth knowing. For weeks, image work was "verified" by taking screenshots of the running app. That was meaningless: the service worker served cached assets, so a screenshot after a fix could show the old image, a stale fallback, or occasionally the correct one. The screenshot was a picture of the cache, not of the code. The reliable method bypassed it:

const img = new Image();
img.onload = () => ok(img.naturalWidth > 0);
img.src = path + "?v=" + Date.now();     // ← cache-bust makes it truthful

Caches between you and the truth will lie to you (Chapter 28).


Lessons

  1. Match the rendering model to the content. A SPA for a content site is slow and unindexable.
  2. Never cache your service worker. no-store, always. It's one of the few unrecoverable web mistakes.
  3. Never cache a non-ok response. One bad deploy becomes permanent for everyone who visited during it.
  4. Bump the cache key every release, or your fixes don't reach anyone.
  5. A one-line fix still needs a tracked item. Difficulty isn't what determines whether something gets fixed.
  6. PNG for photographs is a 10-20× mistake. One command fixes it.
  7. Optimise the pipeline, not the file. Add a size check to your deploy.
  8. Bundle size is a conversion problem before it's a hosting cost.
  9. Set image dimensions. Cheapest CLS fix available.
  10. Never verify assets with a screenshot. Caches make pictures untrustworthy; measure with cache-busting.

Next: Chapter 18: Mobile Development →

Useful? Share this chapter