---
title: "28 — Web Performance — Shrinking the Gap Between Ask and See"
uid: web-performance
tags: ["devtools", "core-web-vitals", "lighthouse", "service-workers", "caching", "roadmap:frontend", "performance"]
excerpt: "Performance is the gap between 'user asks' and 'user sees' — and every metric, tool, and optimization either measures that gap or shrinks it."
date: 2026-08-12T18:35:07+0000
source: https://www.aveshina.my.id/en/blog/web-performance
---

"Make it fast" was my performance goal, and it was too vague to act on. The split that made it actionable: **performance is the gap between "user asks" and "user sees"** [1]. Every metric, tool, and optimization is either trying to *measure* that gap or *shrink* it. Once I held that split in my head — MEASURE versus OPTIMIZE — the whole field stopped feeling like a grab-bag of tricks.

Here's the way of thinking that organized everything else. Two halves, one loop: I measure to find where the gap is widest, I optimize that spot, I re-measure. The levers on the optimize side are the same three everywhere — cache, compute less, and ship content progressively:

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="The performance loop. Left half is MEASURE: Lighthouse scores and DevTools network and performance tabs inspecting the gap between user asks and user sees. Right half is OPTIMIZE: three levers — Cache-Control and Service Worker caching, streaming responses that ship content in chunks. An arrow loops from OPTIMIZE back to MEASURE.">
  <defs>
    <marker id="parrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- gap baseline -->
    <text x="60" y="40" font-size="12" font-weight="700" fill="#1e1b4b">USER ASKS</text>
    <text x="640" y="40" font-size="12" font-weight="700" fill="#052e16" text-anchor="end">USER SEES</text>
    <line x1="60" y1="50" x2="680" y2="50" stroke="#cbd5e1" stroke-width="1.5" stroke-dasharray="4 4"/>
    <text x="370" y="44" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">the gap to shrink</text>

    <!-- MEASURE half -->
    <rect x="40" y="70" width="300" height="170" rx="10" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="190" y="96" font-size="14" font-weight="700" fill="#422006" text-anchor="middle">MEASURE</text>
    <text x="190" y="114" font-size="11" fill="#475569" text-anchor="middle">find where the gap is widest</text>
    <rect x="60" y="128" width="260" height="32" rx="6" fill="#fffbeb" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="190" y="148" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">Lighthouse — scores &amp; audits</text>
    <rect x="60" y="166" width="260" height="32" rx="6" fill="#fffbeb" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="190" y="186" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">DevTools — Network &amp; Performance tabs</text>
    <rect x="60" y="204" width="260" height="28" rx="6" fill="#fffbeb" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="190" y="222" font-size="10.5" fill="#475569" text-anchor="middle">Core Web Vitals: LCP · INP · CLS</text>

    <!-- OPTIMIZE half -->
    <rect x="400" y="70" width="300" height="170" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="550" y="96" font-size="14" font-weight="700" fill="#052e16" text-anchor="middle">OPTIMIZE</text>
    <text x="550" y="114" font-size="11" fill="#475569" text-anchor="middle">shrink it</text>
    <rect x="420" y="128" width="260" height="32" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.2"/>
    <text x="550" y="148" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">Cache-Control + Service Worker</text>
    <rect x="420" y="166" width="260" height="32" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.2"/>
    <text x="550" y="186" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">Streamed responses (chunked)</text>
    <rect x="420" y="204" width="260" height="28" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.2"/>
    <text x="550" y="222" font-size="10.5" fill="#475569" text-anchor="middle">less bytes, less compute</text>

    <!-- loop arrow -->
    <path d="M550,242 C550,290 190,290 190,244" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#parrow)"/>
    <text x="370" y="284" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">re-measure after every change</text>
  </g>
