Your “Optimization” Didn’t Ship Until a Bot Failed the Build

Automated performance testing that guards user-facing metrics (LCP/TTFB/INP) and proves ROI—before your next release quietly adds 800ms to checkout.

If your pipeline can’t fail on a performance regression, you don’t have performance engineering—you have optimism.
Back to all posts

The day your “small refactor” adds 900ms to checkout

I’ve watched teams spend two sprints “optimizing” a hot path, ship it, celebrate… and then quietly lose conversion because a separate UI tweak bloated the JS bundle and pushed LCP over the cliff. Nobody noticed until Support started getting “site feels slow” tickets and your PM is staring at a funnel drop.

Here’s the uncomfortable truth: performance improvements don’t exist until they’re validated automatically. If your CI can’t catch a regression, you’re relying on hope, heroics, and someone remembering to open DevTools before Friday’s deploy.

What actually works is boring and effective: automated performance tests that run on every meaningful change, compare against a baseline, and fail the build when the product gets slower—using user-facing metrics and numbers your CFO will care about.

Measure what users feel (and what the business pays for)

If you’re still gating releases on “average response time” alone, you’re missing the plot. Users don’t experience averages; they experience tail latency, jank, and the moment the page becomes usable.

Start with a small set of metrics that map cleanly to user experience and business impact:

  • Frontend (RUM-aligned):
    • LCP (Largest Contentful Paint): “Did the page show something real?”
    • INP (Interaction to Next Paint): “Did it respond when I clicked?”
    • TTFB (Time to First Byte): usually backend + CDN + network.
  • Backend (API critical path):
    • p95 / p99 latency per endpoint (not global).
    • error rate and timeouts (slow often becomes broken).
    • queue depth / saturation if you’re async.

Then do the part most teams skip: tie metrics to dollars.

  • 300–500ms improvement in LCP on checkout pages commonly shows up as +0.5% to +3% conversion depending on your traffic mix.
  • 200ms improvement in p95 for search can cut “rage clicks” and reduce support tickets.
  • Fixing p99 spikes often reduces autoscaling churn and knocks real money off infra bills.

At GitPlumbers, we’ll often build a simple “impact model” spreadsheet for leadership:

  • sessions/day × conversion rate × AOV × expected lift from LCP/TTFB change
  • plus infra cost deltas (e.g., fewer pods, lower DB CPU, reduced egress via caching)

It doesn’t need to be perfect. It needs to be directional and defensible.

Design performance budgets that can actually fail a build

Budgets are where performance testing stops being a dashboard and becomes a guardrail.

Keep budgets specific, per journey, per metric. Bad budget: “site should be fast.” Good budget: “checkout LCP <= 2.5s on mobile emulation; /api/cart p95 <= 250ms under 100 RPS.”

Practical budget rules I’ve seen work in real orgs:

  • Use p95 as the default gate; watch p99 as the “this will page you at 2am” signal.
  • Gate on regression more than absolute perfection:
    • Fail if LCP worsens by >10% vs baseline, or by >200ms (whichever is larger).
    • Fail if API p95 worsens by >15% or errors increase by >0.2%.
  • Allow time-boxed overrides. If you let people bypass budgets forever, they will.

Example budget config (simple and reviewable):

{
  "journeys": {
    "checkout": {
      "lcp_ms": 2500,
      "inp_ms": 200,
      "ttfb_ms": 400,
      "regression": { "percent": 10, "absolute_ms": 200 }
    }
  },
  "api": {
    "/api/cart": { "p95_ms": 250, "error_rate": 0.002 },
    "/api/checkout": { "p95_ms": 400, "error_rate": 0.003 }
  }
}

Budgets should live next to the code, be code-reviewed, and change only when you can explain why users won’t notice.

Make it automated: Lighthouse CI + k6 in your pipeline

You want two kinds of automated tests:

  • Synthetic browser tests for Web Vitals (repeatable): Lighthouse CI + scripted journeys.
  • Load tests for APIs and critical backend paths: k6 (or Gatling if you’re a JVM shop).

Frontend: Lighthouse CI against real journeys

