I removed SSR from Cubbly because supporting Lingui in both the Cloudflare Worker and the browser had become more complex than the application needed.
The architectural goal was straightforward: one React runtime, one locale activation, and a Worker that owns authentication, D1, and the API. The performance goal was harder.
SSR made Cubbly seem fast because the browser received visible application HTML before the client finished starting. A client-rendered application begins with less. It has to download JavaScript, activate Lingui, authenticate the user, load inventory, and render the route.
Moving away from SSR only made sense if I could recover that perceived speed and improve the time to useful content.
The decision was not simply whether SSR is good or bad. I needed to compare several compositions of the application and decide which one gave Cubbly the best balance of maintainability, perceived speed, and useful-content speed.
Why SSR came out
Cubbly previously rendered React inside the Worker and then hydrated the same tree in the browser. Lingui had to resolve the locale and activate the same catalog in both runtimes.
That introduced several ways for startup to fail. The server and browser could disagree about the locale, catalog, or initial tree. Changes had to work during server rendering and hydration even though Cubbly’s main interface is private and database-backed.
I replaced that setup with one client-rendered Vite application. Hono still handles trusted server work. React Router owns navigation and rendering. Lingui activates once before the router starts.
The result is easier to maintain, but removing SSR also exposed every dependency in the browser startup path.
The options were not equivalent
Each version solved a different part of the problem. Comparing them made it easier to avoid choosing an architecture from one metric alone.
| Composition | What it improves | What the measurements showed | Decision |
|---|---|---|---|
| SSR + hydration | Visible HTML arrives early | Good perceived startup, but React and Lingui run on both sides | Remove because the complexity was not justified for a private app |
| Client rendering + serial loaders | One React and Lingui runtime | Bundle splitting improved FCP, but LCP stayed effectively flat at 3,484 → 3,536 ms | Keep the simpler runtime, fix the loading path |
| Client rendering + bounded bootstrap | Fewer requests and 58% less critical JSON | The network improved, but the apparent 42% LCP win did not repeat | Keep as the data foundation, not as proof of user-visible speed |
| Client rendering + earlier shell and route splits | Code and data start sooner; Settings loads less | Useful cold content improved 31–42%; Settings soft navigation improved 81% | Use this composition |
The final choice combines the simpler client-rendered ownership model with a bounded server response and earlier route-code discovery. No single change produced the complete result.
The client was doing too much before rendering
The first client-rendered version discovered Cubbly one request at a time:
/api/me
↓
/api/homes
↓
/api/home + /api/activity
↓
authenticated React app
Each response unlocked the next request. The Home response also loaded every Thing before any route could render. Settings paid for the same inventory work as Things.
I changed the startup path in three places.
First, I replaced the serial account, Home, inventory, and Activity requests with one /api/bootstrap request. Authentication and Home selection now happen once inside the Worker.
Second, I limited startup to 24 recent Things. Room and Box totals remain SQL COUNT(...) projections, while Activity and later Thing pages load only when the interface needs them. This reduced the critical JSON by about 58% without adding a count cache that would need its own invalidation logic.
Third, I changed when the browser discovers application code. The authenticated shell now loads before /api/bootstrap starts. Settings has a small route shell and loads account, passkey, import, connector, notification, and Box-category code only for the selected section.
The first two changes reduced requests and data. The third improved the time to useful content.
Measuring the result
I compared f06cf3b0 before the route-loading change with ff9a61db after it. Both production builds used the same copied D1 database containing 13 Rooms, 47 Boxes, and 74 Things. The browser stayed at 1440 × 1000 with no throttling.
I collected five cache-disabled cold loads for Overview, Things, Boxes, Rooms, and Settings. I also collected five fresh soft-navigation sequences through the same routes. 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 arrived 31–42% sooner across all five routes. Settings soft navigation improved from 370 to 70 milliseconds, or 81%. Boxes improved by 29%. Overview, Things, and Rooms were effectively unchanged during soft navigation.
Observed LCP improved by 29–41%, but LCP required more care than the summary number suggests. One earlier benchmark appeared to improve LCP by 42%, from 6,685 to 3,859 milliseconds. A verification run measured 6,993 milliseconds instead.
Chrome had selected different elements. The faster result measured the loading placeholder. The slower result continued until useful inventory became the largest painted element. A loading screen can make LCP look better without making the application more useful.
That is why I kept the route-specific content marker in the final comparison.
Beating SSR’s perceived speed has a cost
FCP became 68–132 milliseconds slower after the route-loading change. The earlier version painted a large placeholder quickly. The new version stays blank slightly longer, then reaches useful content roughly 200–267 milliseconds sooner.
This is the main trade-off. SSR and large loading states are good at showing something early. That does not mean the user can do anything with it.
For Cubbly, I prefer real inventory sooner even if the first visual acknowledgement is slightly later. A future lightweight shell could reduce that blank interval, but it should not become another large placeholder that improves the metric without improving the experience.
The repeated benchmark also found a production-only Lingui race. My first implementation imported the router and locale catalog in parallel. On a fast production load, the router evaluated translated display options before Lingui had activated the locale, leaving Things blank. Activating Lingui before importing the router fixed it while preserving the earlier code discovery.
What the measurements recommend
The benchmark gives me a concrete next-step list instead of a general instruction to make the app faster.
| Recommendation | Evidence | Trade-off |
|---|---|---|
| Keep Cubbly client-rendered | It removes the duplicate React, Lingui, and hydration path | The browser must assemble the first screen |
Keep the bounded /api/bootstrap response | Critical JSON is about 58% smaller and later Thing pages no longer block startup | Some routes must request more data when needed |
| Keep early shell discovery and Settings section splits | Useful cold content is 31–42% sooner; Settings navigation is 81% faster | Startup JavaScript is scheduled earlier rather than becoming materially smaller |
| Keep SQL count projections instead of a persisted count cache | Counts were already cheap; loading complete Thing records was the expensive work | Counts still require a database query |
| Add a small startup shell next | FCP regressed by 68–132 ms while useful content improved | The shell must stay small enough that it does not become another misleading LCP candidate |
| Add production field measurements | Local repeated samples prove the controlled comparison | Real devices and networks may rank the trade-offs differently |
These recommendations are deliberately narrower than “restore SSR” or “split everything.” The measurements show which parts created value and where the remaining cost sits.
Benchmarking is easier now
This comparison would have taken much longer to do manually. It requires checking out two versions, preparing the same database, clearing caches, repeating routes, recording multiple metrics, identifying LCP elements, and calculating medians.
A coding agent handled most of that repetition. It prepared isolated builds, ran the same navigation sequence, collected the samples, and inspected the traces. That made it practical to continue after the first result appeared to confirm the optimization.
The automation did not decide what fast meant. I still had to decide that useful inventory mattered more than a placeholder and that the FCP regression was acceptable. It gave me enough evidence to make those decisions instead of relying on the architecture alone.
That is the useful change in how I work. Benchmarking is no longer only a final score after the implementation. I can use it while choosing between implementations. The agent can combine bundle output, network traces, browser metrics, route-specific timings, and repeated samples; I can use that synthesis to decide which trade-offs fit the product.
Measurement is also easier to justify now because the setup and repetition cost less. If two architectures are plausible, I do not have to settle the question from intuition or framework conventions. I can build both boundaries, measure the same tasks, and keep the one whose costs I understand.
Removing SSR solved the React and Lingui complexity. The follow-up work made the client-rendered version reach useful content sooner. Both parts mattered. A simpler application that feels slower is not a complete improvement.



