A car speedometer and instrument dials lit on a dashboard

Guide

Claude Code rules for Core Web Vitals

Put the budget in CLAUDE.md so the agent knows it, then enforce it with a hook and a build gate that exits non-zero. Rules shape behaviour; exit codes guarantee it.

Published July 19, 2026

Write the budget into CLAUDE.md so the agent knows it, then enforce it with a PostToolUse hook and a build gate that exits non-zero so the agent cannot ship past it. Rules in a markdown file shape behaviour; only an exit code guarantees it. Both layers, or the numbers drift within a fortnight.

Why written rules are not enough on their own

A coding agent regresses performance in ways that each look individually reasonable. It reaches for a component library to solve a dropdown, marks a component "use client" because it needed one event handler, drops in an icon package for a single chevron, or adds a font from a CDN because that is what the documentation example does. None of those are mistakes in isolation. Together they are how a site that shipped at 100 arrives at 78 a month later.

The instinct is to write it all down, and that instinct is correct as far as it goes. CLAUDE.md is loaded into context, and stating the budget plainly does raise compliance. But context is probabilistic: it competes with the task, the file being edited, and everything else in the window. If the number genuinely matters, it needs to exist somewhere the model cannot talk its way past.

Layer one: state the budget as a number

Vague rules produce vague compliance. “Keep the site fast” is unenforceable and unfalsifiable; “the homepage First Load JS budget is 115 kB” is a number that can be checked. Every rule worth writing should name a threshold, a file path, or a specific API, and should say what to do instead of what not to do.

CLAUDE.md — the performance section from this site
## Performance (non-negotiable)

- The homepage hero stays a **server component with text as the LCP
  element**. No client components, images, or videos above the fold on `/`.
- Server components by default; add `"use client"` only for real
  interactivity — an event handler is not automatically real interactivity.
- **Never import framer-motion in the layout path** — `Header`, `Footer`,
  `MobileNav`, or anything reachable from `app/layout.tsx`.
- **No new runtime dependencies** without explicit approval.
- After any change run `npm run build` and check the route table: homepage
  First Load JS budget is **<= 115 kB**. If a change pushes past it, treat
  that as a failure to fix, not a number to report.
- Images: always `next/image` with explicit width/height; `loading="lazy"`
  below the fold; `priority` only above the fold.
- **CLS stays 0**: explicit dimensions on all media, no content swaps that
  change size, placeholder heights for anything dynamic.

Claude Code reads ./CLAUDE.md at the project root, ~/.claude/CLAUDE.md for rules that apply everywhere, and picks up a CLAUDE.md inside a subdirectory when it starts reading files there — which is the right home for rules that only apply to one package in a monorepo. Files are concatenated rather than overridden, and @path/to/file imports another file into the same context, so a long ruleset can be split without duplicating it.

Layer two: the gate that actually stops you

The enforcement layer is a script that reads the production build output and exits non-zero when the budget is exceeded. This is the piece that makes the rule real, because it runs the same way in the agent's session, in your terminal, and in CI — and none of those three can be talked out of it.

scripts/check-budget.mjs — parses the Next.js route table and fails the build
import { execSync } from "node:child_process"

// Route -> max First Load JS in kB. Add a route here the day you start
// caring about it; anything unlisted is unbudgeted and will drift.
const BUDGETS = {
  "/": 115,
  "/knowledge-base": 120,
}

const out = execSync("npx next build", { encoding: "utf8" })

// Route table rows look like:  ┌ ○ /    715 B    112 kB
const rows = [...out.matchAll(/^[┌├└]\s+[○ƒλ●]\s+(\S+)\s+[\d.]+\s*[kMB]*B\s+([\d.]+)\s*kB$/gm)]

let failed = false
for (const [, route, firstLoad] of rows) {
  const budget = BUDGETS[route]
  if (budget === undefined) continue
  const kb = Number(firstLoad)
  const verdict = kb <= budget ? "ok" : "OVER"
  console.log(`${verdict.padEnd(5)} ${route.padEnd(30)} ${kb} kB / ${budget} kB`)
  if (kb > budget) failed = true
}

