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 reviewer | 1 | # How g1t.sh ships CSS |
| 2 | ||
| 3 | Internal research, 2026-10-06. Prompted by GitHub's "Improving site | |
| 4 | performance by shipping more CSS" (github.blog, engineering). Read-only: | |
| 5 | nothing here is applied yet. The measurements can be repeated with the | |
| 6 | commands 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 | ||
| 37 | GitHub's Primer design system and github.com styled React components with | |
| 38 | styled-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 | ||
| 45 | The fix was CSS Modules: plain `.module.css` files colocated with | |
| 46 | components and compiled to static stylesheets "sent as part of the HTML | |
| 47 | for a page", with no client or server runtime. The title's "shipping more | |
| 48 | CSS" means more static CSS bytes in exchange for no runtime styling work. | |
| 49 | ||
| 50 | How they did it: | |
| 51 | ||
| 52 | 1. **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. | |
| 55 | 2. **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. | |
| 59 | 3. **Theming, 2026.** They moved off styled-components theme utilities to | |
| 60 | CSS variables (`@primer/css`). | |
| 61 | ||
| 62 | Measured results (server-side only; the post gives no client metrics such | |
| 63 | as 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 | ||
| 71 | What we take from it: the expensive thing was *runtime* styling. GitHub's | |
| 72 | end state is static CSS in a stylesheet, which is where apps/web already | |
| 73 | is. | |
| 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 | ||
| 89 | The order matters because the browser asks for resources in document | |
| 90 | order 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 | |
| 92 | subresources were requested between 645 and 664 ms. The stylesheet was the | |
| 93 | 61st. 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 | ||
| 105 | Chrome gives the stylesheet top priority. Cloudflare's HTTP/3 prioritization | |
| 106 | should favor it on a real connection more than DevTools throttling does, | |
| 107 | so these absolute numbers are pessimistic. The direction holds either way: | |
| 108 | nothing paints until the CSS arrives, and the CSS is queued behind about | |
| 109 | 450 KB that is not needed for first paint. | |
| 110 | ||
| 111 | ## Measurements | |
| 112 | ||
| 113 | The build is `npm run build` in apps/web, run locally on 2026-10-06. Its | |
| 114 | CSS hash `KP0DOluu` matches production. CSS requests are counted from the | |
| 115 | live 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 | ||
| 124 | Lighthouse 13.5.0, headless Chrome, signed out, performance only. The | |
| 125 | default mode is simulated throttling. "CSS est." is Lighthouse's | |
| 126 | render-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 | ||
| 143 | What 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 | ||
| 170 | Neither patch is applied. Both are small. | |
| 171 | ||
| 172 | ### 1. Stylesheet first in `<head>` (apps/web/app/root.tsx) | |
| 173 | ||
| 174 | Import 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 | |
| 176 | head, which it writes before bulk preloads such as `modulepreload`. Nothing | |
| 177 | else 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 | ||
| 190 | Verify before shipping: | |
| 191 | ||
| 192 | 1. 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). | |
| 196 | 2. 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. | |
| 200 | 3. 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 | ||
| 205 | Expected gain: the stylesheet goes from the 61st request to the 1st. On a | |
| 206 | bandwidth-limited first visit it then finishes before most of the JS | |
| 207 | rather 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 | |
| 209 | connections. There is no change on desktop broadband or repeat visits. | |
| 210 | ||
| 211 | Risk: low. The same file, the same blocking semantics, a different | |
| 212 | position. 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 | ||
| 216 | In `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. | |
| 221 | const css = routerContext.manifest.routes.root?.css ?? []; | |
| 222 | if (css.length > 0) { | |
| 223 | responseHeaders.append("Link", css.map((href) => `<${href}>; rel=preload; as=style`).join(", ")); | |
| 224 | } | |
| 225 | ``` | |
| 226 | ||
| 227 | Turn on **Early Hints** for the g1t.sh zone (Speed > Optimization > | |
| 228 | Content Optimization, or `early_hints` in the zone settings API). It needs | |
| 229 | a line in `scripts/cloudflare-setup.py` and a note in | |
| 230 | docs/SELF_HOSTING.md: self-hosted installs ignore it harmlessly, because a | |
| 231 | `Link` header is just a header. | |
| 232 | ||
| 233 | Cloudflare remembers `Link: rel=preload` headers from HTML responses and | |
| 234 | sends them as a `103` before the Worker answers the next request for that | |
| 235 | URL. Today g1t.sh sends no `Link` header (checked 2026-10-06). The gain is | |
| 236 | largest exactly where we are slowest: `/flagon-io/g1t` spent 1.3 s in | |
| 237 | loaders, all of which could overlap with fetching the CSS (and, if we | |
| 238 | choose, the two preloaded fonts) on a first visit. Even without a 103, the | |
| 239 | header lets the browser start the CSS as soon as the response headers | |
| 240 | arrive, before parsing any HTML. | |
| 241 | ||
| 242 | Risk: 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 | |
| 264 | cd apps/web && npm run build # CSS size: build/client/assets/*.css | |
| 265 | npx lighthouse https://g1t.sh/<page> --preset=desktop --only-categories=performance --output=json | |
| 266 | npx lighthouse https://g1t.sh/<page> --only-categories=performance --output=json # mobile, simulated | |
| 267 | npx lighthouse https://g1t.sh/<page> --throttling-method=devtools --only-categories=performance --output=json # mobile, real throttling | |
| 268 | ``` | |
| 269 | ||
| 270 | The waterfall is `audits["network-requests"]` in the JSON. The | |
| 271 | render-blocking estimate is `audits["render-blocking-insight"]`. |