Breaking the CSR Waterfall: Achieving a 95 Performance Score in a Multi-Tenant Rust/Yew SPA
Building a complex, multi-tenant web application in Rust using Axum and Yew is an incredibly rewarding experience. The type safety is unparalleled, and shipping WebAssembly (WASM) to the browser feels like the future. However, the future comes with a cost. Recently, I audited my central routing hub (mop_dev), and Lighthouse handed me a brutal reality check: a 6.7-second Largest Contentful Paint (LCP) on simulated 4G mobile networks.
My architecture was suffering from the classic Client-Side Rendering (CSR) waterfall. The browser had to download, parse, and instantiate an 860 KiB WASM binary before it could even mount the DOM and paint the primary <h1> tag to the screen.
It was time to tear down the CSR waterfall and rebuild the rendering pipeline. Here is how I architected a fleet-wide optimization rollout, implemented Server-Side Rendering (SSR), and survived the deployment debugging that followed to achieve a 95 Lighthouse Performance score.
Phase 1: Halting the Render-Blocking Bleed
Before tackling the WASM elephant in the room, I needed to clean up the basic asset delivery pipeline. The initial audit flagged a massive payload of render-blocking CSS and oversized images.
CSS Consolidation & Deferral Multiple render-blocking stylesheets (tokens.css, base.css, components.css, animations.css, theme.css) were halting the browser's paint thread. I combined these into a single main.css file. I then updated the <link> in the index.html template to load this single bundle, and explicitly deferred non-critical styles like admin.css using the media="print" onload="this.media='all'" pattern.
Responsive Asset Pipelines The hero image was intrinsically 1000x520—massive overkill for mobile viewports. I used ffmpeg to generate <50KiB mobile-optimized versions (hero-logo-dark.webp and hero-logo-light.webp). I then updated the frontend routing components, substituting generic <img> elements for responsive <picture> blocks specifying a max-width source.
Phase 2: The SSR Silver Bullet
To bypass the WASM download penalty, the Axum backend needed to render the initial HTML shell. This allows the browser to paint immediately, while the WASM bundle downloads in the background to "hydrate" the page.
1. Dual-Target Compilation
The Yew frontend needed to compile for both the browser and the server. I updated the Cargo.toml to compile the frontend into both cdylib (for WASM) and rlib (for the native backend) targets. I strictly gated DOM-specific libraries like gloo down to the wasm32 architecture to prevent backend leakage.
2. The Server-Side Router & Hydration
On the native Linux server, direct browser history doesn't exist. I replaced direct application routing with MemoryHistory::with_entries inside a new exported ServerApp to perform backend SSR rendering.
Crucially, to prevent the frontend from wiping out the server-rendered DOM once the WASM loaded, I re-architected the main entry point to launch via .hydrate() instead of .render().
Rust
// mop_frontend/src/main.rs
#[cfg(target_arch = "wasm32")]
fn main() {
wasm_logger::init(wasm_logger::Config::default());
// Hydrate the existing SSR DOM instead of replacing it
yew::Renderer::<App>::new().hydrate();
}
3. The Axum Bridge
Finally, I wired up mop_frontend::app::render_ssr directly inside the Axum backend via an ssr_fallback_handler. This intercepts wildcard routes, renders the DOM server-side, and injects it into the index.html template before returning it to the client.
Phase 3: The Reality of Deployment (Taming the 520s)
You can test locally all you want, but production is a different beast. Upon deploying the fleet-wide SSR update, the CI/CD pipeline passed, but the live infrastructure immediately started throwing Cloudflare 520 (Web Server Returned an Unknown Error) outages.
Identifying the SSR Panic
The Axum backend was hanging. After diving into the server logs via my custom MCP devops tools, I discovered a fleet-wide SSR safety issue: shared components in mop_frontend_common (used across all spoke apps) were directly accessing browser-only APIs like LocalStorage and window during the initial server rendering phase.
Because the native Linux backend doesn't have a window object, the thread panicked, taking the Axum worker down with it.
The Fix: I aggressively wrapped all LocalStorage and window calls across the frontend crates in conditional compilation flags (#[cfg(target_arch = "wasm32")]). Furthermore, to prevent any rogue component from ever hanging the server again, I implemented a strict 5-second timeout fail-safe in the backend router. If rendering takes too long, it aborts and falls back to a safe state.
Phase 4: PWA and Service Worker Chaos
Once the 520s were resolved, the site loaded instantly, but the UI was degraded. The PWA Service Worker was failing to register, and cached CSS was throwing 404s.
-
Cross-Origin Service Workers: The Service Worker was being registered from https://cdn.mop.dev. Browser security policies strictly block cross-origin Service Worker registrations. I had to resolve these UI issues by implementing a same-origin SW registration and correcting the caching paths to use same-origin relative paths.
-
Cache Purging: Because the static asset hashes didn't fully turn over in the edge nodes, Cloudflare was serving an older cached skeleton that didn't match the new SSR <div id="app"> structure. A manual purge of the main cache cleared the hydration panics.
Conclusion
Performance optimization is rarely just about changing a few configuration flags; it usually requires a fundamental architectural shift. By consolidating assets, enforcing dual-target WASM/Native compilation, and navigating the complexities of hydration and SSR safety guards, the AAAMOP_DEV platform now delivers a lightning-fast 2.4s LCP.
The CSR waterfall is gone, the backend is resilient against rendering panics, and the PWA behaves seamlessly across the fleet. It was a grueling deployment cycle, but seeing that 95 Lighthouse Performance score makes every late-night debug session worth it.