寸陰を惜しむ

Frontend performance cheatsheet · Vol. 1

PERF

Where the milliseconds go, what spends them, and the line that wins them back.

寸陰を惜しむ (sun’in o oshimu) is a Japanese saying: don’t waste a single moment.

Chapter 1

Slow server

TTFB over 0.8 s

第一章

01RedirectsTTFB, FCP, LCPmedium impact

Redirects before the page

Slow
<a href="http://shop.example/sale">Sale</a><!-- → https://shop.example/sale → https://shop.example/sale/ -->
Fast
<a href="https://shop.example/sale/">Sale</a>

Each hop is a round trip, often a new connection, before the first byte. Link to the final URL, trailing slash included; HSTS turns the http hop into none.

Spot it Lighthouse: “Document request latency”

02RequestTTFB, FCP, LCPhigh impact

Rendering every request from scratch

Slow
Cache-Control: no-store # the origin rebuilds the HTML every visit
Fast
Cache-Control: public, s-maxage=60, stale-while-revalidate=600

Nothing paints before the first byte, so every millisecond on the server is added to LCP. Let a CDN serve cached HTML and revalidate in the background; keep no-store for pages that are truly per-user.

Spot it Network panel → Timing: “Waiting for server response” over 0.8 s

Chapter 2

Blank screen

FCP over 1.8 s

第二章

04FCP, LCP, CLSmedium impact

Fonts behind someone else's stylesheet

Slow
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700");
Fast
@font-face {  font-family: "Inter";  src: url("/fonts/inter-latin.woff2") format("woff2");  font-weight: 100 900;}

Text stays invisible until the font arrives, and the font is found only after two stylesheets and a new connection. Caches are partitioned by site, so no visitor arrives with it. Self-host one variable file; preconnect to any origin the first paint still needs.

Spot it Network panel → Font: requests to a second origin, starting after two stylesheets

05FCP, LCPmedium impact

One stylesheet for the whole site

Slow
<link rel="stylesheet" href="/site.css"> <!-- 300 KB, every page -->
Fast
<link rel="stylesheet" href="/home.css"> <!-- 20 KB, this page -->

Nothing paints until every stylesheet in the <head> has downloaded and parsed. Give each page the CSS it uses, split by route or component, and inline the little the first paint needs.

Spot it Coverage panel: most of the stylesheet unused on load

Chapter 3

Slow paint

LCP over 2.5 s

第三章

06Resource load delayLCPhigh impact

Lazy-loaded hero image

Slow
<img src="hero.avif" loading="lazy" alt="…">
Fast
<img src="hero.avif" fetchpriority="high" alt="…">

The image that decides LCP waits until layout proves it is on screen. Never lazy-load above the fold; a hero set as a CSS background needs <link rel="preload">.

Spot it Lighthouse: “LCP request discovery”

07Resource load durationLCPhigh impact

One image for every screen

Slow
<img src="photo-4000w.jpg" alt="…">
Fast
<img src="photo-800w.avif" alt="…"  srcset="photo-800w.avif 800w, photo-1600w.avif 1600w"  sizes="(min-width: 60rem) 50vw, 100vw">

A phone downloads 4000 pixels to show 400. srcset lists the sizes, sizes says how wide the image renders, and the browser picks the smallest sharp one.

Spot it Lighthouse: “Improve image delivery”

08Resource load durationLCP, FCPmedium impact

Hashed assets that are never cached

Slow
Cache-Control: no-cache # app.3f9a1c.js, asked for every visit
Fast
Cache-Control: max-age=31536000, immutable

Each repeat visit revalidates a file whose hash guarantees it never changed. Cache hashed files for a year; only the HTML that names them stays short-lived.

Spot it Lighthouse: “Use efficient cache lifetimes”

09Element render delayLCP, FCPhigh impact

Content only JavaScript can render

Slow
<div id="root"></div><script type="module" src="/app.js"></script>
Fast
<div id="root"><h1>Summer sale</h1><img src="hero.avif" alt="…"></div><script type="module" src="/app.js"></script>

An empty <div> gives the browser nothing to paint and nothing to preload. The bundle downloads, runs, fetches its data, and only then asks for the hero. Prerender or server-render the HTML; the script can still take over.

