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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- code-splitting Installs to ~/.agents/skills/code-splitting/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install code-splitting The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project code-splitting Installs to .agents/skills/code-splitting/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/code-splitting.zipunzip -o code-splitting.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) code-splitting PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| Candidate | Split? | Mechanism |
|---|---|---|
| Other routes / pages | Yes, always | Router-level lazy routes (often automatic in frameworks) |
| Heavy widget behind a click (rich text editor, chart lib, map, PDF viewer, emoji picker) | Yes | import() on interaction, prefetch on hover/focus |
| Below-the-fold sections on a long page | Often | Lazy on visibility (IntersectionObserver, Angular @defer (on viewport)) |
| Modal and drawer contents | If > ~20 kB gzip | Lazy on open, prefetch on hover of the trigger |
| Admin or rarely used features | Yes | Separate route chunk |
| Small shared components, design system primitives | No | Splitting adds requests and overhead for little gain |
| Code needed for first render or LCP | Never | Keep 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
| Technique | Use 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/touchstart | Widgets 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
Suspenseboundaries that each trigger animport()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:preloadErroronwindow; webpack throwsChunkLoadError. 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 uselazy(() => 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.yaml
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.