Skip to content

perf: lazy-load /app routes to shrink marketing bundle#19

Merged
mastermanas805 merged 1 commit into
mainfrom
perf/lazy-load-auth-routes
May 11, 2026
Merged

perf: lazy-load /app routes to shrink marketing bundle#19
mastermanas805 merged 1 commit into
mainfrom
perf/lazy-load-auth-routes

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Marketing visitors landing on instanode.dev shouldn't pay the JS cost of the auth-gated dashboard. This converts the 12 /app/* page components plus the AppShell layout to React.lazy, splitting them into per-page chunks that only load after AuthGate passes.

  • Main entry bundle: 764 KB -> 682 KB (-82 KB / -11%) — gzipped 198 KB -> 184 KB
  • 14 new chunks loaded on demand: AppShell 9.6 KB, 12 pages 2.5-18 KB each, shared useDashboardCtx 1.5 KB
  • Marketing pages, auth pages (login/claim), and the prerender route list are unchanged — first-paint surface is untouched
  • Suspense fallback ("Loading…") wraps the whole /app subtree and only renders for the auth'd user while a chunk arrives

SSG compatibility

scripts/prerender.mjs has never had /app/* in its PRERENDER_ROUTES list (auth-gated, uses localStorage). React.lazy components are never invoked during SSG, so the build still emits exactly 115 HTML files. Verified locally.

Test plan

  • npm run build succeeds with smaller main chunk + new per-page chunks
  • npm test passes — 26/26 markdown renderer tests
  • Prerender step still emits 115 HTML files (matches main)
  • Smoke-test marketing pages on a deployed preview — homepage, /pricing, /for-agents, /blog, /docs, /use-cases
  • Smoke-test /app navigation — login -> /app, navigate between Resources/Deployments/Vault/Billing and confirm each chunk loads under 200ms on a warm connection
  • Confirm the Suspense "Loading…" fallback is acceptable UX while a chunk is in flight on cold load

Marketing visitors landing on the homepage shouldn't pay the JS cost of
the auth-gated dashboard. Converts the 12 /app/* page components and the
AppShell layout to React.lazy, splitting them into per-page chunks that
only load after AuthGate passes. Public marketing routes, auth routes,
and the prerender route list are unchanged.

Bundle: main entry 764 KB → 682 KB, with 14 new chunks (AppShell 9.6 KB,
12 pages 2.5-18 KB each, shared ctx 1.5 KB) loaded on demand. SSR
prerender still emits 115 HTML files since /app/* was never in the
prerender list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 merged commit 93ee96b into main May 11, 2026
1 of 2 checks passed
@mastermanas805 mastermanas805 deleted the perf/lazy-load-auth-routes branch May 11, 2026 13:00
mastermanas805 added a commit that referenced this pull request May 11, 2026
…undle (#27)

PR #19 already split /app/* routes out of the entry chunk, but the
remaining public marketing pages were still eagerly imported in App.tsx
on the theory that "a homepage visitor might click any of them." In
practice, BlogPostPage / DocsPage / UseCaseDetailPage etc. are only
reachable after at least one click, so paying for their bytes on cold
load is wasted weight on the most-visited path.

This commit converts the secondary public pages to React.lazy() and
relies on Rollup's per-import code-splitting to peel them into their
own chunks. The first-paint JS that a homepage visitor downloads is
now meaningfully smaller; chunks for /pricing, /docs, /blog, /status,
/use-cases, /for-agents are fetched on demand when the visitor clicks
into those routes.

Bundle sizes (vite build), main entry + each new chunk:

| File                                | Before          | After           |
|-------------------------------------|-----------------|-----------------|
| index-*.js (main entry)             | 710.87 / 194.51 | 616.57 / 168.76 |
| PricingPage chunk                   | (in main)       |  14.43 /  3.87  |
| ForAgentsPage chunk                 | (in main)       |  11.43 /  3.53  |
| StatusPage chunk                    | (in main)       |   8.67 /  2.73  |
| BlogPage chunk                      | (in main)       |   2.22 /  0.97  |
| BlogPostPage chunk                  | (in main)       |   3.06 /  1.14  |
| DocsPage chunk                      | (in main)       |  10.48 /  4.49  |
| UseCasesPage chunk                  | (in main)       |   5.07 /  1.88  |
| UseCaseDetailPage chunk             | (in main)       |   9.63 /  2.90  |
| PublicShell chunk (extracted)       | (in main)       |   9.02 /  2.32  |
| posts content chunk                 | (in main)       |  22.68 /  9.95  |
| markdown renderer chunk             | (in main)       |   1.98 /  0.84  |

(All sizes in kB; pair is raw / gzip. Vite's 500 kB chunk-size warning
still fires on the main entry — most of the remaining bytes are react +
react-dom + react-router + MarketingPage's inlined USE_CASES dataset.
That's tracked separately.)

Net delta on cold load:
  raw:  710.87 → 616.57 kB  (-94.30 kB, -13.3%)
  gzip: 194.51 → 168.76 kB  (-25.75 kB, -13.2%)

SSG complication and how it's handled:

scripts/prerender.mjs renders public routes through renderToString at
build time so crawlers see real HTML, not an empty <div id="root">.
React.lazy() during renderToString resolves to the parent Suspense
fallback, NOT the page's content — so naively lazy-loading the public
pages would mean every pre-rendered HTML (/pricing, /docs, /blog/*,
/use-cases/*, …) ships only a <span aria-hidden> instead of the
marketing copy. That defeats the entire SEO/GEO setup.

Fix: src/entry-server.tsx now declares its own SSRRoutes with
synchronous imports for every public page. The SSR bundle is built by
Vite as a separate module graph (build({ ssr: 'src/entry-server.tsx' }))
and thrown away after prerender, so the static imports there don't bloat
the client output. The route table is duplicated between App.tsx (lazy)
and entry-server.tsx (static) — small cost, documented inline at the top
of entry-server.tsx, and Keep-In-Sync flagged for future route adds.

I first tried a unified App.tsx using `import.meta.env.SSR` to switch
between lazy and static imports inline. Vite emits a warning when a
module is both statically and dynamically imported and refuses to split
it — so that approach silently put every "lazy" page back into the main
bundle. Separate SSR entry is the only pattern that actually splits.

Verification:
- npm run build emits 116 static HTML files + 116 .md mirrors (unchanged).
- Pre-rendered HTML contains real content (no "Loading…" fallbacks):
  /pricing 36 kB, /docs 22 kB, /use-cases 73 kB, /blog 14 kB.
- npm test → 96 passed, 3 skipped (unchanged from baseline).
- tsc --noEmit clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant