How I improved RankMySEO sign-in performance

Table of Contents
RankMySEO is a rank tracker I build and run. Many of its users open the dashboard on a phone, on a mobile network in Indonesia that is often busy. In a lab test I measured what that feels like, fixed what was slow, and measured again.
On Chrome Slow 4G with an empty cache, sign-in first contentful paint (FCP) fell from 7.4 seconds to 1.6 seconds. The page downloads 295 KB instead of 1,368 KB. This post covers the method, each change, the restructure that came first, and one change I decided against.
These figures are a lab before-and-after on one setup. They are not a real-world guarantee.
The setup
The stack is a React single-page app built with Vite, served from Cloudflare Workers static assets. The API is a separate Node service. The marketing site is prerendered HTML that hydrates later.
I measured production builds served with compression, under these conditions:
- Network: Slow 4G through the DevTools Protocol: 150 ms latency, 1.6 Mbps down, 750 Kbps up.
- Cache: disabled.
- Metrics: first contentful paint, largest contentful paint, JavaScript transferred, and total bytes, read from the Performance API after load.
I alternated runs between the old and new builds on the same machine and connection. One lesson from that: run the measurements one at a time. When I fired navigations in parallel, they raced each other and produced numbers that meant nothing.
What the baseline showed
| Build | First paint (FCP) | JavaScript | Total |
|---|---|---|---|
| Customer app sign-in | 7.4 s | 926 KB | 1,368 KB |
| Admin sign-in | 2.8 s | 171 KB | 420 KB |
| Marketing home | 2.2 s | 0 KB before paint | 108 KB |
The marketing site was already fine. It ships prerendered HTML and adds its script after the first frame. The customer app was the problem. Every route was a static import in App.tsx. The sign-in page shipped all of the app code: a single 3.2 MB entry chunk.

The changes
Load each route when it opens
I turned every page import into React.lazy, including the app shell and the site layout. The sign-in page no longer pulls either of them. The interesting part is where the Suspense boundaries go. A single boundary at the root would blank the whole screen on every navigation. Instead, each layout wraps its own <Outlet />:
export function RouteSuspense({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<div className="p-4"><BlockState kind="loading" label="Loading…" /></div>}>
{children}
</Suspense>
)
}
The shell, the site layout, and the section layouts each render <RouteSuspense><Outlet /></RouteSuspense>. When you change screens, the navigation and the site switcher stay put, and only the content area waits. The entry chunk went from 3.2 MB to 373 KB, or 117 KB compressed.
Lazy routes bring one new failure: a tab opened before a deploy asks for chunk names that no longer exist. Vite fires vite:preloadError when that happens. I reload once, guarded by sessionStorage, so a down network cannot cause a reload loop.
Defer what is only decoration
A source-map breakdown of the remaining entry showed the animation library at about 450 KB of source. A decorative grid behind the sign-in form pulled it in. I lazy-loaded that component with an empty fallback, so the form paints first.
Shrink images to their display size
The logo was a 512 px PNG, 205 KB, shown at 32 px. A 96 px WebP is 948 bytes and looks identical. The sign-in photo got a WebP srcSet (20 KB at 640 px, 47 KB at 1200 px) with fetchPriority="low". It does not compete with code and CSS for bandwidth.
Share identical in-flight requests
On load, the shell and the home page both asked for the account and the site list together. I changed the fetch helper so plain GETs for the same path share one in-flight promise. Each caller gets its own structuredClone of the result. Nothing is cached after the request settles, so no data goes stale, and writes are never shared. A unit test asserts both behaviours.
Cache hashed files for a year
Production was sending Cache-Control: public, max-age=0, must-revalidate for hashed build files. Every repeat visit revalidated each file, at one round trip each. On Slow 4G, that is at least 150 ms per file. One rule in each Worker _headers file fixed it:
/assets/*
Cache-Control: public, max-age=31536000, immutable
Before shipping it, I confirmed that every file under /assets/ has a content hash in its name.
Start the bot check with the page
Sign-in uses Cloudflare Turnstile. Its script was requested only after the React form mounted, about 6.0 seconds into the slow test. The app Content Security Policy blocks inline scripts. I added a tiny same-origin loader that starts the Turnstile download on /login and /signup only. Now it starts at 0.3 seconds, and the challenge is running while the form is still loading.
Results
| Page | Metric | Before | After | Total before | After |
|---|---|---|---|---|---|
| Customer sign-in | First paint (FCP) | 7.4 s | 1.6 s | 1,368 KB | 295 KB |
| Customer sign-in | Largest contentful paint (main image) | 8.5 s | 3.7 s | ||
| Admin sign-in | First paint (FCP) | 2.8 s | 1.5 s | 420 KB | 228 KB |
Again: lab before-and-after on one setup, not a real-world guarantee.
The restructure that came first
None of this would have been a one-day job a week earlier. Before the speed work, I reorganized the codebase:
- Folders by domain. The API and shared packages moved into domain folders. The dashboard 164 screen files moved into one folder per navigation area. File names did not change, so search still finds everything.
- One source per rule. Plan limits, prices, and paid-plan lists were hand-copied across the API, the dashboard, the admin, and the marketing site. They now read from one shared table. That change surfaced a public comparison page listing a plan with 10 keywords instead of 20.
- Fewer, clearer dependencies. Both packages now have zero import cycles, and the platform layer no longer imports from auth.
- Less code. About 6,400 lines removed and 4,200 written, including tests that only restated a table.
That is what made the lazy-route change mechanical. Every page already had an obvious module to point React.lazy at.
The upgrade gotcha CI caught
I also moved the marketing site from Vite 6 to Vite 8. Its client build dropped from 3.4 seconds to 0.3. CI then failed on its “bundle contains localhost” check. This code:
const env = import.meta.env;
export const appOrigin = env.VITE_APP_ORIGIN ?? "http://localhost:5174";
worked in Vite 6, where the minifier folded the fallback away. In Vite 8 the aliased import.meta.env became a whole inlined object, and the fallback string stayed in the bundle. The runtime value was still the production origin, but the gate was right to fail. Reading import.meta.env.VITE_APP_ORIGIN directly lets the build replace it, and the fallback drops out.
Why I did not adopt GraphQL
The marketing pages already stream HTML with early data. For the dashboard, the changes above covered the rest. A second API would also have been costly. The REST API has 256 routes and serves customer API keys, MCP clients, and a phone app. Every GraphQL field would need the same plan limits and cost caps as its REST route. A client-side query cache over the existing REST calls is the next step. It gives the part of a client cache I would actually use. The last answer shows at once while it refreshes.
Takeaways
- Measure on a throttled connection, with an empty cache, against production builds. Office Wi-Fi hides all of this.
- Put a Suspense boundary at each layout outlet, not only at the root.
- Check your cache headers in production. Hashed files that revalidate on every visit cost a round trip each.
- Look for decoration in the entry chunk with a source map. Mine was 450 KB of animation code on a login page.
- Keep a CI check for the mistakes you have made before. Mine caught a build-tool change on the first try.
Need this built instead of read about?
Send me your project. I reply within 24 hours and tell you plainly whether I’m the right fit.
Send me your project

