Field note
26

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

Adding French Made Me Remove React SSR From Cubbly

Adding French exposed an unnecessary React SSR bridge. Removing it simplified Cubbly, but useful inventory content arrived later.

A field-guide drawing of a black-tailed deer
In this article7 sections

I started by trying to add French to Cubbly. I ended by removing React server-side rendering.

The problem was not that Lingui made translation slow. The problem was that Cubbly’s custom SSR architecture made every translated render happen twice. The Worker and browser each needed the compiled messages, the same locale, and the same initial React tree. Then the browser still had to download JavaScript and hydrate the server output.

Removing React SSR solved that coordination problem instead of adding another bridge around it. Cubbly still has a Cloudflare Worker. It still uses Hono for APIs, authentication, MCP, and public shared-box pages. The browser now owns the private React interface, including Lingui. French and Japanese work, macros compile normally, and a visitor downloads only the selected language catalog.

The browser benchmark made the result less tidy. Cubbly paints sooner, but its useful inventory content arrives later. Removing SSR exposed a data-loading waterfall that the old HTML had hidden.

Why Lingui exposed the wrong boundary

The original arrangement had two copies of the same interface involved in opening a page:

Request

Cloudflare Worker + Hono render React into HTML

Browser displays that HTML

Browser downloads React and renders the app again

React attaches behavior to the existing HTML

That last handoff is hydration. It works well when a framework owns both sides. Cubbly was not using React Router’s Framework Mode, though. I had built a custom bridge between Hono, Vite, Lingui, and React Router’s browser router.

Lingui has two relevant jobs. At build time, its compiler transforms message macros in the React modules. At runtime, the application loads and activates a catalog for the selected locale. Neither job is unusual in one browser application.

The custom SSR bridge duplicated the runtime job. Adding a language meant both React renders had to choose the same catalog at exactly the right time. The Worker needed to read the language, load and activate the catalog, render the translated HTML, and serialize that choice for the browser. The browser then needed to load and activate the same catalog before hydration. A disagreement could produce flickering text, hydration warnings, or controls that looked present but were not actually alive.

Supporting Lingui was not inherently complex. Supporting it across two independently started React runtimes was. None of those tools was individually broken; I had made Cubbly own a difficult seam that the product did not need.

Why removing SSR is the right solve

The private application is a client-rendered single-page app. The Worker remains a backend.

Before · React in two places
One private React application
WorkerReact SSRrender HTML and coordinate locale
BrowserHydrationrender again and attach behavior
Now · one owner for the interface
Cloudflare serves the small SPA entry
React runs in the browser
Worker + HonoBackendAPI, auth, MCP, public shares
React RouterInterfaceloads screens when needed
I did not remove the server. I removed React from the server. The Worker now does the jobs that require a trusted backend, while the browser owns the private interactive interface.

React Router 8 does not require Framework Mode. Cubbly uses createBrowserRouter in Data Mode and its lazy route objects. Hono does not compete with it because Hono owns server endpoints and React Router owns browser navigation.

Cloudflare’s official Vite plugin builds both the Worker and the browser assets. Wrangler still deploys the result and manages Cloudflare resources. There is one build command without pretending that the backend and interface are the same program.

This boundary fits the product. The inventory application is private and authenticated, so its HTML has little public search value. Every useful screen needs JavaScript for search, editing, dialogs, and navigation, so SSR never removed the client runtime. Public shared-box pages still use Worker-rendered HTML because server output has a clear purpose there.

The alternative was to maintain locale resolution, catalog activation, serialization, and hydration across both runtimes. Removing the unnecessary renderer deletes those responsibilities. It solves the source of the complexity instead of making the bridge more elaborate.

How a language is saved now

The French language is saved as the short code fr in a browser cookie named cubbly_locale. Japanese is ja, and English is en.

The cookie is the preference, not the translation. The translated messages live in separate Lingui catalogs. On startup, Cubbly reads the cookie, imports only that catalog, activates it, and then mounts React:

cubbly_locale=ja

Import Japanese messages

Activate ja in Lingui

Start React with Japanese text

English catalog  ✕ not requested
French catalog   ✕ not requested