</svg>
```

The rest of these notes just walk the loop: what the gap is made of, how to measure it, and the levers I keep coming back to.

## What "fast" actually means — Core Web Vitals

Before any tooling, I had to learn what the gap is *made of*. It isn't one number. It's three families of feeling, and Google's **Core Web Vitals** give each a single metric so I can stop arguing about vibes [2][3]:

- **Load speed** — how fast did *something useful* appear? Metric: **LCP** (Largest Contentful Paint), the moment the biggest visible element paints. Aim under 2.5s.
- **Interactivity** — when I click, does it respond now or later? Metric: **INP** (Interaction to Next Paint), the worst click-to-paint delay across the session. Aim under 200ms.
- **Visual stability** — did the content jump while I was reading it? Metric: **CLS** (Cumulative Layout Shift), how much stuff moved. Aim under 0.1.

That trio is why "the page loaded fast" and "the page felt fast" are different sentences. A page can hit LCP in a second and still feel broken if every tap takes 500ms to register (bad INP), or if the image loads late and shoves the text I'm reading down the screen (bad CLS). The metrics force me to name *which kind* of slow.

## MEASURE — Lighthouse and DevTools

The measure half is two tools, and they do different jobs.

**Lighthouse** is the automated audit [4]. I run it from DevTools (or the CLI — command line) against any URL and it produces a scored report across Performance, Accessibility, SEO, and PWA (progressive web app — a web app that can work offline) — plus a list of *actionable* opportunities ("eliminate render-blocking resources," "serve images in next-gen formats"). What I had to internalize: the **lab score** Lighthouse reports is simulated on a throttled device in a clean room. It's reproducible and great for regression tracking, but it's not what a real user on a real phone experienced. For that I need field data.

**DevTools** is where the field-grade diagnosis actually happens, two tabs especially [5]:

- The **Network tab** shows every request as a waterfall — DNS, connect, request, response — color-coded and sortable. This is where I see the *one* 3-second image holding up the whole page, or the render-blocking script chain. Disable cache, throttle to "Slow 3G," reload, and the slow resource sticks out.
- The **Performance tab** records a flame chart (a timeline showing which functions ran and for how long) of everything the main thread did — the main thread is the browser's single lane for running JavaScript and painting — parse, style, layout, paint, JS execution. When INP is bad, this is where I find the long task (a chunk of work that takes too long) blocking the click handler.

The habit these notes left me with: never optimize blind. Lighthouse tells me *what* category is weak; the Network and Performance tabs tell me *which specific resource or task*. Only then do I reach for a lever.

## OPTIMIZE — Cache-Control and Service Workers

The biggest, cheapest lever is the same on every project: stop re-fetching what you already have. There are two caching layers, and the line between them took me a while to place.

**Cache-Control** is an HTTP header on the *response* — I set it on the server (or CDN), and it tells every intermediary "store this for this long, under these conditions" [6]. The directives I actually use:

```
Cache-Control: public, max-age=31536000, immutable
```

That one line covers the case of a hashed asset like /_next/static/chunk-a1b2.js. max-age says cache for a year; immutable says "this will never change, don't even bother revalidating"; public lets the CDN cache it too. For HTML I use no-cache (it can be stored but must revalidate) — because HTML is the one thing that *does* change, and it's the entry that points to all those immutable assets.

**Service Workers** are a second cache layer, but they sit somewhere different — *inside the browser*, as a JavaScript proxy between the page and the network [7]. A service worker can intercept any request the page makes and answer from its own cache before the network is even consulted. That's what makes offline PWAs possible, but the same machinery is a performance win: the second visit can load from the SW cache instantly, with zero network round-trip.

```figure
<svg viewBox="0 0 740 220" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Two cache layers between page and server. A Page box on the left. Above it an HTTP Cache-Control header arrow labeled server/CDN-controlled. Below the page a Service Worker box that intercepts requests, labeled browser-controlled, with its own Cache. On the right the origin Server. Request flow shows SW checking its cache first, then falling through to the network where Cache-Control governs the shared HTTP cache.">
  <defs>
    <marker id="carrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- Page -->
    <rect x="40" y="40" width="140" height="60" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="110" y="66" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">Page</text>
    <text x="110" y="84" font-size="10" fill="#475569" text-anchor="middle">makes a request</text>

    <!-- Cache-Control header above page -->
    <rect x="40" y="10" width="140" height="22" rx="4" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="110" y="25" font-size="10" font-family="ui-monospace,monospace" font-weight="700" fill="#422006" text-anchor="middle">Cache-Control: max-age</text>
    <text x="200" y="25" font-size="9.5" fill="#64748b">server / CDN controlled</text>

    <!-- Service Worker layer -->
    <rect x="260" y="40" width="220" height="60" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="370" y="66" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">Service Worker</text>
    <text x="370" y="84" font-size="10" fill="#052e16" text-anchor="middle">intercepts · browser controlled</text>

    <!-- SW Cache -->
    <rect x="260" y="120" width="220" height="44" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.2"/>
    <text x="370" y="146" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">SW Cache (keyed by request)</text>

    <!-- HTTP cache -->
    <rect x="40" y="120" width="140" height="44" rx="8" fill="#fffbeb" stroke="#ca8a04" stroke-width="1.2"/>
    <text x="110" y="146" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">HTTP cache</text>

    <!-- Origin server -->
    <rect x="560" y="80" width="140" height="60" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="630" y="106" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Origin server</text>
    <text x="630" y="124" font-size="10" fill="#500724" text-anchor="middle">last resort</text>

    <!-- flow arrows -->
    <line x1="180" y1="70" x2="258" y2="70" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>
    <text x="219" y="62" font-size="9.5" fill="#64748b" text-anchor="middle">1</text>
    <line x1="370" y1="100" x2="370" y2="118" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>
    <text x="385" y="112" font-size="9.5" fill="#64748b">2 cache hit?</text>
    <path d="M480,70 C540,70 560,90 560,105" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>
    <text x="530" y="64" font-size="9.5" fill="#64748b" text-anchor="middle">3 miss → network</text>
  </g>
