audit-bundles
Analyze a pracht production build: client bytes per route, fat vendor chunks, routes shipping large dependencies, and dynamic `import()` / prefetch recommendations. Use for "audit bundles", "why is my JS so big", "bundle size per route", "what's in my vendor chunk", "tune prefetc
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-bundles
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Audit Bundles
Pracht performs route-level code splitting via the Vite plugin and emits a manifest. This skill reads that manifest, sizes each route's client payload, and surfaces the worst offenders.
Step 1: Build with analysis
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out. pracht inspect needs the pracht plugin in the vite config.
inspect build needs a prior pracht build.
pracht build --analyze --json
This is the native per-route payload report: for every route (pattern + render
mode) it emits the transitive client JS chunks with raw and gzip sizes
(routes[].chunks), route-specific and total sums (routeGzipBytes,
totalGzipBytes), and the shared entry chunks broken out (shared), sorted by
total gzip descending. A stale dist/ produces misleading numbers — rebuild,
don't reuse.
Exit-code footgun: when configured budgets fail, pracht build --analyze --json sets a nonzero exit code while still printing valid JSON on stdout.
Do not treat exit 1 as "no output" — parse stdout anyway, or run with
--no-budget-fail to keep the exit code clean.
For a human-readable table, use pracht build --analyze instead.
Step 2: Pull supporting metadata
pracht inspect build --json
Captures the resolved adapter, client entry URL, and CSS/JS manifest. Cross
reference with dist/client/.vite/manifest.json for chunk metadata
(file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in
analysis (Step 4) and CSS sizing, which the analyze report does not cover.
Size CSS from the cssManifest this command reports, never from
dist/client/.vite/manifest.json. A hydration: "none" or "islands" route is
absent from the client manifest by construction, so reading it directly reports
no CSS for a page whose document links a stylesheet — under-reporting exactly
the routes pracht recommends. inspect merges dist/server/css-manifest.json,
the build's own record, over the client manifest; an older build without that
file is client-only, and the CSS column should say so rather than read as zero.
Step 3: Interpret per-route payload
From the Step 1 JSON, report:
| Route | Hydration | Route JS (gz) | Shared (gz) | Total (gz) | CSS (gz) | LCP-class? |
|---|
gz = gzip size, taken directly from routeGzipBytes / shared.gzipBytes /
totalGzipBytes. Size CSS chunks (from cssManifest) with zlib.gzipSync.
Account for the per-route hydration field the analyze JSON emits (omitted
means "full") before judging sizes:
hydration: "none"routes ship 0 bytes of JS — the report already zeroes them out. Never flag them.hydration: "islands"routes never load the shared client entry; their total is the islands bootstrap plus every island chunk in the app — an upper bound, since which islands a page actually uses is only known at render time. Treat their totals as pessimistic.- Only
"full"routes pay route JS + shared entry.
LCP-class? is yes when total gz exceeds 200 KB — that's the order of
magnitude where mid-tier mobile starts losing LCP budget. Apply the threshold
after the hydration adjustment above.
If the app declares budgets in the pracht plugin config, the Step 1 JSON also
contains a budgets section with per-route pass/fail — lead the report with any
failures. If no budgets are configured, suggest adding them (see
https://pracht.resynapse.dev/docs/reference/config), starting from the current sizes plus ~10% headroom.
Step 4: Vendor chunk health
Identify chunks under node_modules/. For each:
- Size (raw and gz).
- Number of route chunks that import it (fan-in).
A vendor chunk imported by every route is the framework runtime — expected. A vendor chunk imported by ONE route is a code-splitting opportunity (move the import inside that route's component or lazy-load it).
A vendor chunk over 100 KB gz that is imported by every route is worth a manual review — common offenders: date libraries, validation libraries, icon sets, charting libraries.
Step 5: Heavy dependencies in route components
For each route chunk over 50 KB gz, run pracht inspect build --json plus
du-style analysis on the chunk contents:
- Grep the chunk source for known heavy module headers (
moment,lodash,chart.js,three,@stripe/stripe-js, etc.). - For each, recommend: (a) tree-shakeable alternative, (b) dynamic import
inside an event handler, (c) lazy-load via
lazy()from@pracht/core.
Step 6: Prefetch strategy
pracht inspect routes --json exposes prefetch per route on a current
@pracht/cli. If your CLI version predates the field (it's absent from the
JSON), fall back to grepping the route manifest source for prefetch:.
Route-level strategies are "none", "hover", "intent", "viewport"
("render" exists only as a per-link override — see below). Recommend:
"viewport"for primary-nav links."hover"for content links inside long pages."intent"(default) is fine if you're not sure."none"for routes that are large and rarely visited (admin, settings) so hover doesn't preload them.
Cost model for "viewport": the IntersectionObserver fires once per anchor —
it unobserves the anchor after the first prefetch, and prefetched route state
is cached for 30 seconds — so a link in a marketing footer costs at most one
prefetch per anchor per page view, not one per scroll. Viewport and render
prefetches run three at a time, drop links that left the page, and are skipped
on Save-Data or 2G connections. It's still wasteful
for a 300 KB route that footer visitors rarely click; prefer "none" or
"hover" there. Also note the per-link escape hatch: <Link prefetch="...">
overrides the route-level strategy for a single anchor (and accepts the extra
"render" value — prefetch as soon as the link renders, the most eager
option), so a route can stay on "intent" while its primary-nav link opts
into "viewport" or "render".
If every route ends up on "none", do not stop there — the prefetch listeners
still ship, in a chunk the router lazily imports on every page. Recommend
pracht({ client: { prefetch: false } }) instead, which compiles the whole
mechanism out (~2.6 KB gzip and one fewer request). Confirm nothing relies on
prefetching first: the router silently stops honouring route({ prefetch }) and
<Link prefetch>, and the imperative prefetch() export becomes a no-op. See
https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript.
Step 6b: Chunk-group composition
App-level chunking and pracht's vendor chunk coexist — recommend it freely.
Pracht reads build.rollupOptions.output and contributes its Preact group in
whichever form the app used, so adding a group (to merge the long tail of small
initial chunks, say) does not cost the framework chunk:
// vite.config.ts
build: {
rollupOptions: {
output: {
codeSplitting: { groups: [{ name: "app", tags: ["$initial"], minSize: 20_000 }] },
},
},
}
Two things to check when an app already configures chunking:
- Mixed forms. Rolldown lets
codeSplittingsilence bothadvancedChunksandmanualChunks. An app that sets more than one is running onlycodeSplitting; report the dead config aswarn. - A group that swallows Preact. Precedence is higher
priorityfirst, then declaration order, with pracht's group last — so an app group matchingnode_modulesat equal priority absorbs the framework runtime. If a separate, long-cached framework chunk was the intent, recommend aprioritybump or atestthat excludes Preact.
Verify any grouping change against the prerendered HTML, not just the numbers:
a group broad enough to reshuffle entry chunks can drop the stylesheet links
pracht injects per route. Compare <link rel="stylesheet"> tags in
dist/client/**/index.html before and after.
pracht({ vendorChunk: false }) makes pracht contribute nothing, for an app
that wants Preact inside its own chunks or places frameworkChunkGroups()
itself.
Pracht contributes one group to the server build too: each island becomes
its own chunk, so a route that does not fully hydrate can resolve its CSS from
that build without inheriting the server entry's merged stylesheet. An app that
configures SSR chunking composes with it the same way the client side does; a
group that pulls islands back into the entry shows up as extra <link rel="stylesheet"> tags on static pages, and the build warns.
build.cssCodeSplit: false is rejected outright — there is no index.html to
link the single stylesheet it produces, so every page would ship without one.
When a route's CSS is small and the site is entered cold — a static export, a
content site arriving from search — the stylesheet link is often the only
render-blocking request left on a page that ships no JavaScript. Report
pracht({ inlineCss: true }) as a suggestion there, with the cost: the same
bytes in every document and no shared stylesheet cache. An app whose visitors
move between pages should keep the link.
Step 7: Report
Three sections:
- Top 10 routes by total client payload — sorted desc.
- Top 10 vendor chunks by size — with fan-in.
- Suggestions — ordered by impact (KB saved × routes affected).
Tag every finding with a primary severity — error (budget failure), warn
(LCP-class route, single-route vendor chunk), info (tuning opportunity) —
and keep the size numbers as supporting detail.
Include before/after estimates for each suggestion: "Lazy-load chart.js
inside Component: -180 KB gz off /dashboard/analytics."
Rules
- Always run
pracht build --analyze --jsonfirst. A stale build is a source of bad advice. - Use the Vite manifest as the source of truth — chunk names rotate per build.
- Report gzip size, not raw — the wire size is what users pay.
- Distinguish "shared" code (entry + framework) from "route-specific" code when reporting; users can only optimize the latter.
- Do not auto-edit. Bundle changes have render-blocking implications; surface and let the user choose.
$ARGUMENTS
Files (pracht)
-
SKILL.md 10.5 KB
--- name: audit-bundles version: 1.2.1 description: | Analyze a pracht production build: client bytes per route, fat vendor chunks, routes shipping large dependencies, and dynamic `import()` / prefetch recommendations. Use for "audit bundles", "why is my JS so big", "bundle size per route", "what's in my vendor chunk", "tune prefetching". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht Audit Bundles Pracht performs route-level code splitting via the Vite plugin and emits a manifest. This skill reads that manifest, sizes each route's client payload, and surfaces the worst offenders. ## Step 1: Build with analysis MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over shelling out. `pracht inspect` needs the pracht plugin in the vite config. `inspect build` needs a prior `pracht build`. ```bash pracht build --analyze --json ``` This is the native per-route payload report: for every route (pattern + render mode) it emits the transitive client JS chunks with raw and gzip sizes (`routes[].chunks`), route-specific and total sums (`routeGzipBytes`, `totalGzipBytes`), and the shared entry chunks broken out (`shared`), sorted by total gzip descending. A stale `dist/` produces misleading numbers — rebuild, don't reuse. Exit-code footgun: when configured budgets fail, `pracht build --analyze --json` sets a nonzero exit code while still printing valid JSON on stdout. Do not treat exit 1 as "no output" — parse stdout anyway, or run with `--no-budget-fail` to keep the exit code clean. For a human-readable table, use `pracht build --analyze` instead. ## Step 2: Pull supporting metadata ```bash pracht inspect build --json ``` Captures the resolved adapter, client entry URL, and CSS/JS manifest. Cross reference with `dist/client/.vite/manifest.json` for chunk metadata (file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in analysis (Step 4) and CSS sizing, which the analyze report does not cover. Size CSS from the `cssManifest` this command reports, never from `dist/client/.vite/manifest.json`. A `hydration: "none"` or `"islands"` route is absent from the client manifest by construction, so reading it directly reports no CSS for a page whose document links a stylesheet — under-reporting exactly the routes pracht recommends. `inspect` merges `dist/server/css-manifest.json`, the build's own record, over the client manifest; an older build without that file is client-only, and the CSS column should say so rather than read as zero. ## Step 3: Interpret per-route payload From the Step 1 JSON, report: | Route | Hydration | Route JS (gz) | Shared (gz) | Total (gz) | CSS (gz) | LCP-class? | | ----- | --------- | ------------- | ----------- | ---------- | -------- | ---------- | `gz` = gzip size, taken directly from `routeGzipBytes` / `shared.gzipBytes` / `totalGzipBytes`. Size CSS chunks (from `cssManifest`) with `zlib.gzipSync`. Account for the per-route `hydration` field the analyze JSON emits (omitted means `"full"`) before judging sizes: - `hydration: "none"` routes ship **0 bytes** of JS — the report already zeroes them out. Never flag them. - `hydration: "islands"` routes never load the shared client entry; their total is the islands bootstrap plus **every** island chunk in the app — an upper bound, since which islands a page actually uses is only known at render time. Treat their totals as pessimistic. - Only `"full"` routes pay route JS + shared entry. `LCP-class?` is `yes` when total gz exceeds 200 KB — that's the order of magnitude where mid-tier mobile starts losing LCP budget. Apply the threshold after the hydration adjustment above. If the app declares `budgets` in the pracht plugin config, the Step 1 JSON also contains a `budgets` section with per-route pass/fail — lead the report with any failures. If no budgets are configured, suggest adding them (see <https://pracht.resynapse.dev/docs/reference/config>), starting from the current sizes plus ~10% headroom. ## Step 4: Vendor chunk health Identify chunks under `node_modules/`. For each: - Size (raw and gz). - Number of route chunks that import it (fan-in). A vendor chunk imported by every route is the framework runtime — expected. A vendor chunk imported by ONE route is a code-splitting opportunity (move the import inside that route's component or lazy-load it). A vendor chunk over 100 KB gz that is imported by every route is worth a manual review — common offenders: date libraries, validation libraries, icon sets, charting libraries. ## Step 5: Heavy dependencies in route components For each route chunk over 50 KB gz, run `pracht inspect build --json` plus `du`-style analysis on the chunk contents: - Grep the chunk source for known heavy module headers (`moment`, `lodash`, `chart.js`, `three`, `@stripe/stripe-js`, etc.). - For each, recommend: (a) tree-shakeable alternative, (b) dynamic import inside an event handler, (c) lazy-load via `lazy()` from `@pracht/core`. ## Step 6: Prefetch strategy `pracht inspect routes --json` exposes `prefetch` per route on a current `@pracht/cli`. If your CLI version predates the field (it's absent from the JSON), fall back to grepping the route manifest source for `prefetch:`. Route-level strategies are `"none"`, `"hover"`, `"intent"`, `"viewport"` (`"render"` exists only as a per-link override — see below). Recommend: - `"viewport"` for primary-nav links. - `"hover"` for content links inside long pages. - `"intent"` (default) is fine if you're not sure. - `"none"` for routes that are large and rarely visited (admin, settings) so hover doesn't preload them. Cost model for `"viewport"`: the IntersectionObserver fires once per anchor — it unobserves the anchor after the first prefetch, and prefetched route state is cached for 30 seconds — so a link in a marketing footer costs at most one prefetch per anchor per page view, not one per scroll. Viewport and render prefetches run three at a time, drop links that left the page, and are skipped on Save-Data or 2G connections. It's still wasteful for a 300 KB route that footer visitors rarely click; prefer `"none"` or `"hover"` there. Also note the per-link escape hatch: `<Link prefetch="...">` overrides the route-level strategy for a single anchor (and accepts the extra `"render"` value — prefetch as soon as the link renders, the most eager option), so a route can stay on `"intent"` while its primary-nav link opts into `"viewport"` or `"render"`. If every route ends up on `"none"`, do not stop there — the prefetch listeners still ship, in a chunk the router lazily imports on every page. Recommend `pracht({ client: { prefetch: false } })` instead, which compiles the whole mechanism out (~2.6 KB gzip and one fewer request). Confirm nothing relies on prefetching first: the router silently stops honouring `route({ prefetch })` and `<Link prefetch>`, and the imperative `prefetch()` export becomes a no-op. See <https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript>. ## Step 6b: Chunk-group composition App-level chunking and pracht's vendor chunk coexist — recommend it freely. Pracht reads `build.rollupOptions.output` and contributes its Preact group in whichever form the app used, so adding a group (to merge the long tail of small initial chunks, say) does not cost the framework chunk: ```ts // vite.config.ts build: { rollupOptions: { output: { codeSplitting: { groups: [{ name: "app", tags: ["$initial"], minSize: 20_000 }] }, }, }, } ``` Two things to check when an app already configures chunking: - **Mixed forms.** Rolldown lets `codeSplitting` silence both `advancedChunks` and `manualChunks`. An app that sets more than one is running only `codeSplitting`; report the dead config as `warn`. - **A group that swallows Preact.** Precedence is higher `priority` first, then declaration order, with pracht's group last — so an app group matching `node_modules` at equal priority absorbs the framework runtime. If a separate, long-cached framework chunk was the intent, recommend a `priority` bump or a `test` that excludes Preact. Verify any grouping change against the prerendered HTML, not just the numbers: a group broad enough to reshuffle entry chunks can drop the stylesheet links pracht injects per route. Compare `<link rel="stylesheet">` tags in `dist/client/**/index.html` before and after. `pracht({ vendorChunk: false })` makes pracht contribute nothing, for an app that wants Preact inside its own chunks or places `frameworkChunkGroups()` itself. Pracht contributes one group to the **server** build too: each island becomes its own chunk, so a route that does not fully hydrate can resolve its CSS from that build without inheriting the server entry's merged stylesheet. An app that configures SSR chunking composes with it the same way the client side does; a group that pulls islands back into the entry shows up as extra `<link rel="stylesheet">` tags on static pages, and the build warns. `build.cssCodeSplit: false` is rejected outright — there is no `index.html` to link the single stylesheet it produces, so every page would ship without one. When a route's CSS is small and the site is entered cold — a static export, a content site arriving from search — the stylesheet link is often the only render-blocking request left on a page that ships no JavaScript. Report `pracht({ inlineCss: true })` as a suggestion there, with the cost: the same bytes in every document and no shared stylesheet cache. An app whose visitors move between pages should keep the link. ## Step 7: Report Three sections: 1. **Top 10 routes by total client payload** — sorted desc. 2. **Top 10 vendor chunks by size** — with fan-in. 3. **Suggestions** — ordered by impact (KB saved × routes affected). Tag every finding with a primary severity — `error` (budget failure), `warn` (LCP-class route, single-route vendor chunk), `info` (tuning opportunity) — and keep the size numbers as supporting detail. Include before/after estimates for each suggestion: "Lazy-load `chart.js` inside `Component`: -180 KB gz off `/dashboard/analytics`." ## Rules 1. Always run `pracht build --analyze --json` first. A stale build is a source of bad advice. 2. Use the Vite manifest as the source of truth — chunk names rotate per build. 3. Report gzip size, not raw — the wire size is what users pay. 4. Distinguish "shared" code (entry + framework) from "route-specific" code when reporting; users can only optimize the latter. 5. Do not auto-edit. Bundle changes have render-blocking implications; surface and let the user choose. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.