if (failed) {
  console.error("\nFirst Load JS budget exceeded. Fix the regression — " +
                "raising the budget is a decision, not a workaround.")
  process.exit(1)
}

The last line matters more than the rest of the script. If the failure message says “update the budget in check-budget.mjs”, an agent will helpfully do exactly that and report success. Failure text is a prompt: write it as one.

Layer three: hooks, for the things that must never land

A build gate catches regressions after the code exists. A PreToolUse hook catches them before the edit is written, which is what you want for the handful of rules that have no legitimate exception. Hooks live in .claude/settings.json, receive the tool call as JSON on stdin, and block it by exiting with code 2 — stderr goes back to the model as the reason.

.claude/settings.json
{
  "permissions": {
    "ask": [
      "Bash(npm install *)",
      "Bash(npm add *)",
      "Bash(pnpm add *)"
    ],
    "deny": [
      "Edit(public/**)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/guard-layout-path.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
.claude/hooks/guard-layout-path.sh — blocks the one import that cannot appear in the layout path
#!/usr/bin/env bash
# stdin carries the tool call; tool_input.file_path is the target.
payload="$(cat)"
file="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')"
content="$(printf '%s' "$payload" | jq -r '.tool_input.new_string // .tool_input.content // empty')"

case "$file" in
  */app/layout.tsx|*/components/layout/*)
    if printf '%s' "$content" | grep -qE "from ['\"]framer-motion"; then
      echo "framer-motion is banned in the layout path: it ships to every route." >&2
      echo "Use CSS transitions, or components/motion/Reveal for scroll-in." >&2
      exit 2   # exit 2 blocks the tool call and returns stderr to the model
    fi
    ;;
esac
exit 0

Keep this layer small. Every hook is latency on every matching tool call, and a slow session is a session where the hooks get disabled. One or two absolute rules here; everything else belongs in the build gate, where it costs nothing until you actually build.

What to measure it against

Lighthouse run locally will lie to you about LCP — the simulated throttling model chains script evaluation into text paint and reports two to three seconds where the real paint is well under half of one. Judge LCP on the deployed URL through PageSpeed Insights, and use the local build for the things it measures honestly: bundle size, layout shift, and the presence of anything third-party.

For measuring inside an agent session, an MCP server that drives a real browser is the honest option — Chrome DevTools MCP and Playwright MCP both expose navigation, tracing, and console access as tools. Configure one in .mcp.json at the project root so it is shared with the team rather than living in one person's local config:

.mcp.json
{
  "mcpServers": {
    "chrome-devtools": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "chrome-devtools-mcp@latest"]
    }
  }
}

The same discipline is what keeps the scores on this site behind a strict Content-Security-Policy — see the CSP build note for the header itself. If your editor of choice is Cursor rather than Claude Code, the rule content is identical but the enforcement point moves — that version is here.

Rules keep an agent from undoing your work; they don't diagnose a site that is already slow. That part is a job — see Core Web Vitals rescues for how an existing site gets fixed in place.

Questions

Why not just put the rules in CLAUDE.md and trust them?

Because CLAUDE.md is context, and context competes with everything else in the window. It raises the odds a rule is followed; it does not make breaking one impossible. Anything you cannot afford to lose needs a gate that returns a non-zero exit code — the model can misread a paragraph, but it cannot argue with a failing build.

Doesn't a PostToolUse hook on every edit slow the session down?

It does, which is why the hook should be cheap and the expensive check should be the build gate. A regex over a diff costs milliseconds; running a full production build after every file write costs minutes and will make you turn the hook off within a day.

What if the agent needs to add a dependency legitimately?

Then a human approves it. Putting package installs behind an ask rule rather than a deny rule keeps the door open while making the decision explicit — which is exactly the property you want, because most performance regressions arrive as a reasonable-sounding new package.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on web development

Related service: Web Development

Want this kind of engineering on your project?

Tall Karol takes on fractional and project-based engagements for startups and agencies.

Book a working session