Spot it Performance panel → LCP breakdown: a long load delay, the image requested only after the script runs

10Element render delayLCPhigh impact

Request waterfall

Slow
const user = await getUser();const posts = await getPosts();
Fast
const [user, posts] = await Promise.all([getUser(), getPosts()]);

Two awaits, two round trips. Start independent requests together; the same staircase hides in components that fetch after their parent and in CSS @import chains.

Spot it Network panel: requests that start as the previous one ends

Chapter 4

Layout shift

CLS over 0.1

第四章

11Images without dimensionsCLShigh impact

Images without dimensions

Slow
<img src="banner.avif" alt="…">
Fast
<img src="banner.avif" width="1200" height="400" alt="…">

An image with no size takes no space until it loads, then shoves the text down. width and height reserve the ratio, and height: auto keeps it responsive; embeds need aspect-ratio.

Spot it Performance panel: Layout shift clusters on image load

12Late-loaded contentCLShigh impact

Content that arrives without its space

Slow
{loading ? <Spinner /> : <Feed posts={posts} />}
Fast
{loading ? <FeedSkeleton rows={5} /> : <Feed posts={posts} />}

A 40-pixel spinner gives way to a 2 000-pixel feed, and everything below it jumps; banners, ads and embeds do the same. Reserve the space up front with a skeleton the size of the result or a min-height on the slot, or keep late content below the fold.

Spot it Performance panel → Insights: “Layout shift culprits”

13AnimationsCLS, INPhigh impact

Animating layout properties

Slow
.drawer { transition: left 300ms; }
Fast
.drawer { transition: transform 300ms; }

left, width and margin move the layout itself: every frame reruns layout and paint, and whatever moves without a recent input counts as a shift. transform and opacity stay on the compositor and count as neither.

Spot it Rendering drawer → Paint flashing: green on every frame

14Web fontsCLSmedium impact

Web font reflow

Slow
@font-face { font-family: "Brand"; src: url(brand.woff2); font-display: swap; }
Fast
@font-face { font-family: "Brand fallback"; src: local("Arial"); size-adjust: 104%; }body { font-family: "Brand", "Brand fallback"; }

When the web font arrives with different metrics, the text reflows. A fallback tuned with size-adjust takes the same space, so the swap moves nothing. Astro’s font API and next/font generate one.

Spot it Performance panel: a layout shift right as the font request finishes

15bfcacheCLS, TTFB, FCP, LCPhigh impact

Unload handlers that break the back button

Slow
addEventListener("unload", () => navigator.sendBeacon("/log", stats));
Fast
addEventListener("pagehide", () => navigator.sendBeacon("/log", stats));

Back and forward could restore the page from memory, laid out and scrolled where the reader left it. An unload listener rules that out, so the page loads again from the network, shifts and all. pagehide fires in the same places and keeps the cache.

Spot it Application panel → Back/forward cache: “Test back/forward cache”

Chapter 5

Frozen clicks

INP over 200 ms

第五章

16Input delayINPhigh impact

Hydrating the whole page

Slow
hydrateRoot(document, <App />) // every paragraph too
Fast
<Article /><Comments client:visible />

The page looks ready, but clicks do nothing until every component has run again. Ship JavaScript only for the interactive parts, and hydrate them when needed.

Spot it Performance panel: one long Evaluate Script task after first paint

18Input delayINP, LCPhigh impact

One bundle for every route

Slow
import { Editor } from "./editor"; // 400 KB, used on one page
Fast
const { Editor } = await import("./editor"); // on click

A static import ships 400 KB to every visitor on every route. A dynamic import() moves it into a chunk fetched only when needed.

Spot it Coverage panel: most of the main bundle unused on load

19Input delayINP, LCPhigh impact

Third-party widgets at startup

Slow
<script src="https://chat.example/widget.js" async></script>
Fast
chatButton.addEventListener("click", () => {  const script = document.createElement("script");  script.src = "https://chat.example/widget.js";  document.head.append(script);}, { once: true });

Chat, A/B tests and tag managers run long tasks while the page is waking up, and early clicks queue behind them. Load a widget when it is asked for, behind a facade that looks like it.