In modern apps (React/Next.js, Vue, whatever), “homepage load” is not your bottleneck—your funnel is. Test the flows that make money.

Run Lighthouse CI against a deployed preview environment (not your laptop). Example lighthouserc.json:

{
  "ci": {
    "collect": {
      "url": [
        "https://staging.example.com/checkout",
        "https://staging.example.com/search?q=drill"
      ],
      "numberOfRuns": 3,
      "settings": {
        "preset": "desktop",
        "throttlingMethod": "simulate"
      }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "interactive": ["warn", { "maxNumericValue": 3500 }]
      }
    }
  }
}

Then wire it into CI (GitHub Actions example):

name: perf-gate
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run deploy:staging
      - run: npx @lhci/cli autorun --config=./lighthouserc.json

If you want to avoid the “Lighthouse flake” drama, stabilize the environment:

  • Run against a dedicated perf staging (same instance type, warmed caches).
  • Use 3–5 runs and take the median.
  • Fail on meaningful deltas, not a 2-point score wobble.

Backend: k6 for p95/p99 and error rate

Here’s a minimal k6 test for an API endpoint with thresholds:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.002'],
    http_req_duration: ['p(95)<250', 'p(99)<600'],
  },
};

export default function () {
  const res = http.get(`${__ENV.BASE_URL}/api/cart`);
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.1);
}

Run it in CI against the same deployed build you tested in Lighthouse:

BASE_URL=https://staging.example.com k6 run perf/cart.js

Now you’re gating on p95/p99 and error rate—the stuff that causes incidents and churn.

Baselines, regression detection, and “no more mystery wins”

The biggest trap: teams run perf tests but have no opinion on what “good” is. They see charts, shrug, and ship anyway.

The fix is to store a baseline and compare. Options I’ve used successfully:

  • Compare to last green build on main.
  • Compare to a blessed baseline commit (update intentionally).
  • Push metrics into Prometheus/Grafana and run a simple comparator step.

A pragmatic pattern:

  1. On main, publish perf results as an artifact + push summary metrics.
  2. On PRs, run the same tests.
  3. Compare PR vs baseline and fail if thresholds are exceeded.

Even a crude JSON diff is enough to start. The key is that regressions stop at the gate, not in production.

Also: always pair synthetic tests with RUM. Lighthouse says you improved LCP? Great—verify in the wild with web-vitals + your analytics stack.

If you’re not already collecting Web Vitals from real users, you’re flying IFR with the instruments turned off.

Optimization techniques that repeatedly move the needle (with outcomes)

You asked for concrete techniques and measurable outcomes. These are the ones I’ve seen deliver across stacks—from crusty Rails monoliths to “we rewrote it in Go” microservices.

1) Reduce payload size (because bandwidth is still a thing)

  • Kill unused JS (webpack-bundle-analyzer, next build --profile).
  • Turn on compression and caching headers correctly.
  • Split bundles by route; lazy-load below-the-fold.

Typical outcomes:

  • -150KB to -600KB JS transferred on critical pages
  • LCP -300ms to -1200ms on mid-tier mobile devices

2) Fix cache correctness (CDN + server + data)

I’ve seen teams add Redis and make things slower because cache keys were wrong and stampedes happened.

What works:

  • Use Cache-Control with s-maxage for CDN where safe.
  • Add stale-while-revalidate for resilience.
  • Protect origin with request coalescing (or “singleflight”).

Example HTTP caching header that’s actually useful:

Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60

Typical outcomes:

  • Origin RPS -30% to -80%
  • TTFB -100ms to -400ms for cacheable pages
  • Lower infra cost and fewer “why is prod melting?” incidents

3) Fix N+1s and query shape (the silent p95 killer)

When your p95 is bad but averages look fine, it’s often DB contention + pathological query patterns.

What works:

  • Add the right composite indexes (measured, not guessed).
  • Preload/joins for ORM N+1.
  • Cap unbounded queries; add pagination.

Typical outcomes:

  • /api/search p95 -200ms to -900ms
  • DB CPU down enough to drop an instance size (real money)

4) Tame tail latency with timeouts + circuit breakers