Changing the selector to French imports the French catalog on demand and updates the page immediately. It also changes the document’s lang attribute and saves cubbly_locale=fr for the next visit. There is no full-page reload and no Worker-side React instance to coordinate.

The application code can use Lingui macros such as:

<Plural value={count} one="# thing stored" other="# things stored" />

Vite transforms that macro during the build. At runtime, Lingui uses the active locale’s plural rules and catalog. French can select one for one object and other for multiple objects; Japanese can use one form for both. The component does not need to contain a hand-written language switch.

French and Japanese are no longer token examples. Each compiled catalog contains all 395 messages currently extracted from Cubbly, with zero missing translations.

Translation exposed a loading problem

Once the architecture was simpler, I looked at the slow load the way a visitor experiences it: why download code for Settings, QR labels, drag-and-drop controls, and every language before using any of them?

The first production build put a 551.43 kB raw, 163.22 kB gzip application entry in front of the browser, in addition to vendor chunks. Its emitted initial JavaScript graph was roughly 365 kB gzip. Those are Vite build-output sizes, not a measurement of a visitor’s connection or Core Web Vitals, but they were enough to show that too much code was eager.

I split Cubbly at boundaries a person can understand:

  • The signed-out landing page no longer imports the authenticated application.
  • React Router loads inventory screens and Settings only when their routes are opened.
  • QR generation loads only when a QR label is needed.
  • English, French, and Japanese are separate catalog chunks.
  • Cubbly no longer forces unrelated UI libraries into one eagerly preloaded vendor group.

The new entry is 12.95 kB raw and 3.96 kB gzip. The emitted initial preload graph is about 152 kB gzip, plus one selected locale catalog. The larger authenticated screens still exist; they have moved behind the actions that need them.

Build outputBeforeAfter
Main application entry551.43 kB / 163.22 kB gzip12.95 kB / 3.96 kB gzip
Initial JavaScript graphabout 365 kB gzipabout 152 kB gzip + one locale
Language messagesall bundled eagerlyEnglish 5.92, French 7.08, or Japanese 7.59 kB gzip
Authenticated app shellinside the entry42.59 kB gzip, on demand
Inventory route screensinside the entry28.68 kB gzip, on demand
Settingsinside the entry0.83 kB route + 10.05 kB screen, on demand
QR generatorinside the entry8.86 kB gzip, on demand

Bundle splitting is not free. A first visit to a lazy screen may make another network request, and too many tiny chunks can become counterproductive. Caching makes later visits cheaper, but it cannot rescue a poor first split. I therefore split around screens, authentication, QR work, and locale catalogs rather than turning every component into its own file.

The benchmark is the evidence, including the cost

I then compared the commits on both sides of each change:

  • SSR removal: 938354bd2535b1e7
  • Bundle splitting: dca9232fb946b198

Each commit ran as a production Vite preview against the same authenticated inventory: 9 rooms, 43 boxes, and 55 things. I measured Overview, Things, Boxes, Rooms, and Settings five times each in headless Chromium at 1440 × 1000. The browser used a 4× CPU slowdown, 40 ms network latency, 10 Mbps down, and 2 Mbps up.

A cold load started with the authentication cookie already present, an empty browser cache, and a direct request to the screen. A soft navigation started with Overview loaded, then followed the same in-app sequence through Things, Boxes, Rooms, Settings, and back to Overview. That makes the Settings number a first visit to its lazy chunk, not a repeat visit after caching. Every number below is the median of five samples in milliseconds.

Removing SSR changed which part was slow

ScreenFCP, SSR → clientLCP, SSR → clientSoft navigation, SSR → client
Overview1,656 → 1,5321,852 → 3,352229 → 328
Things2,020 → 1,5242,440 → 3,792609 → 483
Boxes1,756 → 1,5362,096 → 3,728512 → 740
Rooms1,700 → 1,5481,820 → 3,416298 → 363
Settings1,584 → 1,6721,584 → 3,356235 → 286

Across the five screen medians, First Contentful Paint improved 10%, DOMContentLoaded improved 49%, and observed long-task time fell 53%. The local SSR preview spent roughly 1.1–1.5 seconds rendering the document in the Worker. The client-rendered build served its static document in single-digit milliseconds. Those local TTFB values are not production latency measurements, but they show where the work moved.

