I co-founded Domestina in 2014 as a Rails monolith, and for the past 7 years I've been its only engineer. SEO is our main growth channel, so a slow mobile landing page and a Lighthouse score of 44 had me genuinely worried.
Last week I sat down to fix it properly.
The biggest win was unglamorous
One ~360KB CSS bundle was render-blocking every page. It included all of Bootstrap, the full icon set, and two datepickers (react-datepicker, flatpickr) that our landing pages never even load. A landing page was downloading the whole app's CSS just to paint an <h1>.
That monolith used to be a best practice, the app was born on Sprockets, where concatenating everything into one fingerprinted, far-future-cached application.css was the documented best practice. On HTTP/1.1, fewer requests beat smaller payloads. HTTP/2 multiplexing and Core Web Vitals quietly inverted that tradeoff. The problem was also that for 12 years, were were just piling styles into one big file, unrealizing how big it had become.
The optimizations
- I restructured the CSS into page-set bundles on the existing cssbundling-rails + dart-sass setup: shared.bundle (Bootstrap subset + global chrome), then public.bundle, funnel.bundle, dashboard.bundle, blog.bundle. A landing page now loads shared + public only.
- The icon CSS rules were subsetted, but the actual .woff2 was still the full ~2000-glyph file: 131KB → 10.5KB once properly subset (via subset-font, harfbuzz-wasm, no Python). One gotcha: bin/build_icons.js scans for literal bi-* tokens, so dynamically built names like
"bi-#{x}"aren't detected and silently drop from the subset. It also has to scan the Ruby that emits icon markup, not just the views. - Domestina operates in 6 countries, each country running its own instance of the web app, and each comes with its own set of locales. All 9 i18n catalogs, around 754KB, were bundled into every market because
require()inside an object literal is eager. The runtime filter only chose which catalog to store, not which one to bundle. I genuinely thought I'd fixed this before. The real fix was generating a per-market active_locales.generated.js from the market's supported_locales.
This moved the score from 44 to the low 70s in one shot. FCP and Speed Index both went from 5.8s to 1.7s.
I pressed further
I deferred a render-blocking script — a no-brainer win — and the score went down. The culprit was LCP at ~7s. But the observed LCP was ~440ms in every run. The page painted fast.
The 7s number wasn't real
The 7s was Lighthouse's Lantern simulation. Lighthouse runs the page quickly, then estimates timings on a throttled profile: Moto G Power, 1.6 Mbps, 150ms RTT, 4× CPU slowdown.
Under that model, simulated LCP collapses toward total bytes divided by bandwidth. The tell was that Lighthouse's own metricSavings for LCP was 0ms on every audit. There was no single fixable resource left.
And the lab score is not what Google ranks on anyway. For that, Google uses CrUX field data: real Chrome users, 28-day window, 75th percentile. Ours was 100% of mobile URLs "Good", zero over 2.5s LCP, stable for weeks.
The page was already fast for real people.
So I called it a day
Hitting a green 90 would mean optimizing for a low-end phone on a connection slower than almost anyone in our market uses. The byte cuts still help genuine low-end users, so they weren't wasted. But past that point I'd be polishing an artifact, not improving the business.
What I'd take away
- In Lighthouse simulate mode, a bad LCP can be a function of total page weight divided by throttled bandwidth, not paint timing. Look at observed LCP and
metricSavingsbefore believing it. - Judge performance by field data (CrUX / Search Console), not a single-run lab score. That's also what Google ranks on.
- On a Rails/Bootstrap monolith, the highest-leverage fix is usually unglamorous: get render-blocking CSS down to critical-only.
- "I already fixed that" deserves a second look. Filtering usage is not the same as reducing bundle bytes.
- Stop when the fixes stop helping real users.
One honest footnote on the year: I'd tried a version of this last summer and got nowhere. What got me through this time was the latest Claude Code, that could actually follow the thread and push back when I was confidently wrong. I gave it plenty to work with.
Top comments (0)