</svg>
```

The clean division I finally landed on: **Cache-Control governs the HTTP cache that the browser and CDN already have; a Service Worker is a programmable cache I write myself.** For most assets the HTTP cache with good headers is enough. I reach for a Service Worker when I need control the header can't give — offline pages, fine-grained runtime caching, background sync.

## OPTIMIZE — streamed responses

The last lever is the one I under-rated longest. Most of my picture of a request was: server builds the *whole* response, sends it, browser renders it. **Streaming** breaks that — the server sends data in chunks as soon as each is ready, and the browser can start parsing and painting before the rest arrives [8][9].

The payoff is purely *perceived* performance, and that's not a slight — perceived performance is the only kind the user feels. A page that streams its shell first shows content in 200ms instead of staring blank for 2s while the full document finishes server-side. The total time is similar; the gap between "user asks" and "user sees *something*" collapses. In a framework like Next.js this is exactly what loading.tsx, Suspense boundaries (placeholders shown while data loads), and streamed RSC (React Server Components) payloads do — they flush the shell, then stream the slower data in as it resolves.

The same idea powers AI chat token streams and large dataset downloads: ship the first chunk now, keep the connection open, fill in the rest. The browser's **Streams API** is the low-level primitive that lets client code read these chunks incrementally rather than waiting for the full body [8].

## How I use this

The loop is the whole takeaway. When a page feels slow I no longer flail — I run Lighthouse to name the weak category (LCP, INP, or CLS), open the matching DevTools tab to find the specific offender, then reach for the right lever: a Cache-Control header for a re-fetched static asset, a Service Worker for offline or runtime control, or streaming/Suspense for a slow first paint. Then I re-measure. The gap between ask and see is the only thing that matters, and every tool is just a way to see it more clearly.

## References

[1] Mozilla, "Web performance," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/Performance](https://developer.mozilla.org/en-US/docs/Web/Performance)

[2] Google, "Core Web Vitals," web.dev, 2024. [Online]. Available: [https://web.dev/articles/vitals](https://web.dev/articles/vitals)

[3] Google, "How Core Web Vitals are measured," web.dev, 2024. [Online]. Available: [https://web.dev/articles/how-web-vitals-are-measured](https://web.dev/articles/how-web-vitals-are-measured)

[4] Google, "Lighthouse overview," Chrome for Developers, 2024. [Online]. Available: [https://developer.chrome.com/docs/lighthouse/overview](https://developer.chrome.com/docs/lighthouse/overview)

[5] Google, "Chrome DevTools," Chrome for Developers, 2024. [Online]. Available: [https://developer.chrome.com/docs/devtools](https://developer.chrome.com/docs/devtools)

[6] Mozilla, "Cache-Control," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control)

[7] Mozilla, "Service Worker API," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)

[8] Mozilla, "Streams API," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/API/Streams_API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)

[9] Mozilla, "An overview of HTTP," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview](https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview)

```quiz
Q: Which Core Web Vital measures visual stability — content jumping around as it loads?
- LCP
- INP
- CLS
correct: 2
explain: CLS (Cumulative Layout Shift) captures how much visible content shifts after it first renders. LCP is load speed; INP is interactivity.

Q: Lighthouse reports a low performance score. What is the score actually based on?
- Real user data collected from your production site
- A simulated, throttled lab run on a clean browser instance
correct: 1
explain: Lighthouse runs a lab simulation on a throttled device in a clean room — reproducible and good for regression tracking, but not the real-user experience. For real-user data you need field tools (CrUX, RUM).

Q: You want a hashed asset file to be cached for a year and never revalidated. Which header?
- Cache-Control: no-cache
- Cache-Control: public, max-age=31536000, immutable
- Cache-Control: no-store
correct: 1
explain: max-age sets the year-long lifetime; immutable tells the browser the bytes will never change so it needn't revalidate; public lets the CDN cache it too. no-cache forces revalidation; no-store forbids caching entirely.

Q: What's the clean division between Cache-Control and a Service Worker?
- There is no difference — they are two ways to do the same thing
- Cache-Control governs the shared HTTP cache the server/CDN controls; a Service Worker is a programmable, browser-side proxy cache you write
correct: 1
explain: Cache-Control is a response header governing the built-in HTTP cache. A Service Worker is a script that intercepts requests in the browser and answers from its own cache — giving control the header can't (offline, runtime caching, background sync).

Q: Why stream a response in chunks instead of sending it whole?
- It reduces the total number of bytes sent
- It lets the browser start parsing and painting before the full response is ready, improving perceived performance
correct: 1
explain: Streaming doesn't change the total payload — it ships the first chunk immediately so the user sees content sooner. Perceived performance is the only kind the user feels.
```
