I had made Cubbly easier to maintain and slower to show useful inventory.
That was the result after I removed React SSR to keep Lingui in one browser runtime. The browser painted a loading state quickly, but Largest Contentful Paint moved from a median 1,852 to 3,416 milliseconds. The old Worker response had included inventory HTML; the new application had to authenticate and fetch it.
I did not want to restore the architecture I had just removed. I also did not want “simpler” to become an excuse for a slower product. The next decision was how much of SSR’s perceived speed I could recover while keeping the private application client-rendered.
I chose a bounded bootstrap response, earlier route-code discovery, and smaller Settings sections. Across five routes, useful cold content arrived 31–42% sooner. The first visual paint became slightly slower, which is a trade-off I accept because the user reaches real inventory sooner.
The waterfall I had exposed
The first client-rendered version discovered the application in sequence:
/api/me
↓
/api/homes
↓
/api/home + /api/activity
↓
authenticated React app
Each response unlocked the next request. /api/home also returned every Thing before any route could render. That might be defensible for the Things screen, but Settings paid for the same inventory even when the user only wanted to change an account preference.
Bundle splitting had already made the JavaScript entry smaller. It did not help this chain because the browser still discovered code and data too late.
I compared compositions, not isolated tricks
There were several plausible fixes, and each optimized a different thing:
| Composition | What it bought | Why it was not enough |
|---|---|---|
| SSR + hydration | Inventory in the initial HTML | Restored the duplicate React and Lingui path |
| Client rendering + serial loaders | One interface runtime | Preserved the request waterfall |
| One complete bootstrap response | Fewer dependent requests | Still blocked every route on the full inventory |
| Bounded bootstrap + earlier route code | Less critical data and earlier useful work | Slightly delayed the first paint |
The last option fit the product best. It kept the simple ownership model and moved fewer unnecessary things into the critical path.
What changed
First, I replaced the serial account, Home, inventory, and Activity discovery with one /api/bootstrap request. Authentication and Home selection now happen once inside the Worker.
Second, startup returns only 24 recent Things. Room and Box totals use SQL COUNT(...) projections. Activity and later Thing pages load when a route needs them.
I considered persisting the counts as cached derived values. The query showed that counts were already cheap; loading full Thing records was the expensive work. A cache would add invalidation rules to optimize the wrong part. Keeping a projection is less clever and easier to trust.
Third, the authenticated shell begins loading before /api/bootstrap finishes. Settings now has a small route shell and loads account, passkey, import, connector, notification, and Box-category code only for the selected section.
The bootstrap removed unnecessary request dependencies and cut critical JSON by about 58%. Earlier code discovery is what made useful route content arrive sooner.
The benchmark
I compared commit f06cf3b0 before the route-loading change with ff9a61db after it. Both production builds used the same copied D1 database with 13 Rooms, 47 Boxes, and 74 Things.
I collected five cache-disabled cold loads and five fresh soft-navigation sequences across Overview, Things, Boxes, Rooms, and Settings at 1440 × 1000. Alongside FCP and LCP, I timed a route-specific marker for useful content.
| Route | Useful cold content | Soft navigation |
|---|---|---|
| Overview | 627 → 426 ms | 55 → 59 ms |
| Things | 604 → 410 ms | 130 → 140 ms |
| Boxes | 647 → 415 ms | 128 → 91 ms |
| Rooms | 637 → 370 ms | 70 → 68 ms |
| Settings | 570 → 380 ms | 370 → 70 ms |
Useful cold content improved by 31–42% on every route. Settings soft navigation improved from 370 to 70 milliseconds, or 81%. Boxes improved by 29%. The other three soft navigations were close enough that I treat them as unchanged.
FCP went in the other direction. It became 68–132 milliseconds slower because the previous version painted a large loading placeholder almost immediately. The new version stays blank for slightly longer, then reaches useful content roughly 200–267 milliseconds sooner.
I prefer that composition. A placeholder acknowledges the click, but it is not the inventory the user came to see. I can add a small shell later if the blank interval feels bad, as long as the shell does not become another large element that makes the metric look good while hiding the actual wait.
LCP briefly told me the wrong story
One earlier run appeared to improve LCP by 42%, from 6,685 to 3,859 milliseconds. A verification run measured 6,993 milliseconds instead.
The code had not suddenly regressed. Chrome had selected different LCP elements. In the faster-looking run it measured the loading placeholder. In the slower run it continued until useful inventory became the largest painted element.
The final comparison includes a route-specific content marker alongside LCP. Before I use LCP to choose an architecture, I need to know which element Chrome measured.
Gabriel Scherer’s Measure–Explain–Test–Improve loop describes what happened here. The first result created a plausible explanation. Repeating the test and inspecting the measured element showed that the explanation was incomplete.
The repeated run also found a production-only Lingui race. I initially imported the router and locale catalog in parallel. On a fast production load, translated display options could evaluate before Lingui activated the locale, leaving Things blank. Activating Lingui before importing the router fixed the race while retaining earlier discovery of the rest of the application.
What I am keeping
I am keeping the client-rendered architecture and these three loading changes:
| Keep | Evidence | Cost |
|---|---|---|
Bounded /api/bootstrap | About 58% less critical JSON | Later Thing pages make another request |
| SQL count projections | Counts were not the slow part | Counts still query D1 |
| Early shell and Settings discovery | Useful cold content 31–42% faster; Settings navigation 81% faster | FCP is 68–132 ms slower |
I am not restoring SSR, persisting count caches, or splitting every component. Those are all possible optimizations, but the benchmark does not justify their maintenance cost.
The next useful measurement is production field data. These repeated local samples make the two versions comparable, but real devices, networks, and inventories may rank the trade-offs differently.
Why I measure more often now
This comparison required two isolated production builds, the same database, repeated cache states, five routes, multiple metrics, and inspection of the LCP elements. I used to reserve that amount of setup for a larger performance project.
A coding agent handled the repeated builds and measurements while I was still deciding. Simon Willison describes writing code becoming cheap while good code still has a cost. The same applies to experiments. I still had to notice that Chrome had changed LCP elements and that the translated route sometimes went blank. Another run helped because I inspected the odd result instead of averaging it away.
I am keeping the single browser renderer and the shorter waterfall. Useful content arrives sooner, FCP is slightly worse, and I do not have to restore the duplicate Lingui path.
One quick signal
Did this earn your time?
Thanks. That gives me something concrete to check.