Spot it Performance panel → Insights: “3rd parties”, and their long tasks

20Input delayINP, LCPmedium impact

Importing through a barrel file

Slow
import { Button } from "@/components"; // index.ts re-exports 200 modules
Fast
import { Button } from "@/components/button";

The bundler must load every re-exported module, and drops one only if it can prove it has no side effects. The dev server loads them all, every time. Import from the file itself.

Spot it A bundle visualiser: modules the page never renders, pulled in by one index file

21Input delayINP, LCPmedium impact

Three libraries for one job

Slow
{ "dependencies": { "moment": "^2.30", "dayjs": "^1.11", "date-fns": "^4.1" } }
Fast
new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(date);

Each team picked its own date library, and every visitor downloads all three. Mismatched versions add second copies of the same one. Pick one, or none: Intl formats dates, numbers and lists.

Spot it npm ls: the same job done by several packages, or one package at several versions

22Input delayINP, LCPmedium impact

Transpiling for browsers nobody uses

Slow
// browserslist"> 0.25%, ie 11"
Fast
// browserslist"baseline widely available"

ES5 output turns classes and async into longer helpers and ships polyfills to browsers that have the feature. Target what visitors actually run; Baseline is a sane default.

Spot it Lighthouse: “Legacy JavaScript”

23Processing durationINPhigh impact

Layout thrashing

Slow
for (const el of items) el.style.height = el.offsetWidth / 2 + "px";
Fast
const widths = items.map((el) => el.offsetWidth);items.forEach((el, i) => (el.style.height = widths[i] / 2 + "px"));

In a loop, each read after a write forces layout, once per item. Read everything, then write everything, and layout runs once.

Spot it Performance panel: purple Layout blocks flagged “Forced reflow”

24Processing durationINPhigh impact

Long task in a click handler

Slow
button.addEventListener("click", () => {  saveDraft(); // 300 ms  status.textContent = "Saved";});
Fast
button.addEventListener("click", async () => {  status.textContent = "Saving…";  await scheduler.yield(); // paint first  saveDraft();});

Nothing paints until the handler returns, so the click feels dead for 300 ms. Answer first, yield, then work. Safari has no scheduler.yield() yet; fall back to setTimeout.

Spot it Performance panel: Interactions track, long processing duration

25Processing durationINPhigh impact

State that re-renders the whole page

Slow
function App() {  const [query, setQuery] = useState("");  return <><Search value={query} onChange={setQuery} /><Feed /></>;}
Fast
function App() {  return <><Search /><Feed /></>; // query state lives in Search}

State at the top re-renders everything below it on each keystroke, though only the search box changed. Keep state beside what reads it; memoise the expensive parts that truly share it.

Spot it React DevTools Profiler: the whole tree renders on each keystroke

26Processing durationINPmedium impact

Heavy work on the main thread

Slow
input.oninput = () => render(search(index, input.value)); // 200 ms
Fast
input.oninput = () => worker.postMessage(input.value);worker.onmessage = (e) => render(e.data);

Each keystroke waits 200 ms for the search. Work that doesn’t touch the DOM belongs in a Web Worker; send it the query, not the whole index.

Spot it Performance panel: long tasks on every keystroke

27Presentation delayINPmedium impact

Rendering what nobody sees

Slow
list.append(...rows) // 10 000 rows, all rendered
Fast
.row { content-visibility: auto; contain-intrinsic-size: auto 3rem; }

10 000 rows cost style, layout and memory, on screen or not. content-visibility: auto skips rendering the off-screen ones and keeps them findable; the nodes still exist, so for very long lists, virtualise.

Spot it Performance panel: long Recalculate Style and Layout after the list renders

28Presentation delayINPmedium impact

Thousands of rows in one click

Slow
showAll.onclick = () => list.replaceChildren(...rows.map(toRow)); // 5 000 rows
Fast
showAll.onclick = () => list.replaceChildren(...rows.slice(0, 50).map(toRow)); // more on scroll

The handler is quick; styling, laying out and painting 5 000 new rows is not, and the next frame waits for all of it. Render what fits on screen and add the rest as the reader scrolls.

Spot it Performance panel → Interactions: a long presentation delay after a short handler