Dev.to · 8 min read

How I Cut a 3D Homepage from 9.1 MB to 370 KB Without Removing the 3D

How I Cut a 3D Homepage from 9.1 MB to 370 KB Without Removing the 3D

A single 3D model accounted for 83.7% of my homepage's initial payload. It was a 7,833,440-byte GLB displayed in the hero of World Models Watch. The model looked good, but every mobile visitor downloaded it before deciding whether to interact with it. Three mobile Lighthouse runs produced this baseline: Metric Baseline median Final production median Performance 53 96 LCP 4.525 s 2.653 s CLS 0.146 0 TBT 357 ms 1 ms Initial transfer about 9.1 MB about 370 KB The final page still has the interactive 3D model. It simply does not load it until the visitor asks for it. This is a lab-tested case study, not field data. Chrome UX Report did not have enough real-user data for the site, so INP was unavailable. The final LCP also missed my stricter target of less than 2 seconds. That distinction matters: this was a large improvement, not a claim that performance work was finished. Here is what changed and, more importantly, how I found the work that actually mattered. Find the request that dominates the page The first audit could have led to a long list of generic fixes: compress images, reduce JavaScript, minify CSS, add caching, and so on. The network evidence pointed somewhere much more specific: Initial page resources: ~9.1 MB /generation-models/anisotropy-barn-lamp.glb: 7.83 MB Share of measured resources: 83.7% Optimizing several 30 KB assets while continuing to load a 7.8 MB model would not materially change the experience. The useful question was not “How do I make a GLB slightly smaller?” It was “Why is a mobile visitor downloading this GLB before interacting with the 3D viewer?” That changed the solution from asset tuning to load-path design. Replace eager 3D with a mobile interaction boundary On mobile, the homepage now starts with a 19 KB AVIF cover. The cover has the same 4:3 geometry as the interactive stage, so switching from one to the other does not move surrounding content. The actual stage component is imported after a tap: "use client"; import Image from "next/image"; import { useCallback, useState } from "react"; type StageComponent = typeof import("./HomeInteractiveStage").HomeInteractiveStage; let stagePromise: Promise | null = null; function requestInteractiveStage() { stagePromise ??= import("./HomeInteractiveStage").then( (module) => module.HomeInteractiveStage, ); return stagePromise; } export function HomeInteractiveStageClient() { const [Stage, setStage] = useState(null); const [requested, setRequested] = useState(false); const loadStage = useCallback(() => { setRequested(true); void requestInteractiveStage().then((component) => { setStage(() => component); }); }, []); if (requested) { return Stage ? : Loading 3D…; } return ( Tap to load · Drag to rotate ); } The promise is cached at module level. Repeated clicks or renders reuse the same import instead of starting duplicate work. Desktop can still request the interactive stage automatically. The important product decision is that mobile users get a useful, stable first screen before paying for the 3D runtime and asset. Lazy-load the runtime as well as the component Code-splitting the React component is not enough if another import pulls the viewer runtime into the initial route. I removed direct @google/model-viewer imports from homepage and generation-output components. A shared loader now injects the self-hosted runtime only when a 3D component mounts: "use client"; const selector = 'script[data-model-viewer-runtime="true"]'; let viewerPromise: Promise | null = null; export function loadModelViewer() { if (customElements.get("model-viewer")) return Promise.resolve(); if (viewerPromise) return viewerPromise; viewerPromise = new Promise((resolve, reject) => { const existing = document.querySelector(selector); const script = existing ?? document.createElement("script"); script.addEventListener( "load", () => void customElements.whenDefined("model-viewer").then(resolve), { once: true }, ); script.addEventListener("error", () => reject(new Error("3D runtime failed")), { once: true, }); if (!existing) { script.dataset.modelViewerRuntime = "true"; script.src = "/vendor/model-viewer.min.js"; script.type = "module"; document.head.appendChild(script); } }); return viewerPromise; } This loader solves three problems: it keeps the runtime out of the initial mobile path; it prevents duplicate script elements; it gives every 3D surface one loading contract. The walkable Three.js world was heavier still. Instead of embedding that entire runtime on the homepage, I moved it to a dedicated /world-experience route. The homepage shows a preview and a clear entry point. The feature was not removed. Its cost was moved to the moment of intent. Check route prefetch after adding lazy loading After deferring the component, I still saw generator code arrive earlier than expected. The first assumption was stale build output. The actual cause was Next.js route prefetching. Homepage calls to action and the mobile navigation linked to generator routes, so the browser downloaded route dependencies before the user navigated. For heavy destinations, I disabled automatic prefetch: Create your model This is an easy failure mode to miss. A component can be correctly lazy-loaded on its own route while a nearby quietly warms that route and reintroduces the same cost. Prefetch is valuable when the prefetched destination is small and likely to be visited. It is not automatically a win when the route owns a large editor, authentication UI, task history, or 3D dependencies. Keep global layouts boring The root layout originally had responsibilities that were not needed by anonymous homepage visitors: the global authentication provider; a deferred generation task center; advertising; analytics. “Deferred” components can still expand a route's dependency graph. A provider at the root makes every page pay part of its initialization cost. I moved account and task-center behavior closer to the routes that require it. Public marketing pages now render without initializing Clerk. Advertising and analytics wait until the browser is idle, with a timeout fallback, instead of joining the critical rendering path. The architectural rule is simple: Root layout: document shell and truly global UI Public page: immediately useful content Account routes: authentication runtime Generator routes: editor and task dependencies 3D interaction: viewer and asset after intent Route ownership is a performance feature. It stops one convenient global import from taxing every visitor. Fix layout stability at the boundary The baseline CLS median was 0.146. The 3D area and mobile sections did not reserve enough stable space before late content arrived. The cover and loaded stage now share fixed intrinsic dimensions and a 4:3 container. Loading text also renders inside that same box. The page no longer discovers the height of the hero after JavaScript runs. .interactiveStage { aspect-ratio: 4 / 3; inline-size: 100%; overflow: hidden; } .stageCover, .stageLoading, .stageLoaded { block-size: 100%; inline-size: 100%; } This is why the final CLS reached 0. The image format helped transfer size; intrinsic geometry fixed stability. Protect the loading boundary with tests Performance regressions often look harmless in code review. Someone restores a static viewer import, puts authentication back in the root layout, or removes prefetch={false}. I added source-level tests for the invariants that must stay true: test("mobile 3D stays behind an explicit request", () => { const gate = source("../src/components/home/HomeInteractiveStageClient.tsx"); const stage = source("../src/components/home/HomeInteractiveStage.tsx"); const loader = source("../src/lib/model-viewer-loader.ts"); assert.match(gate, /onClick=\{loadInteractiveStage\}/); assert.doesNotMatch(stage, /import\("@google\/model-viewer"\)/); assert.match(loader, /\/vendor\/model-viewer\.min\.js/); assert.ok(statSync(cover).size { const layout = source("../src/app/layout.tsx"); assert.doesNotMatch(layout, /ClerkProvider/); }); These tests do not replace Lighthouse, browser traces, or real-user monitoring. They stop known architectural regressions before another measurement is needed. The production release was also checked for behavior, not only scores: no GLB request before interaction; no initial Clerk or Google-script request; the viewer runtime and GLB loaded successfully after a click; homepage, generator, sign-in, and world-experience routes still worked; the production build and complete test suite passed. What the numbers actually prove The final three production Lighthouse runs reported: Performance: 97 / 91 / 96 LCP: 2.43 s / 2.94 s / 2.65 s CLS: 0 / 0 / 0 TBT: 0 ms / 19 ms / 1 ms Transfer: ~370 KB Requests: 43 The improvement came from removing work, not making the browser execute the same work more efficiently. There are also clear limits to the result: median LCP was 2.653 seconds, above the aggressive sub-2-second target; lab variance remained visible across runs; INP could not be evaluated without enough field data; the measurements describe this release and test setup, not a permanent guarantee. That is the useful lesson. A dramatic payload reduction can coexist with unfinished LCP work. Report transfer, LCP, CLS, TBT, test conditions, and field-data availability separately. The checklist I would reuse For any homepage with interactive 3D, maps, editors, or other heavy media: Run the audit more than once and report the median. Sort initial network requests by transferred bytes. Ask whether the largest resource is required before interaction. Defer the component, runtime, and asset—not only one of the three. Inspect route prefetching for heavy destinations. Remove route-specific providers from the root layout. Reserve final geometry before client code runs. Verify the deferred feature still works after a real click. Add tests for the dependency boundaries you do not want to regress. Keep lab results separate from field data and from your target. The fastest 7.8 MB GLB is the one a visitor never has to download. The better product is not a homepage without 3D. It is a homepage that waits until the visitor says, “Show me the 3D.” You can explore the live project at World Models Watch. Its public repository and technical documentation are available on GitHub.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News