Field note
26

Field note · Published Aug 20, 2026 · Updated Aug 20, 2026

Optimizing Cubbly After Moving Away from SSR

I compared Cubbly's SSR and client-rendered startup options, measured their trade-offs, and used the results to choose a simpler architecture that reaches useful content sooner.

A field-guide drawing of a Pacific banana slug
In this article7 sections

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.

CompositionWhat it improvesWhat the measurements showedDecision
SSR + hydrationVisible HTML arrives earlyGood perceived startup, but React and Lingui run on both sidesRemove because the complexity was not justified for a private app
Client rendering + serial loadersOne React and Lingui runtimeBundle splitting improved FCP, but LCP stayed effectively flat at 3,484 → 3,536 msKeep the simpler runtime, fix the loading path
Client rendering + bounded bootstrapFewer requests and 58% less critical JSONThe network improved, but the apparent 42% LCP win did not repeatKeep as the data foundation, not as proof of user-visible speed
Client rendering + earlier shell and route splitsCode and data start sooner; Settings loads lessUseful 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.

RouteUseful cold contentSoft navigation
Overview627 → 426 ms55 → 59 ms
Things604 → 410 ms130 → 140 ms
Boxes647 → 415 ms128 → 91 ms
Rooms637 → 370 ms70 → 68 ms
Settings570 → 380 ms370 → 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.

RecommendationEvidenceTrade-off
Keep Cubbly client-renderedIt removes the duplicate React, Lingui, and hydration pathThe browser must assemble the first screen
Keep the bounded /api/bootstrap responseCritical JSON is about 58% smaller and later Thing pages no longer block startupSome routes must request more data when needed
Keep early shell discovery and Settings section splitsUseful cold content is 31–42% sooner; Settings navigation is 81% fasterStartup JavaScript is scheduled earlier rather than becoming materially smaller
Keep SQL count projections instead of a persisted count cacheCounts were already cheap; loading complete Thing records was the expensive workCounts still require a database query
Add a small startup shell nextFCP regressed by 68–132 ms while useful content improvedThe shell must stay small enough that it does not become another misleading LCP candidate
Add production field measurementsLocal repeated samples prove the controlled comparisonReal 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.