DEV Community

West
West

Posted on

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%
Enter fullscreen mode Exit fullscreen mode

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<StageComponent> | null = null;

function requestInteractiveStage() {
  stagePromise ??= import("./HomeInteractiveStage").then(
    (module) => module.HomeInteractiveStage,
  );
  return stagePromise;
}

export function HomeInteractiveStageClient() {
  const [Stage, setStage] = useState<StageComponent | null>(null);
  const [requested, setRequested] = useState(false);

  const loadStage = useCallback(() => {
    setRequested(true);
    void requestInteractiveStage().then((component) => {
      setStage(() => component);
    });
  }, []);

  if (requested) {
    return Stage ? <Stage /> : <div role="status">Loading 3D…</div>;
  }

  return (
    <button
      aria-label="Load interactive 3D preview"
      onClick={loadStage}
      type="button"
    >
      <Image
        alt=""
        fetchPriority="high"
        height={576}
        loading="eager"
        src="/home-lab/model-cover-mobile.avif"
        width={768}
      />
      <span>Tap to load · Drag to rotate</span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

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<void> | null = null;

export function loadModelViewer() {
  if (customElements.get("model-viewer")) return Promise.resolve();
  if (viewerPromise) return viewerPromise;

  viewerPromise = new Promise<void>((resolve, reject) => {
    const existing = document.querySelector<HTMLScriptElement>(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;
}
Enter fullscreen mode Exit fullscreen mode

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:

<Link href="/image-to-3d-model" prefetch={false}>
  Create your model
</Link>
Enter fullscreen mode Exit fullscreen mode

This is an easy failure mode to miss. A component can be correctly lazy-loaded on its own route while a nearby <Link> 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
Enter fullscreen mode Exit fullscreen mode

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%;
}
Enter fullscreen mode Exit fullscreen mode

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 <= 50_000);
});

test("the public shell does not initialize account UI", () => {
  const layout = source("../src/app/layout.tsx");

  assert.doesNotMatch(layout, /ClerkProvider/);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Run the audit more than once and report the median.
  2. Sort initial network requests by transferred bytes.
  3. Ask whether the largest resource is required before interaction.
  4. Defer the component, runtime, and asset—not only one of the three.
  5. Inspect route prefetching for heavy destinations.
  6. Remove route-specific providers from the root layout.
  7. Reserve final geometry before client code runs.
  8. Verify the deferred feature still works after a real click.
  9. Add tests for the dependency boundaries you do not want to regress.
  10. 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.

Top comments (0)