Code Splitting skill for Codex

Load each route's code, prefetch the next one. Codex decides what deserves its own chunk (other routes, editors, charts, maps, modals) and what must stay in the initial load because it renders the LCP. It applies the router's native lazy loading or dynamic import with sized fallbacks, prefetches on hover, idle or with Speculation Rules so users never wait, avoids chunk-then-data waterfalls, and keeps old assets plus a reload guard so deploys do not break open tabs.

  • Performance
  • $code-splitting
  • 179 lines
  • 2 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- code-splitting

Installs to ~/.agents/skills/code-splitting/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $code-splitting, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Splits JavaScript so each route and interaction loads only the code it needs - route-level lazy loading in React Router, TanStack Router, Vue Router, Angular and Next.js, component and library splitting with dynamic import(), React.lazy, next/dynamic and Angular defer blocks - then hides the latency with modulepreload, intent-based prefetch and Speculation Rules, and handles chunk load failures after deploys. Use when the initial bundle carries code for other pages, heavy widgets (editors, charts, maps) load up front, navigation feels slow after splitting, or ChunkLoadError appears in error logs.

Use it when

  • Initial JavaScript includes code for other routes or rarely used features
  • Heavy widgets load up front but are used only after interaction
  • Navigation got slower after splitting because chunks load on demand
  • Users hit chunk load errors after deploys

Use something else when

  • You first need to know what is in the bundle and remove dead weight: use bundle-size-reduction
  • The work is CPU-heavy computation rather than code size: use web-workers

How to check the result

The build shows the deferred modules in separate chunks, initial route JS and TBT drop, LCP and CLS do not regress, and the deferred interaction shows no wait on a throttled profile.

The mistake it prevents

Splitting the component that renders the LCP element, which shrinks the bundle but delays the paint that matters most.

~/.agents/skills/code-splitting/SKILL.md

--- name: code-splitting description: Splits JavaScript so each route and interaction loads only the code it needs - route-level lazy loading in React Router, TanStack Router, Vue Router,… license: MIT ---

Done well means: the initial load of each key route carries only what renders above the fold and handles the first interaction; deferred code arrives before the user needs it (prefetched on intent or idle); there are no new request waterfalls; and a deploy never leaves users with chunks that 404.

Decide what to split

CandidateSplit?Mechanism
Other routes / pagesYes, alwaysRouter-level lazy routes (often automatic in frameworks)
Heavy widget behind a click (rich text editor, chart lib, map, PDF viewer, emoji picker)Yesimport() on interaction, prefetch on hover/focus
Below-the-fold sections on a long pageOftenLazy on visibility (IntersectionObserver, Angular @defer (on viewport))
Modal and drawer contentsIf > ~20 kB gzipLazy on open, prefetch on hover of the trigger
Admin or rarely used featuresYesSeparate route chunk
Small shared components, design system primitivesNoSplitting adds requests and overhead for little gain
Code needed for first render or LCPNeverKeep in the initial chunk; splitting it delays LCP

Measure first: the route’s initial JS and which modules are not used during first render (DevTools Coverage tab, or the treemap from $bundle-size-reduction).

Workflow

- [ ] 1. Read AGENTS.md / package.json: framework, router, bundler, SSR or SPA
- [ ] 2. Record baseline: initial JS per key route, LCP and TBT (median of runs)
- [ ] 3. Confirm routes are split; split the largest non-critical modules next
- [ ] 4. Add loading states (Suspense fallbacks / skeletons) sized to avoid layout shift
- [ ] 5. Add prefetching so navigation and interactions do not wait on the network
- [ ] 6. Handle chunk load errors; keep old assets after deploys
- [ ] 7. Re-measure initial JS, LCP, TBT and the latency of the deferred interaction

Route-level splitting

// React Router v7 (data/declarative routers): lazy route modules
const router = createBrowserRouter([
  { path: '/', Component: Home },
  { path: '/reports', lazy: () => import('./routes/reports') }, // module exports Component, loader
]);
  • React Router framework mode, Next.js, SvelteKit, Nuxt and Remix-style frameworks split per route automatically; check the build output before adding manual splits.
  • TanStack Router: enable automatic code splitting in the bundler plugin (autoCodeSplitting: true).
  • Vue Router: component: () => import('./views/ReportsView.vue').
  • Angular: loadComponent: () => import('./reports.component').then(m => m.ReportsComponent) in route config.

Component and library splitting

// React
const ChartPanel = lazy(() => import('./ChartPanel'));

