all posts
May 17, 2026·5 min read·performancenextjsbundle

−22 KB in one PR: how I split the chat panel and deferred the easter egg

A perf retrospective. The bottleneck was eager imports of features that 95% of visitors never use. The fix: dynamic-import them, and gate non-visible components behind requestIdleCallback.

After Phase 2 shipped (theme studio + PWA + WebGPU lab + content polish), the home page First Load JS sat at 217 KB. Not terrible — but I'd added five features without a serious bundle pass, and I wanted the home page below 200 KB.

One PR later, every route is ~22 KB lighter with zero feature changes.

RouteBeforeAfterΔ
/217 KB195 KB−22 KB
/blog210 KB188 KB−22 KB
/lab215 KB193 KB−22 KB
/projects/[slug]216 KB194 KB−22 KB

The trick was identifying which JS was eager-imported but rarely used — and pushing it behind dynamic imports or idle callbacks.

The audit

The home page mounts five always-on client components:

  • 3D scene (visible immediately — must stay eager)
  • Command palette (keyboard-triggered, but listener registration matters)
  • Chat dock button (visible — must stay eager)
  • Konami easter egg (keyboard-triggered, invisible until unlocked)
  • PWA installer (only shows a pill when beforeinstallprompt fires, invisible 99% of the time)

The 3D scene is unavoidable — it's the hero. The command palette listener (~5 KB) is small enough not to matter. But two big things stood out:

  1. ChatDock eagerly imports ChatPanel, which pulls in @ai-sdk/react's useChat hook — the bulk of the chat code (~12 KB compressed). 95% of visitors don't open the chat.
  2. KonamiEgg and PWAInstaller ship code that's invisible until specific triggers fire. The Konami listener doesn't need to run immediately. The PWA install pill doesn't need to load before LCP.

Fix 1: dynamic-import the chat panel

The chat button stays eager (visible bottom-right). The panel doesn't:

import dynamic from "next/dynamic";
 
const ChatPanel = dynamic(() => import("./ChatPanel").then((m) => m.ChatPanel), {
  ssr: false,
  loading: () => (
    <div className="grid h-full place-items-center font-mono text-xs">
      booting AI…
    </div>
  ),
});
 
export function ChatDock() {
  const { chatOpen } = useUi();
  return (
    <>
      <button onClick={toggleChat}>...</button>
      {chatOpen && <ChatPanel />}
    </>
  );
}

The first click on the chat button triggers a tiny chunk download (~12 KB). The "booting AI…" placeholder flashes for ~150ms on a fast connection. Worth the trade.

Verified via DevTools Network panel: the AI SDK chunk does not load until the chat button is clicked. The dynamic import worked — webpack code-split it into a separate chunk (753.js + 534.js in the prod build).

Fix 2: idle-mount the invisible features

Konami and PWAInstaller are functionally invisible until triggered. So they don't need to load during the critical path. A single wrapper component handles both:

"use client";
import dynamic from "next/dynamic";
import { useIdle } from "@/hooks/useIdle";
 
const KonamiEgg = dynamic(
  () => import("@/components/viral/KonamiEgg").then((m) => m.KonamiEgg),
  { ssr: false, loading: () => null },
);
const PWAInstaller = dynamic(
  () => import("@/components/viral/PWAInstaller").then((m) => m.PWAInstaller),
  { ssr: false, loading: () => null },
);
 
export function DeferredClient() {
  const ready = useIdle(1500);
  if (!ready) return null;
  return (
    <>
      <KonamiEgg />
      <PWAInstaller />
    </>
  );
}

The useIdle hook is a 25-line wrapper around requestIdleCallback with a 1.5s setTimeout fallback:

export function useIdle(timeoutMs = 1500) {
  const [idle, setIdle] = useState(false);
  useEffect(() => {
    const cb = () => setIdle(true);
    if (typeof window.requestIdleCallback === "function") {
      const id = window.requestIdleCallback(cb, { timeout: timeoutMs });
      return () => window.cancelIdleCallback?.(id);
    }
    const t = setTimeout(cb, timeoutMs);
    return () => clearTimeout(t);
  }, [timeoutMs]);
  return idle;
}

The Konami sequence still works — the user just needs to wait ~1.5s after page load before typing ↑↑↓↓←→←→BA. Nobody is that fast. The SW registration still happens, just shortly after load instead of immediately. The install pill still appears when beforeinstallprompt fires (which happens on engagement, not page load).

Fix 3: prune dead deps

While I was in the package.json, six deps from the pre-rebuild stack were never imported but lingered:

@studio-freight/lenis     — removed when scroll was fixed via native
gsap, @gsap/react         — never wired up; framer-motion 12 covered the cases
detect-gpu                — replaced with a hand-rolled cores+memory check
fuse.js                   — cmdk's built-in fuzzy search is sufficient
maath                     — math helpers; never imported

These were already tree-shaken from the bundle, so removing them didn't help bundle size — but it cleaned the dependency graph, sped up installs, and clarified intent. pnpm install is meaningfully faster.

The non-fix: bundle analyzer pass

I considered running @next/bundle-analyzer to dig deeper. Skipped it. The two fixes above were the obvious bottlenecks; chasing the next 5 KB through bundle analyzer would have been hours of work for marginal gain. Diminishing returns.

The principle: identify the eager imports that 95% of visitors never use, and defer them. Bundle analyzers are great when you've already done the easy passes.

What I didn't optimize

  • The 3D scene stays eager. Trying to defer it would just blank the hero for a second, which is worse than 200 KB.
  • Framer Motion is still in the initial bundle. It's used in 6+ places (audience switch, chat dock, palette, etc.); dynamic-importing it would mean components flashing in.
  • Geist fonts are self-hosted via the geist npm package and already preloaded. Nothing to win there.

What I measured

Bundle First Load JS (gzipped, from next build output):

  • / : 217 → 195 KB
  • All static routes lost ~22 KB

I deliberately didn't run a synthetic Lighthouse score — the bundle delta is the metric I care about, and live Core Web Vitals will flow through Vercel Speed Insights (which is already wired in layout.tsx).

The total work was about an hour of editing. Identifying the eager imports was 80% of the value; the actual dynamic-import code is boilerplate. The cheapest perf wins are usually structural, not algorithmic.