If you don’t control tail latency, your users will.

  • Set sane upstream timeouts.
  • Add bulkheads / concurrency limits.
  • Use circuit breakers (resilience4j, envoy, istio where appropriate).

Typical outcomes:

  • Fewer cascading failures
  • MTTR improvement because failures become bounded and observable

Shipping it safely: canaries, flags, and SLOs (the grown-up part)

Performance changes are risky because they touch hot paths. The safest teams I’ve worked with treat perf like any other production change:

  1. Ship behind a feature flag or config toggle.
  2. Release via canary deployment (5% → 25% → 100%).
  3. Watch SLO-aligned signals: LCP/INP from RUM, API p95, error rate.
  4. Roll back automatically if you violate the error budget.

If you’re doing ArgoCD/GitOps, this is straightforward to codify: performance budgets gate merges; canaries gate rollouts.

The win isn’t just speed—it’s trust. Teams stop arguing about “feels faster” and start shipping performance improvements as part of normal delivery.

If you can’t prove it moved LCP/INP/p95 and didn’t regress next week, it wasn’t an optimization. It was a story.

Where GitPlumbers comes in (when you’re tired of performance theater)

If you’re in the stage where:

  • every release is a roll of the dice,
  • performance regressions are found by customers,
  • AI-assisted code changes keep slipping in 400ms regressions,

…we do this kind of code rescue and performance hardening all the time at GitPlumbers. We’ll help you pick the right journeys, wire up the CI gates, and—importantly—fix the underlying bottlenecks so the tests don’t just fail forever.

Next step that’s actually useful: run a one-week “perf gate” spike on a single funnel (usually checkout or search). You’ll know fast whether you’re bleeding milliseconds—and money.

Related Resources

Key takeaways

  • If you can’t fail a build on a regression, you don’t have performance work—you have vibes.
  • Start with **user-facing metrics** (LCP/INP/TTFB) and map them to business KPIs (conversion, retention, support load).
  • Use **two layers**: synthetic (repeatable CI) + RUM (real users) to avoid chasing lab-only wins.
  • Treat performance like a contract: **budgets** + baselines + automated gates + exceptions with expirations.
  • Optimization techniques that win repeatedly: **payload reduction**, **cache correctness**, **DB query shape**, **queue/backpressure**, and **tail latency** controls.

Implementation checklist

  • Pick 2–3 critical user journeys (e.g., login, search, checkout) and freeze them as scripted tests.
  • Define performance budgets for Web Vitals (LCP/INP) and backend p95/p99 latency per endpoint.
  • Add Lighthouse CI for frontend and k6 for APIs to CI; run against a stable staging environment.
  • Persist results (artifact + time series) and compare against a baseline commit or last green build.
  • Fail the pipeline on regression beyond thresholds; require explicit, time-boxed overrides.
  • Correlate synthetic results with RUM (Web Vitals + tracing) to ensure changes move real-user outcomes.
  • Ship optimizations behind flags; validate via canary + SLO monitoring before full rollout.

Questions we hear from teams

Won’t Lighthouse CI be too flaky to gate merges?
It can be flaky if you run it on shared, noisy infrastructure and fail on tiny score changes. Run against a stable staging environment, use 3–5 runs with the median, and gate on meaningful metric deltas (e.g., LCP regression >10% or >200ms). Teams keep Lighthouse gates enabled when failures are rare and actionable.
Should we test in CI or only in production with RUM?
Both. CI synthetic tests catch regressions before users see them and give fast feedback to engineers. RUM validates that your lab improvements actually move **real-user** LCP/INP/TTFB across device mixes and geographies. CI prevents bad changes; RUM proves business impact.
What’s the fastest way to start if we have zero performance testing today?
Pick one revenue-critical journey (usually checkout) and one backend endpoint. Add Lighthouse CI for that page and a k6 test for that endpoint. Store a baseline from `main`, gate PRs on regression thresholds, and iterate. One journey done well beats ten dashboards nobody trusts.

Ready to modernize your codebase?

Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.

Talk to GitPlumbers about a performance gate that actually sticks See how we stabilize legacy + AI-generated systems without breaking prod

Related resources