g1t/docs/research/css-shipping.md

271 lines15,019 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Research: shipping CSS, and a ReviewBench harness for g1t's reviewer1# How g1t.sh ships CSS
2
3Internal research, 2026-10-06. Prompted by GitHub's "Improving site
4performance by shipping more CSS" (github.blog, engineering). Read-only:
5nothing here is applied yet. The measurements can be repeated with the
6commands at the end.
7
8## Verdict
9
10- **The article's technique does not apply to us. We already ship CSS the
11 way GitHub moved to.** GitHub replaced runtime CSS-in-JS
12 (styled-components) with static CSS Modules and got faster server
13 rendering. apps/web has no CSS-in-JS. Tailwind v4 compiles to one static
14 stylesheet at build time. Nothing runs at render time to produce styles,
15 and no `<style>` tags are injected.
16- **We do have a CSS delivery problem, and it is a small fix.** The one
17 stylesheet is the **last** element in `<head>`. It comes after 2 font
18 preloads and 38 to 58 `modulepreload` links. On a slow mobile link it
19 shares bandwidth with roughly 400 KB of JavaScript and fonts, and first
20 paint waits for it. Under real (DevTools) slow-4G throttling, the
21 stylesheet finished at 2.9 to 3.3 s. First paint followed at 3.5 s. TBT
22 was 0 and the document had arrived at 0.7 s.
23- **Recommended:** (1) emit the stylesheet first in `<head>`, ahead of the
24 module preloads. (2) Send it as a `Link: rel=preload` response header, so
25 Cloudflare Early Hints can start it during server think time. Expected
26 gain: roughly 0.3 to 0.4 s off FCP/LCP on mobile in the Lighthouse model,
27 and up to the CSS's whole load time (1 s or more) on pages with slow
28 loaders, for first visits. Repeat visits gain nothing, because the CSS is
29 cached immutable for a year. Risk is low; the details are under
30 [Patches](#patches).
31- **Not recommended:** per-route CSS splitting, critical-CSS inlining and
32 CSS Modules. They would add requests, HTML weight or complexity for about
33 15 KB (brotli) that is already cached across every page.
34
35## The article
36
37GitHub's Primer design system and github.com styled React components with
38styled-components (CSS-in-JS). That had three costs:
39
40- Styles were computed and injected at runtime, on the server during SSR
41 and on the client during hydration.
42- SSR spent time collecting styles.
43- The overhead grew with the number of components on a page.
44
45The fix was CSS Modules: plain `.module.css` files colocated with
46components and compiled to static stylesheets "sent as part of the HTML
47for a page", with no client or server runtime. The title's "shipping more
48CSS" means more static CSS bytes in exchange for no runtime styling work.
49
50How they did it:
51
521. **Primer, 2023 to 2024.** CSS Module files beside each component,
53 feature-flagged old/new styles, visual regression tests, rolled out to
54 the team, then staff, then everyone.
552. **github.com, 2025 to 2026.** A `@primer/styled-react` bridge kept the
56 legacy `sx` prop working. They migrated about 7,760 `sx` props:
57 - 6,419 by May 2026, with a VS Code extension (sx-to-css) and a codemod;
58 - the last 895 by Copilot agents in three weeks.
593. **Theming, 2026.** They moved off styled-components theme utilities to
60 CSS variables (`@primer/css`).
61
62Measured results (server-side only; the post gives no client metrics such
63as LCP, INP or CLS, and no byte counts):
64
65- Primer migration (Dec 2024): **55% less time to server-render a page**
66 and **25% less time for components to initialize**.
67- github.com migration: SSR improvements **from 1% up to about 22%** per
68 page; the best controller improved by 21.97%.
69- 100% CSS Modules as of June 2026.
70
71What we take from it: the expensive thing was *runtime* styling. GitHub's
72end state is static CSS in a stylesheet, which is where apps/web already
73is.
74
75## How apps/web ships CSS today
76
77| Question | Answer |
78|---|---|
79| Styling system | Tailwind v4 via `@tailwindcss/vite`. Tokens and fonts come from `@g1t/theme`. Custom CSS (keyframes, `art-*` drawings, markdown) is in `app/app.css`. |
80| CSS-in-JS | None: no styled-components, emotion, stitches or runtime `<style>` injection. `style=""` attributes are CSS variables on drawings (114 on `/`, 1 to 38 elsewhere) and Shiki token colors in code views. |
81| Files | **One** stylesheet, `assets/root-<hash>.css`, imported once in `app/root.tsx` (`import "./app.css"`). No route imports CSS, so the manifest has CSS on the root route only. |
82| Size | **120,058 B raw, 18,758 B gzip -9, 15,058 B brotli -q11**. On the wire from Cloudflare: about 19.9 KB. Breakdown: Tailwind utilities 93 KB; `@property` registrations 2 KB (in a 4.5 KB block); theme 2.6 KB; base 4 KB; custom CSS outside layers about 18 KB; 7 `@font-face`, 23 `@keyframes`. |
83| Caching | `cache-control: public, max-age=31536000, immutable` (hashed name). Every page shares the same file, so after the first page it costs nothing. |
84| How it's loaded | A render-blocking `<link rel="stylesheet">`, which is correct. **But it is the last tag in `<head>`**: icons, then 2 font preloads, then 38 (`/pricing`) to 58 (`/flagon-io/g1t`) `modulepreload`s, then the stylesheet. There is no `preload` for it, no `Link` header, no Early Hints, and no inlined critical CSS. |
85| Fonts | Hanken (22 KB) and Bricolage (66 KB) are preloaded and use `font-display: swap`, with metric-matched fallbacks (`size-adjust` and overrides), so swapping does not shift layout. Plex Mono loads on demand. |
86| React Router | `<Links/>` renders the route CSS from the manifest. On client navigation, React Router loads the next routes' stylesheets before committing. With a single shared file that is already cached, navigation never waits on CSS and there is no late-loaded route stylesheet. |
87| Animations | Infinite animations exist only in the landing drawings. They are paused off-screen (`[data-paused]`), promoted to their own layer (`will-change` on `[data-live]`), and stopped under `prefers-reduced-motion`. |
88
89The order matters because the browser asks for resources in document
90order and the CSS is asked for last. In the DevTools-throttled run of
91`/flagon-io/g1t/pull/1` (slow 4G, 150 ms RTT, about 1.6 Mbps), all 61
92subresources were requested between 645 and 664 ms. The stylesheet was the
9361st. The bandwidth was then shared:
94
95| Resource | Bytes | Requested (ms) | Finished (ms) |
96|---|---:|---:|---:|
97| HTML | 16,555 | 0 | 699 |
98| Hanken font (preload) | 22,082 | 645 | 3,282 |
99| Bricolage font (preload) | 66,554 | 646 | 4,271 |
100| entry.client.js | 68,516 | 649 | 4,310 |
101| 56 other modulepreloads | ~300 KB | 649 to 664 | 1,268 to 4,135 |
102| **root.css** | **19,890** | **664** | **3,271** |
103| First contentful paint | | | **3,515** |
104
105Chrome gives the stylesheet top priority. Cloudflare's HTTP/3 prioritization
106should favor it on a real connection more than DevTools throttling does,
107so these absolute numbers are pessimistic. The direction holds either way:
108nothing paints until the CSS arrives, and the CSS is queued behind about
109450 KB that is not needed for first paint.
110
111## Measurements
112
113The build is `npm run build` in apps/web, run locally on 2026-10-06. Its
114CSS hash `KP0DOluu` matches production. CSS requests are counted from the
115live HTML.
116
117| Page | HTML (decoded) | CSS requests | modulepreloads | TTFB (curl, server-timing) |
118|---|---:|---:|---:|---|
119| `/` | 194 KB | 1 | 42 | 462 ms (server 33 ms) |
120| `/pricing` | 64 KB | 1 | 38 | 254 ms (server 180 ms) |
121| `/flagon-io/g1t` | 118 KB | 1 | 58 | **1,403 ms** (server 1,311 ms, 22 service calls) |
122| `/flagon-io/g1t/pull/1` | 76 KB | 1 | 57 | 234 ms (server 74 ms) |
123
124Lighthouse 13.5.0, headless Chrome, signed out, performance only. The
125default mode is simulated throttling. "CSS est." is Lighthouse's
126render-blocking estimate for the stylesheet.
127
128| Page | Mode | Score | FCP | LCP | TBT | CLS | Style+layout | CSS est. |
129|---|---|---:|---:|---:|---:|---:|---:|---:|
130| `/` | desktop | 82 | 1.83 s | 1.93 s | 0 | 0.000 | 145 ms | 110 ms |
131| `/` | mobile | 71 | 4.30 s | 5.06 s | 0 | 0.000 | 476 ms | 420 ms |
132| `/` | mobile, run 2 | 71 | 4.24 s | 5.01 s | 0 | 0.000 | | 290 ms |
133| `/` | mobile, DevTools throttling | 83 | 3.51 s | 3.51 s | 0 | 0.000 | | 100 ms |
134| `/pricing` | desktop | 95 | 1.08 s | 1.19 s | 0 | 0.000 | 63 ms | 50 ms |
135| `/pricing` | mobile | 76 | 3.85 s | 4.40 s | 0 | 0.000 | 189 ms | 330 ms |
136| `/flagon-io/g1t` | desktop | 86 | 1.61 s | 1.69 s | 0 | 0.000 | 36 ms | 80 ms |
137| `/flagon-io/g1t` | mobile | 64 | 5.43 s | 6.21 s | 0 | 0.000 | 131 ms | 410 ms |
138| `/flagon-io/g1t/pull/1` | desktop | 89 | 1.46 s | 1.57 s | 0 | 0.001 | 28 ms | 40 ms |
139| `/flagon-io/g1t/pull/1` | mobile | 64 | 5.44 s | 6.30 s | 0 | 0.000 | 151 ms | 420 ms |
140| `/flagon-io/g1t/pull/1` | mobile, run 2 | 65 | 5.32 s | 6.05 s | 0 | 0.001 | | 430 ms |
141| `/flagon-io/g1t/pull/1` | mobile, DevTools throttling | 83 | 3.52 s | 3.52 s | 1 | 0.001 | | 170 ms |
142
143What the numbers say:
144
145- **Layout shift is solved.** CLS is 0.000 to 0.001 everywhere. The only
146 shifts recorded were a `<time>` element and a muted caption on the pull
147 request page, both under 0.001.
148- **No main-thread problem.** TBT is 0 and no task is over 50 ms on any
149 page. A lab tool can't measure INP, but with no long tasks and small DOMs
150 (358 to 1,474 nodes) nothing points to an INP issue. Style recalculation
151 and layout cost 28 to 151 ms per load. The exception is `/` on mobile at
152 476 ms under 4x CPU slowdown, which comes from DOM size (1,474 nodes)
153 and the landing drawings, not from selector cost: Tailwind selectors are
154 single classes. Paint on `/` (765 ms simulated mobile) is the animated
155 drawings, already layered and paused off-screen.
156- **Unused CSS passes.** Lighthouse's unused-CSS audit passes on all four
157 pages. A 15 KB brotli file is not worth splitting.
158- **The lost time is network and ordering.** Mobile FCP is 3.5 to 5.4 s
159 while the document arrives in about 0.1 to 0.7 s and the main thread is
160 idle. Lighthouse's render-blocking estimate for the stylesheet is 290 to
161 430 ms on mobile (simulated), and the waterfall above shows why.
162- The simulated mobile LCP has an extra 1.1 s of "render delay" on the
163 repo and pull request pages. It did not reproduce under DevTools
164 throttling (FCP = LCP = 3.5 s), so it looks like a simulation artifact of
165 the long modulepreload list rather than a real delay. It is worth
166 re-checking after the patches.
167
168## Patches
169
170Neither patch is applied. Both are small.
171
172### 1. Stylesheet first in `<head>` (apps/web/app/root.tsx)
173
174Import the stylesheet as a URL and make it the first link, with a React 19
175`precedence`. React then hoists it into the stylesheet section of the
176head, which it writes before bulk preloads such as `modulepreload`. Nothing
177else changes, and React dedupes it on the client.
178
179```diff
180-import "./app.css";
181+import stylesheet from "./app.css?url";
182 ...
183 export const links: Route.LinksFunction = () => [
184+ // First, so it is asked for before the fonts and the module preloads:
185+ // nothing on the page paints until it arrives.
186+ { rel: "stylesheet", href: stylesheet, precedence: "default" },
187 { rel: "icon", href: "/favicon.ico", sizes: "32x32" },
188```
189
190Verify before shipping:
191
1921. Run `npm run build`. Then check that `build/client/assets` still has
193 exactly one CSS file, and that the server-rendered `<head>` has the
194 stylesheet before the first `modulepreload` (curl a local `wrangler dev`
195 or a preview).
1962. Check that Tailwind's Vite plugin still processes `app.css` imported
197 with `?url` in dev. React Router's own templates use this pattern; if
198 HMR for styles regresses in dev, keep the side-effect import for dev
199 only.
2003. If React does not hoist it as expected, a fallback gets the same
201 ordering: drop `precedence` and render
202 `<link rel="stylesheet" href={stylesheet} />` directly in `Layout`
203 before `<Meta />`.
204
205Expected gain: the stylesheet goes from the 61st request to the 1st. On a
206bandwidth-limited first visit it then finishes before most of the JS
207rather than among it. That means about 0.3 to 0.4 s off mobile FCP/LCP
208(Lighthouse's render-blocking estimate), and more on slower real
209connections. There is no change on desktop broadband or repeat visits.
210
211Risk: low. The same file, the same blocking semantics, a different
212position. The only real risk is the dev-mode `?url` behaviour above.
213
214### 2. `Link` preload header for Early Hints (apps/web/app/entry.server.tsx)
215
216In `handleRequest`, before the response is built:
217
218```ts
219// The stylesheet, announced in the headers: with Early Hints on, the
220// browser fetches it while loaders are still running.
221const css = routerContext.manifest.routes.root?.css ?? [];
222if (css.length > 0) {
223 responseHeaders.append("Link", css.map((href) => `<${href}>; rel=preload; as=style`).join(", "));
224}
225```
226
227Turn on **Early Hints** for the g1t.sh zone (Speed > Optimization >
228Content Optimization, or `early_hints` in the zone settings API). It needs
229a line in `scripts/cloudflare-setup.py` and a note in
230docs/SELF_HOSTING.md: self-hosted installs ignore it harmlessly, because a
231`Link` header is just a header.
232
233Cloudflare remembers `Link: rel=preload` headers from HTML responses and
234sends them as a `103` before the Worker answers the next request for that
235URL. Today g1t.sh sends no `Link` header (checked 2026-10-06). The gain is
236largest exactly where we are slowest: `/flagon-io/g1t` spent 1.3 s in
237loaders, all of which could overlap with fetching the CSS (and, if we
238choose, the two preloaded fonts) on a first visit. Even without a 103, the
239header lets the browser start the CSS as soon as the response headers
240arrive, before parsing any HTML.
241
242Risk: low.
243
244- The asset names are content-hashed, so a cached hint can never point at
245 a stale file. At worst, right after a deploy, a hint names an old hash
246 that still exists in the asset store until it rotates.
247- Early Hints apply only over HTTP/2 and HTTP/3.
248
249### 3. Optional follow-ups
250
251- **Trim what competes with first paint.** The two preloaded fonts (88 KB)
252 are requested before the CSS. With `font-display: swap` and
253 metric-matched fallbacks they never block paint, so their preloads could
254 move after the stylesheet (patch 1 does this). Alternatively, preload
255 only Hanken and let Bricolage (66 KB, headlines only) load from the CSS.
256- **Fewer modulepreloads.** 38 to 58 per page, many under 1 KB (`dist-*`,
257 `access-*`, `skeleton-*`). Grouping small shared chunks in
258 `vite.config.ts` (as `icons` already is) would cut request overhead on
259 first visits. This is a JS question and outside this note.
260
261## Repeat the measurements
262
263```sh
264cd apps/web && npm run build # CSS size: build/client/assets/*.css
265npx lighthouse https://g1t.sh/<page> --preset=desktop --only-categories=performance --output=json
266npx lighthouse https://g1t.sh/<page> --only-categories=performance --output=json # mobile, simulated
267npx lighthouse https://g1t.sh/<page> --throttling-method=devtools --only-categories=performance --output=json # mobile, real throttling
268```
269
270The waterfall is `audits["network-requests"]` in the JSON. The
271render-blocking estimate is `audits["render-blocking-insight"]`.