Field note
26

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

Removing React SSR from Cubbly to Simplify Lingui

I removed React SSR from Cubbly because maintaining Lingui across the Worker and browser was not worth the speed it bought on a private application.

A field-guide drawing of a vintage camping lantern
In this article5 sections

Adding French to Cubbly was supposed to be translation work. It became an architecture decision.

Cubbly rendered the same React application twice: first in a Cloudflare Worker, then again in the browser during hydration. Lingui therefore needed to resolve and activate the same language catalog in two runtimes before those renders could agree.

I could have built more machinery to coordinate them. Instead, I removed React SSR from the private application.

I chose client rendering because language support needs to remain understandable as Cubbly grows. The trade-off is real: SSR put useful inventory in the initial HTML, while the browser now has to start React and fetch data before it can show the same content. I was willing to accept some startup cost for a simpler system, but I wanted to know exactly how much I was giving up.

Why Lingui made the duplication obvious

Lingui was not unusually difficult. It compiles message macros at build time, then loads and activates a catalog at runtime. In a browser application, that is one straightforward path.

Cubbly’s custom SSR setup turned it into two paths:

ResponsibilityWorkerBrowser
Determine the localeYesYes
Load the catalogYesYes
Activate LinguiYesYes
Render the private interfaceYesYes
Produce an identical initial treeYesYes

If either side chose a different locale or reached a slightly different initial state, the result could be translated text changing after load, a hydration warning, or an interface that looked ready before its controls worked.

The Chrome team’s rendering guide calls this “one app for the price of two”. That description is intentionally incomplete as a verdict: the same guide explains that SSR often produces a faster first paint. The duplication buys something. The question is whether that benefit justifies owning both sides.

For Cubbly’s private interface, I do not think it does. The screens sit behind authentication and need JavaScript for search, navigation, editing, and dialogs. There is little searchable HTML to preserve, and hydration never removes the browser application.

Public shared-box pages are different. They remain Worker-rendered HTML because they are public, linkable, and useful without the authenticated React application. I did not decide that SSR is bad. I decided that it should remain only where it has a clear product job.

The architecture I chose

The private application is now a regular React and Vite SPA. React Router owns browser navigation. Lingui activates once in the browser. Hono remains the backend for authentication, APIs, uploads, MCP, and public Box pages.

Cloudflare’s Vite plugin still builds the browser assets and Worker together. React Router still provides Data Mode and lazy route objects. Removing SSR did not remove the server; it stopped the server from rendering the same private React tree as the browser.

The locale is stored as en, fr, or ja in the cubbly_locale cookie. At startup, the browser imports that one compiled catalog, activates it, and mounts React. Changing the language imports another catalog on demand and updates the page without a reload.

French and Japanese each contain all 395 messages currently extracted from Cubbly. The next language now follows the same browser-only path instead of widening a custom bridge.

This is the kind of boring boundary I want to maintain. Dan McKinley’s Choose Boring Technology argues that familiar technology is valuable partly because its failure modes are understood. React, Hono, and Lingui were not individually unusual here. The unusual part was making them coordinate across two independently started renderers.

Making the browser carry less

Once the browser became responsible for startup, I looked at how much code it had to load before showing anything useful.

The original application entry was 551.43 kB raw and 163.22 kB gzip. Its initial emitted JavaScript graph was roughly 365 kB gzip. Settings, QR generation, authenticated screens, and every language were too eager.

I split around product boundaries rather than individual components: signed-out versus authenticated screens, inventory routes, Settings, QR generation, and locale catalogs.

Build outputBeforeAfter
Main application entry163.22 kB gzip3.96 kB gzip
Initial JavaScript graphabout 365 kB gzipabout 152 kB gzip + one locale
Language messagesevery catalog eagerone 5.92–7.59 kB catalog
Settings and QR codeeagerloaded when needed

Those numbers proved that less code was eager. They did not prove that the application was faster.

What removing SSR actually cost

I compared two commit pairs:

  • SSR removal: 938354bd2535b1e7
  • Bundle splitting: dca9232fb946b198

Each production build used the same authenticated inventory: 9 Rooms, 43 Boxes, and 55 Things. I measured cold loads and soft navigation across Overview, Things, Boxes, Rooms, and Settings. Chromium ran with a 4× CPU slowdown, 40 ms latency, 10 Mbps down, and 2 Mbps up. Every result below is the median of five samples.

ChangeImprovementCost
Remove SSRFCP 10% faster; DOMContentLoaded 49% faster; long-task time 53% lowerLCP increased from 1,852 to 3,416 ms; median soft navigation was 22% slower
Split bundlesFCP 35% faster; DOMContentLoaded 45% faster; transferred bytes 8% lowerLCP stayed roughly flat at 3,484 → 3,536 ms; first Settings navigation increased from 287 to 627 ms

The browser painted earlier and spent less time blocked, but useful inventory arrived later. SSR had included that inventory in the document. The client-rendered version first showed its loading interface, then waited for authentication and data.

Bundle splitting helped the browser begin sooner but could not shorten that data path. It also increased the median cold-load request count from 16 to 46. Settings made the trade-off visible: its first soft navigation became slower because that click now fetched the route and screen chunks. Later visits can use the cache, but the first one still matters.

I do not call this a performance win. The entry became 98% smaller, but useful content still waited on data. The benchmark kept me from passing off a build artifact as a product improvement.

Why I am keeping it

I am keeping the client-rendered architecture because the private interface now has one clear owner and one language runtime. That is easier for me to understand, change, and debug. It is also a well-traveled setup rather than a custom version of framework-level SSR.

The measurements changed the follow-up, not the architectural decision:

QuestionDecision
Does private inventory need searchable HTML?No; keep React in the browser
Do public Box pages benefit from complete HTML?Yes; keep Worker rendering there
Should I undo route splitting to improve LCP?No; LCP is waiting mainly on data
What should I fix next?The authenticated data waterfall and route-sized responses

I did that work next in Optimizing Cubbly After Moving Away from SSR. The first attempt made the request graph cleaner without producing a repeatable LCP improvement. That was useful evidence too: it narrowed the problem from “client rendering is slow” to the timing of code discovery, data loading, and useful content.

Removing SSR made startup slower and gave Cubbly one browser rendering path. I can live with that maintenance trade. The benchmark told me exactly where to work next: the authenticated data waterfall.

One quick signal

Did this earn your time?

What was missing?

Thanks. That gives me something concrete to check.