<Suspense fallback={<div style={{ height: 320 }} aria-busy="true" />}>
  {showChart && <ChartPanel data={data} />}
</Suspense>
// Next.js: client-only heavy widget
'use client';
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('./Editor'), { ssr: false, loading: () => <EditorSkeleton /> });

ssr: false is only allowed in Client Components. In Server Components, prefer leaving server-only libraries on the server so they never reach the client.

<!-- Angular 17+: defer a block until visible, prefetch when idle -->
@defer (on viewport; prefetch on idle) { <app-reviews /> } @placeholder { <div class="h-64"></div> }

Import on interaction (framework-agnostic):

const loadEditor = () => import('./editor.js');            // module promise is cached by the runtime
trigger.addEventListener('pointerenter', loadEditor, { once: true }); // prefetch on intent
trigger.addEventListener('focus', loadEditor, { once: true });
trigger.addEventListener('click', async () => {
  const { openEditor } = await loadEditor();
  openEditor();
});

Prefetching

TechniqueUse for
Router link prefetch (Next.js <Link> in viewport, React Router <Link prefetch="intent"> in framework mode)Likely next pages
<link rel="modulepreload" href="...">Chunks needed soon on this page; Vite injects these for static imports of dynamic chunks
import() on pointerenter/focus/touchstartWidgets behind a click
requestIdleCallback(() => import('./x'))Secondary features after load settles
Speculation Rules API (<script type="speculationrules">)Prefetch or prerender whole next documents in Chromium browsers; MPA-friendly
<script type="speculationrules">
{ "prefetch": [{ "where": { "href_matches": "/products/*" }, "eagerness": "moderate" }] }
</script>

Do not prefetch everything: it competes with the current page’s critical requests and costs users data. Respect navigator.connection?.saveData.

Avoid waterfalls

  • A lazy route that then lazy-loads its data and then a lazy child component makes three sequential round trips. Start data fetching in the route loader in parallel with the chunk (router loaders, or preloading queries on intent).
  • Nested Suspense boundaries that each trigger an import() in sequence: hoist the imports or combine the chunks.
  • webpack magic comments (/* webpackChunkName: "editor" */, webpackPrefetch: true) help naming and prefetch; Vite/Rollup ignores them.

Chunk load failures after deploy

Users with an old HTML page request chunk names that a new deploy removed.

  • Keep previous deploys’ hashed assets available for at least a few days (do not wipe the bucket on deploy).
  • Handle the error: Vite emits vite:preloadError on window; webpack throws ChunkLoadError. Reload once, guarded to avoid loops:
window.addEventListener('vite:preloadError', (event) => {
  if (!sessionStorage.getItem('chunk-reload')) {
    sessionStorage.setItem('chunk-reload', '1');
    event.preventDefault();
    window.location.reload();
  }
});
  • Wrap lazy routes in an error boundary with a “reload” action rather than a blank page.

Gotchas

  • Splitting the LCP component or above-the-fold content delays LCP; split below and behind interactions.
  • Suspense fallbacks without fixed dimensions cause CLS when the chunk arrives.
  • Default exports are required by React.lazy; for named exports use lazy(() => import('./x').then(m => ({ default: m.Chart }))).
  • Over-splitting into dozens of tiny chunks costs per-request overhead and compression efficiency, even with HTTP/2. Aim for chunks in the tens of kB gzip.
  • A dynamic import of a module that is also statically imported elsewhere does nothing; the bundler keeps it in the main chunk (Vite warns about this).

Verification

  • Build output: the target modules now live in separate chunks and are absent from the route’s initial chunk list.
  • DevTools Network (throttled): initial JS for the route dropped; the deferred chunk is fetched on hover/idle and the interaction shows no visible wait.
  • LCP and CLS unchanged or better; TBT lower.
  • Simulate a deploy (serve old HTML with new assets folder) and confirm the reload handler recovers instead of showing a blank screen.

Report template

## Code splitting: <app>
| Route | Initial JS gzip before | After | LCP before/after | TBT before/after |
|---|---|---|---|---|
| / | 410 kB | 205 kB | 3.1 s / 2.4 s | 620 / 240 ms |
Split: editor (lazy on click, prefetch on hover), charts (lazy on viewport), /admin route
Prefetch: Link intent prefetch on product cards; speculation rules for /products/*
Chunk errors: vite:preloadError reload guard; old assets retained 7 days

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Code Splitting"
  short_description: "Load each route's code, prefetch the next one"
  default_prompt: "$code-splitting The home page bundle includes the whole admin area, split it by route"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc