The Day Marketing Added Pixel #13: Performance Budgets That Keep LCP Green
Budgets aren’t scorecards — they’re guardrails that keep your UX fast when scope, traffic, and third‑parties creep. Here’s how to make them real, measurable, and enforced in CI.
If you can’t enforce it in CI, it’s not a budget — it’s a wish.Back to all posts
The problem you’ve lived: one tag, one sale-killing regression
Two Fridays ago, a retail client called: conversions down 8% on mobile. Nothing “functional” shipped. Marketing added a consented tracking pixel (number 13, if you’re counting) and a “lightweight” promo widget. Largest Contentful Paint (LCP) went from 2.4s → 3.6s at p75 on mobile 4G. Cart abandonment spiked. Revenue chart looked like a ski slope.
We rolled it back, but the damage was done. I’ve seen this movie at SaaS, fintech, and media. The fix isn’t a one-time perf sprint; it’s a performance budget that everyone respects because it’s enforced like a unit test.
Make budgets user-facing SLOs, not vanity scores
Budgets that survive real-world chaos map to user experience and revenue, not a single Lighthouse number.
- Pick critical journeys: home → product → cart → checkout, search results → detail, dashboard → report.
- Segment realistically: mobile p75 on 4G (or Effective Connection Type 3g/4g), top 3 geos, and your top device class.
- Set SLOs in Web Vitals:
- LCP ≤ 2.5s p75 (content paints fast)
- INP ≤ 200ms p75 (site feels snappy)
- CLS ≤ 0.1 p75 (no jank)
- Tie to business: “If LCP > 2.5s for Checkout p75, we pause new tag rollouts.” Period.
Translation to resource budgets keeps your build honest:
- JS ≤ 170KB gz on critical path (after code split)
- CSS ≤ 50KB gz critical CSS inline, rest async
- Images ≤ 600KB above the fold, AVIF/WebP where supported
- ≤ 3 third-party scripts on critical path (ads/analytics/consent)
- TTFB ≤ 500ms p75 (CDN + origin)
If you can’t enforce it in CI, it’s not a budget — it’s a wish.
Wire the budget into CI/CD so regressions never reach prod
I don’t trust dashboards alone. Budgets should break builds the same way failing tests do.
- Lighthouse CI in PRs against a preview URL.
- Budgets JSON for resource and timing constraints.
- Assert Web Vitals proxies (Lighthouse isn’t RUM, but it’s a good gate).
{
"ci": {
"collect": {
"url": ["https://preview.example.com/", "https://preview.example.com/checkout"],
"numberOfRuns": 3,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.90 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"interactive": ["warn", { "maxNumericValue": 3000 }]
}
},
"upload": { "target": "temporary-public-storage" }
},
"budgets": [
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 170 },
{ "resourceType": "stylesheet", "budget": 50 },
{ "resourceType": "image", "budget": 600 },
{ "resourceType": "total", "budget": 900 }
],
"timings": [
{ "metric": "interactive", "budget": 3000 },
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
]
}
]
}# .github/workflows/perf-budgets.yml
name: perf-budgets
on: [pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx lhci autorun --config=./lighthouserc.jsonPair this with a visual diff and a bundle diff (webpack-bundle-analyzer, rollup-plugin-visualizer). If your CI doesn’t fail red when someone adds moment accidentally, you don’t have a budget.
Backstop with RUM: budgets as live SLOs with alerts
CI gates are necessary but synthetic. Production reality is devices, cookies, geo, and third-parties. Put budgets on real users.
- RUM tooling: SpeedCurve LUX, New Relic Browser, Elastic RUM, Datadog RUM. Tag events by
path,device,geo,experiment. - p75 by journey: “/checkout” on mobile 4G is green if LCP ≤ 2.5s.
- Alerting: PagerDuty/Slack alert when any journey breaches budget for >15 minutes with ≥500 samples.
- Seasonality-aware: relax alert sensitivity on Black Friday, not the targets.
Minimal web-vitals wiring to your analytics endpoint:
// rum.ts
import { onLCP, onINP, onCLS } from 'web-vitals';
const send = (metric: any) => {
navigator.sendBeacon('/api/rum', JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
path: location.pathname,
device: /Mobi/.test(navigator.userAgent) ? 'mobile' : 'desktop'
}));
};
onLCP(send);
onINP(send);
onCLS(send);On the server/APM, compute p75 per path/device daily and compare to budget. Post a small perf report to Slack every morning. Culture matters here.
Engineering to the budget: what actually works
No silver bullets. You win by shaving big rocks on the critical path.
- Ship less JS
- Adopt route-based code splitting and dynamic imports in React/Next.js:
// pages/product.tsx
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
ssr: false,
loading: () => <div className="skeleton" />
});Prefer
lodash-esor native APIs over pulling wholelodash.Switch to faster toolchains (
esbuild,swc) and fine-tune treeshaking.Audit bundles weekly. Blocklist troublemakers (
moment, large icon packs) or vendor them server-side.Prioritize the hero for LCP
- Inline critical CSS; defer the rest with
media="print"swap orrel="preload" as="style". - Serve next-gen images and hint priority:
- Inline critical CSS; defer the rest with
<img src="/hero.avif" width="1200" height="800" alt="Hero"
fetchpriority="high" decoding="async"/>- Use
content-visibility: autoto short-circuit rendering below the fold:
.card { content-visibility: auto; contain-intrinsic-size: 300px 200px; }Tame third-parties
- Default to
async/defer. Gate non-essential tags behind consent andrequestIdleCallback. - Load heavy tags off the main thread (e.g., Partytown) or sandbox via iframes.
- Maintain a third-party budget: each new tag must buy down cost elsewhere.
- Default to
Reduce TTFB (the budget starter)
- Put a real CDN in front (Fastly/Cloudflare/Akamai). Enable
stale-while-revalidateand cache HTML for anonymous traffic. - Keep origins fast: DB indexes, connection pooling, SSR streaming if you’re on React 18/Next.js 13+.
- Emit Server-Timing to track what’s slow:
- Put a real CDN in front (Fastly/Cloudflare/Akamai). Enable
# nginx.conf (requires brotli module if used)
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
brotli on;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
add_header Server-Timing "cdn-cache;desc=HIT, edge;dur=12, origin;dur=120";Kill layout shifts (CLS)
- Always specify width/height or
aspect-ratiofor media. - Reserve space for ads and dynamically injected components.
- Always specify width/height or
Don’t guess — test
- Canary with 5-10% traffic and watch RUM. Use
sitespeed.io/k6for synthetic checks in staging.
- Canary with 5-10% traffic and watch RUM. Use
These moves routinely take LCP down 20–40% and drop INP into the green without fancy rewrites.
Put numbers on the board: what we’ve seen
- B2C retail (Next.js + Fastly): JS from 280KB → 150KB gz, LCP 3.1s → 2.1s (p75 mobile), checkout conversion +9.3%.
- B2B SaaS dashboard (React, heavy charts): deferred 2 charts behind interaction, INP 380ms → 140ms, support tickets about “laggy UI” down 60%.
- Media homepage (WordPress + Cloudflare): hero image AVIF +
fetchpriority=high, TTFB -25%, LCP 2.9s → 2.3s; ad revenue stable with sandboxed tags.
Do budgets slow shipping? No — they speed it up because engineers stop arguing perf in PRs. The gate is the gate. When you need an exception (e.g., annual event), write an RFC with a rollback date and explicit buy-down tasks.
Keep budgets living documents, not commandments
Treat budgets like SLOs:
- Version and publish them in your repo. Update quarterly with product roadmap changes.
- Differentiate by page type: marketing page ≠ checkout ≠ dashboard.
- Educate: a one-pager for Marketing on “the cost of tags” pays dividends.
- Automate guardrails: a Slack bot that posts “Budget breach on /checkout” will prevent hand-wavy debates.
When budgets drift, look at the business lens first. If you truly need more JS for a strategic bet, fine — then reduce elsewhere and keep the user experience consistent.
Quick start: your first week playbook
- Pick top 3 journeys and set SLOs: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1.
- Implement CI gates with Lighthouse CI and budgets JSON.
- Add
web-vitalsRUM and a daily Slack perf report. - Cut 50–100KB JS: code split, drop dead deps, lazy-load charts/maps.
- Prioritize hero image and inline critical CSS.
- Put CDN caching in front of SSR; measure TTFB with Server-Timing.
- Set third-party budget and move one heavy tag off main thread.
If you want a second set of eyes, GitPlumbers has done this for teams shipping weekly without breaking UX. We’ll help you define budgets that stick and build the CI/RUM plumbing so you don’t have to babysit it.
Key takeaways
- Define budgets in user terms first (p75 LCP/INP/CLS), then translate to resource and timing budgets.
- Enforce in CI/CD with Lighthouse CI and fail PRs that push LCP > 2.5s or scripts > 170KB gz.
- Back budgets with RUM (SpeedCurve/New Relic/Elastic) and alert on path-level regressions by device/network.
- Optimize to the budget: ship less JS, prioritize the hero, constrain third-parties, and reduce TTFB via CDN and caching.
- Budgets evolve. Version them with product changes and require RFCs for exceptions with rollback plans.
Implementation checklist
- Pick your critical journeys and device/network mix (e.g., mobile p75 on 4G).
- Set user-facing SLOs: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 (p75).
- Translate to resource budgets: JS ≤ 170KB gz, CSS ≤ 50KB, images ≤ 600KB, ≤ 3 third-parties on critical path.
- Wire CI gates with `lighthouse-ci` and budgets JSON; fail on regressions.
- Instrument RUM with `web-vitals` and alert in your APM. Segment by path, device, and geo.
- Optimize: code split, compress, cache, prefetch wisely, and sandbox third-parties.
- Canary and watch: use synthetic + RUM to validate before 100% rollout.
- Revisit quarterly; align budgets with product bets and seasonality.
Questions we hear from teams
- Should budgets be different for desktop and mobile?
- Yes. Budgets are about real users. Mobile CPU/network is your true bottleneck, so set stricter JS and TTFB budgets there. Keep desktop budgets, but optimize primarily to mobile p75.
- How do we handle big launches or seasonal spikes?
- Don’t change targets; change procedures. Use canaries, relax alert sensitivity windows, and pre-warm caches/CDN. If you must breach temporarily, file an RFC with rollback and a buy-down plan in the next sprint.
- What if Lighthouse passes but RUM is red?
- Synthetic ≠ real. Check third-parties, geo latency, long-tail devices, and cache hit rates. Treat Lighthouse as a gate and RUM as the truth. Tune caches and prioritize the hero resources for the real world.
- How do we keep Marketing from adding heavy tags?
- Create a third-party budget and a change process. Every new tag: async/defer by default, consent-gated, measured in a staging sandbox, and must offset cost by removing or deferring something else. CI and RUM both enforce the line.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
