2.5 — CSS and the critical path¶
Module 2 · Lesson 5 · 🟡 Intermediate · ~30 min
What you'll learn¶
- Why CSS blocks rendering, and how much that costs on a real commerce page
- The true cost of runtime CSS‑in‑JS in a React/RSC app, and migration paths
content-visibilityand containment for long PLPs- Animation performance: what runs on the compositor and what doesn't
CSS is render‑blocking, by design¶
The browser will not paint until CSS in <head> is downloaded and parsed. This is correct — you
don't want a flash of unstyled content — but it means your stylesheet size is directly on the LCP
critical path.
HTML arrives 1270ms ──▶ parser finds <link rel=stylesheet>
├─ CSS download 180ms (68KB gz over 4G)
├─ CSS parse 45ms (main thread, mid-tier device)
└─ style + layout 60ms
▶ first paint possible at 1555ms
Budget: ≤ 50 KB compressed of render‑blocking CSS for a commerce page (1.5). Most sites that have never looked are at 120–300 KB, usually because of an unpurged utility framework or one giant global stylesheet serving every page type.
Measure yours:
curl -s -H 'Accept-Encoding: br' https://www.auroramarket.com \
| grep -o '/_next/static/css/[^"]*\.css' | sort -u | while read -r css; do
size=$(curl -s -H 'Accept-Encoding: br' "https://www.auroramarket.com$css" | wc -c)
echo "$((size / 1024)) KB $css"
done
And find how much of it is used: DevTools → Coverage panel (Ctrl+Shift+P → "Show Coverage") → reload. Anything above ~60% unused on a given page is a code‑splitting problem.
How Next.js splits CSS¶
The App Router automatically splits CSS by route: styles imported in a route segment load only for that route, and shared styles land in a common file. This works well if your imports are structured to allow it.
app/
├── layout.tsx imports globals.css → loaded on every page (keep it SMALL)
├── page.tsx imports home.module.css
├── p/[slug]/
│ ├── page.tsx imports pdp.module.css → only on PDP
│ └── gallery.tsx imports gallery.module.css
└── checkout/
└── page.tsx imports checkout.module.css → only on checkout
The common failure: a design system barrel that imports every component's CSS.
// ❌ packages/ui/index.ts — importing anything pulls in all the CSS
export * from './button'; // + button.css
export * from './modal'; // + modal.css
export * from './carousel'; // + carousel.css
export * from './datepicker';// + datepicker.css (used on ONE page)
// … 60 more
// ✅ Deep imports keep CSS with the components that are actually used
import { Button } from '@aurora/ui/button';
import { Modal } from '@aurora/ui/modal';
Configure exports in the package so deep imports are the supported path, and add
optimizePackageImports for libraries you can't restructure:
// next.config.ts
const config: NextConfig = {
experimental: {
optimizePackageImports: ['@aurora/ui', 'lucide-react', 'date-fns'],
},
};
More on this in 4.3 Dependency diet.
Runtime CSS‑in‑JS: the honest accounting¶
Libraries that generate and inject styles at runtime (styled‑components, Emotion in its runtime mode) cost you in four places:
| Cost | Magnitude |
|---|---|
| Runtime bundle | 12–20 KB gz |
| Style serialization + injection on every render | 5–40 ms per render on mid‑tier mobile |
| Extra work during hydration (rehydrating the style sheet) | 30–150 ms |
They force 'use client' |
The big one — a styled component can't be a Server Component |
That last row is what makes this a rendering‑architecture problem, not a micro‑optimization. If
your <Button> is a styled‑component, every page that uses a button has a client boundary around
it. At Aurora, the design system's use of runtime CSS‑in‑JS was directly responsible for ~180 KB
of the shared client chunk and prevented most of the RSC migration.
The options¶
| Approach | Runtime cost | RSC‑compatible | Migration effort |
|---|---|---|---|
| Tailwind | 0 | ✅ | Medium (rewrite styles) |
| CSS Modules | 0 | ✅ | Medium |
| Vanilla Extract / Panda / StyleX | 0 (build‑time) | ✅ | Medium — keeps a TS‑authoring DX |
| Emotion/styled‑components + compiler plugin | Reduced | ⚠️ Partly | Low |
| Runtime CSS‑in‑JS as‑is | High | ❌ | — |
For a large design system, the pragmatic migration is strangler‑style, not a big bang:
// Step 1: freeze — no new runtime-styled components; ESLint rule enforces it.
// Step 2: migrate leaf components first (Button, Badge, Price) — highest reuse,
// lowest risk, and each one removed unlocks Server Component usage.
// Step 3: migrate containers.
// Step 4: delete the runtime.
// Before — forces 'use client' everywhere it's used
import styled from 'styled-components';
const Price = styled.span<{ $sale: boolean }>`
font-variant-numeric: tabular-nums;
color: ${(p) => (p.$sale ? p.theme.colors.sale : p.theme.colors.text)};
`;
// After — zero runtime, works in a Server Component
export function Price({ sale, children }: { sale?: boolean; children: React.ReactNode }) {
return (
<span className={cn('tabular-nums', sale ? 'text-red-600' : 'text-neutral-900')}>
{children}
</span>
);
}
Track the migration with a metric everyone can see: number of files containing 'use client',
and the size of the shared client chunk. Both should fall monotonically.
# Put this in CI as a ratchet — the count may go down, never up
rg -l "^'use client'" app components | wc -l
Critical CSS: usually not worth it¶
Inlining above‑the‑fold CSS and deferring the rest is a classic technique. In a Next.js app it's usually not worth the complexity because route‑level CSS splitting already gets you most of the benefit, and inlined CSS can't be cached across navigations.
Consider it only when: your render‑blocking CSS exceeds ~80 KB after purging and you can't split it further and TTFB is already good (so CSS download is genuinely the bottleneck).
// next.config.ts — Next.js has an experimental inliner (needs the `critters`/`beasties` package)
const config: NextConfig = {
experimental: {
optimizeCss: true, // inlines critical CSS, defers the rest
},
};
Measure before and after; it can regress repeat visits by making the HTML larger and uncacheable.
What is almost always worth it: purging unused CSS. Tailwind does this by default via content
scanning — just make sure your content globs actually cover where classes appear, including any
.mdx, CMS‑derived class allowlists, and packages in a monorepo.
// tailwind.config.ts
export default {
content: [
'./app/**/*.{ts,tsx,mdx}',
'./components/**/*.{ts,tsx}',
'./node_modules/@aurora/ui/dist/**/*.js', // monorepo package — easy to forget
],
// Classes constructed dynamically from CMS data must be safelisted,
// or purged away and mysteriously broken in production only
safelist: [{ pattern: /^(bg|text)-(brand|sale|sold-out)$/ }],
};
The dynamic class trap:
className={\text-${color}-600`}cannot be statically detected and will be purged. Always write full class names, or map through an object:const COLORS = { sale: 'text-red-600', new: 'text-green-600' }`.
content-visibility: cheap wins on long pages¶
content-visibility: auto tells the browser to skip rendering work (style, layout, paint) for
off‑screen content until it's near the viewport. On a PLP with 60 product cards or a PDP with a
long reviews list, this cuts initial layout time substantially.
.product-card,
.review-item {
content-visibility: auto;
/* REQUIRED: an estimate of the element's size, or you get scrollbar jumping
and CLS as elements are rendered and their real size becomes known */
contain-intrinsic-size: auto 420px;
}
Rules:
- Never on above‑the‑fold content — it can delay LCP, because the browser skips rendering the element that would have been your LCP candidate.
- Always pair with
contain-intrinsic-size. Without it, scroll height changes as content renders, producing scrollbar jumps and broken anchor links. - Use
auto 420px(theautokeyword remembers the real size after first render, which stops the jumping on scroll‑back). - Measure. On short pages the overhead isn't worth it; on a 60‑tile grid it's typically −80 to −250 ms of initial layout on mid‑tier mobile.
Related, for components you know are self‑contained:
/* Isolate an element's layout/paint so changes inside it don't invalidate the whole page */
.mini-cart-panel { contain: layout paint; }
This is particularly effective for overlays, drawers, and sticky headers that re‑render often.
Animation performance¶
Only two properties can be animated entirely on the compositor thread, without touching layout or
paint: transform and opacity. Everything else forces work on the main thread, every
frame.
/* ❌ Animating `left` triggers layout on every frame → jank, and it competes
with React rendering for the main thread */
.drawer { transition: left 300ms ease; }
.drawer.open { left: 0; }
/* ✅ transform runs on the compositor */
.drawer {
transform: translateX(100%);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform; /* use sparingly — each hint costs memory */
}
.drawer.open { transform: translateX(0); }
| Property | Triggers | Cost |
|---|---|---|
transform, opacity |
Composite only | 🟢 Cheap |
background-color, box-shadow, border-radius, filter |
Paint | 🟠 Moderate |
width, height, top, left, margin, padding, font-size |
Layout + paint | 🔴 Expensive |
will-change discipline: it promotes an element to its own compositor layer, which costs GPU
memory. Applying it to every product card on a PLP can consume hundreds of MB and cause the
browser to drop layers on low‑memory devices — making things slower. Apply it just before the
animation (on hover/focus) and remove it after.
Also watch for forced synchronous layout ("layout thrashing"), which shows up in traces as a purple "Recalculate Style / Layout" bar inside a scripting task:
// ❌ Read → write → read → write forces layout recalculation on every iteration
items.forEach((el) => {
const h = el.offsetHeight; // read (forces layout)
el.style.height = `${h * 2}px`; // write (invalidates layout)
});
// ✅ Batch all reads, then all writes
const heights = items.map((el) => el.offsetHeight); // all reads
items.forEach((el, i) => { // all writes
el.style.height = `${heights[i] * 2}px`;
});
Selector and DOM size¶
Modern engines handle selectors well; this is rarely your bottleneck. Two things do matter:
DOM size. Style recalculation cost scales with node count. A PLP rendering 200 product cards × 25 nodes each = 5,000 nodes, and every state change in an ancestor re‑evaluates styles across them. Lighthouse warns above ~1,400 nodes. Fix with virtualization (5.4), not with clever selectors.
Expensive selectors in hot paths. :has(), deep descendant combinators, and universal
selectors applied to large subtrees can show up in traces. If "Recalculate Style" is a visible
cost in your profile, look here — otherwise don't.
Common mistakes¶
| Mistake | Cost |
|---|---|
| One global stylesheet for all page types | 100–300 KB render‑blocking on every page |
| Design‑system barrel imports pulling all CSS | Unused CSS everywhere |
| Runtime CSS‑in‑JS in a design system | Forces 'use client'; blocks RSC adoption |
| Dynamic Tailwind class names | Purged in production; works in dev, breaks live |
content-visibility above the fold |
Delays LCP |
content-visibility without contain-intrinsic-size |
Scrollbar jumping, CLS |
| Animating layout properties | Jank; competes with React for the main thread |
will-change on hundreds of elements |
GPU memory exhaustion; slower, not faster |
| Read/write layout interleaving | Forced synchronous layout in every frame |
Lab 2.5 — CSS audit¶
- Size: measure render‑blocking CSS per page type with the
curlsnippet. Compare to the 50 KB budget. - Coverage: DevTools Coverage panel on your PDP. If >60% is unused, find the barrel import responsible.
'use client'census: run thergcount. Establish it as a ratchet in CI.- Runtime CSS‑in‑JS: if present, measure the injection cost — record a trace during a filter interaction and look for time in the styling library in the Bottom‑Up view.
content-visibility: apply to below‑fold product cards and review items withcontain-intrinsic-size. Measure layout time before/after in the Performance panel.- Animations: record a trace during your drawer/modal animation. If you see purple Layout bars
per frame, convert to
transform.
Checklist¶
- Render‑blocking CSS ≤ 50 KB compressed per page type
- CSS split per route; no barrel imports pulling unused component styles
- Unused CSS purged; Tailwind
contentglobs cover monorepo packages - No dynamically constructed class names (or they're safelisted)
- Zero runtime CSS‑in‑JS on Server Component paths (or a dated migration plan)
-
content-visibilitybelow the fold only, always withcontain-intrinsic-size - Animations use
transform/opacityonly -
will-changeapplied on demand, not statically to many elements - No read/write layout interleaving in hot code