Largest Contentful Paint moved the other way: the median screen result increased from 1,852 ms to 3,416 ms. SSR put useful inventory HTML in the response. The client-rendered app painted its loading interface sooner, then waited for authentication and inventory requests before it could paint the largest useful content. Soft navigation was mixed and 22% slower at the median screen; Things improved, while the other four screens regressed.

I still prefer removing this custom SSR bridge. The measurement changes the reason. This was a reduction in architecture and main-thread work, not a free improvement to every loading metric. The client data path became the next bottleneck.

Bundle splitting made the first paint earlier

ScreenFCP, before → splitLCP, before → splitFirst soft navigation, before → split
Overview1,672 → 1,0723,524 → 3,536238 → 284
Things1,716 → 1,0043,484 → 3,452581 → 570
Boxes1,656 → 1,1363,740 → 4,444737 → 822
Rooms1,684 → 1,1323,484 → 3,804341 → 397
Settings1,680 → 1,1003,408 → 3,476287 → 627

The median screen reached First Contentful Paint 35% sooner and DOMContentLoaded 45% sooner. Long-task time fell 17%. Resource Timing reported 1.46 MB transferred before the split and 1.33 MB after it, an 8% reduction across the median screen.

The browser also made more requests: the median cold load went from 16 to 46. Largest Contentful Paint stayed nearly flat overall, moving from 3,484 to 3,536 ms. A separate DevTools trace of the split Overview recorded a 4,236 ms LCP with only 34 ms of TTFB; 4,202 ms was render delay. The smaller entry gets the browser started earlier, but it does not make the authenticated data arrive earlier.

Settings exposes the lazy-loading bill most clearly. Its first soft navigation increased from 287 to 627 ms because that click now fetches the Settings route and screen chunks. Once cached, later visits avoid that download, but this benchmark deliberately measured the first visit.

The split did what the build output suggested for initial work. It did not improve LCP, and it traded one large graph for many requests plus a slower first visit to lazy screens. I implemented the first follow-up in I Shortened Cubbly’s Data Waterfall. LCP Did Not Follow.. One typed bootstrap request replaced the serial account, Home, inventory, and activity discovery chain. The request graph became measurably simpler, but LCP did not become repeatably faster because the bootstrap still waits for the complete inventory. The next solve is route-sized data, not more bundle splitting and not a return to SSR.

How I proved the deployed version works

A successful build proves that files exist. It does not prove that the correct files load or that the production Worker serves them. I checked the chain in layers:

  1. 01
    Static checksTypeScript and Biome
    both pass
  2. 02
    Tests50 test files
    395 tests pass
  3. 03
    BuildInspect emitted chunks
    routes and locales split
  4. 04
    Local browserCold-load each locale
    only one catalog loads
  5. 05
    ProductionOpen with Japanese saved
    Japanese UI renders
  6. 06
    Backend canaryCreate, update, read, delete
    33 MCP tools pass
Each check answers a different question. Tests cover behavior, the build exposes chunk boundaries, the browser shows which catalog was requested, and the canary confirms that simplifying the interface did not damage the Worker backend.

In production, a session saved as Japanese requested the Japanese messages chunk and rendered Japanese text. It did not request the English or French catalog. The deployed release reported commit b946b198, and the post-deploy canary completed create, update, verification, and cleanup across all 33 exposed MCP tools.

The trade-off

SSR can make public, content-heavy pages appear sooner and can help search engines receive complete HTML. Cubbly’s private inventory application sits behind authentication and immediately needs JavaScript for dialogs, search, editing, and navigation. Its custom SSR bridge duplicated complexity without removing the need for the client bundle.

Client rendering means the private interface can initially show a loading state while authentication and data arrive. That is now the honest performance target: keep the entry small, cache stable chunks, load the selected language only, and make the API fast. Public shared-box pages can still be rendered by the Worker where server HTML has a clear purpose.

The easiest Cloudflare architecture for Cubbly was not “no server.” It was no React on the server. Removing that one responsibility solved the Lingui compiler boundary, removed hydration and request-locale coordination, and made ordinary Vite bundle splitting possible.

The architecture is less impressive on a diagram. I trust it more, and now I know exactly what it costs.