This post is for the engineering lead or founding developer at a health tech startup, or the agency developer maintaining a clinic or hospital site, who has watched page weight creep up release after release. Nobody decided to ship a 3 MB doctor profile page. It arrived one chat widget, one carousel and one unoptimised hero image at a time.
A performance budget stops that drift by turning "keep the site fast" into a build check. It answers three questions: what limits to set, how to set them per template instead of one number for the whole site, and how to make the build fail when a pull request breaks them. The examples use Lighthouse CI, since that is what we run against our own Next.js builds, but the logic carries to any tool that can assert on resource sizes and timings.
It is not a replacement for field data. A build check measures a synthetic load on a build server; the people using your site are on varied phones and networks. Read this alongside the broader healthcare website speed and Core Web Vitals guide, and treat the budget as the guard rail that stops regressions, not as proof the site is fast.
What a budget is, and what it cannot do
A performance budget is a set of numeric limits that a page or template must stay inside. Typical limits fall into three kinds:
- Quantity limits: bytes of JavaScript, bytes of images, number of third-party requests, number of fonts.
- Timing limits: lab Largest Contentful Paint, Total Blocking Time, Cumulative Layout Shift.
- Rule limits: no render-blocking third-party script in the head, every image has dimensions.
Quantity limits are the most stable because they do not vary with runner load. Timing limits in a CI environment are noisy, so they need several runs and a sensible aggregation. Rule limits catch specific mistakes.
One important limitation: Lighthouse runs in a lab and does not tap or type, so it cannot report Interaction to Next Paint. Total Blocking Time is the usual lab proxy for main-thread congestion, but it is a proxy. Keep a field check for INP using the method in web.dev's INP documentation, which sets 200 milliseconds at the 75th percentile as the good threshold. The budget prevents the obvious regressions; field data catches what the lab cannot see.
Step 1: Group pages into templates
A single budget for the whole site is either too loose for the home page or too tight for a page that legitimately carries a map and a booking widget. Budget by template. A health tech or hospital site normally has five to eight:
| Template | Typical contents | What tends to bloat it |
|---|---|---|
| Home | Hero, service cards, search | Hero image, carousel, video |
| Service or specialty page | Text, FAQ, doctors, form | Form libraries, embeds |
| Doctor profile | Photo, bio, booking | Booking widget, review widgets |
| Listing or directory | Many cards, filters | DOM size, client-side filter code |
| Blog article | Text, images | Inline images, share widgets |
| Booking or checkout | Form, payment | Payment SDKs, validation libraries |
| Location page | Map, hours, directions | Map SDK |
Pick one representative URL per template, or two for the templates that vary most (a doctor with a long bio and a doctor with a short one). Store these in a list the build can read. The budget is only as good as the pages it measures, so include a page with realistic content, not a nearly empty staging page.
Step 2: Set starting numbers by measuring, not guessing
The commonest error is to import a number from an article. Instead, measure the current build for each template, then set the budget slightly above the current figure for templates you consider healthy, and at your target for templates that are already too heavy, with a plan to reach it.
A practical routine:
- Run Lighthouse CI against each template URL with at least five runs, and record the median.
- Record the resource-summary figures: script bytes, image bytes, third-party request count, total.
- Compare with field data for the same template from CrUX or your own beacons. If the field LCP is poor, the lab is probably also poor, and you tighten the budget.
- Set the first budget at the current median plus a small margin for noise on healthy templates. Lower it in steps as work lands.
Illustrative starting limits for a modest site (hypothetical, for structure only, not benchmarks): a blog article might allow 150 KB of JavaScript and 400 KB of images; a listing page might allow 250 KB of JavaScript because of filtering code, but a lower image budget since cards use small thumbnails; a booking page might allow more JavaScript but zero third-party scripts beyond the payment and analytics providers. The relative ordering is what matters. Your own numbers come from your own measurements.
For LCP, Google publishes thresholds for field data, and those are the targets for real users. In CI, set the LCP assertion with headroom on the throttled lab profile, take the median of several runs, and rely on field data for the verdict.
Step 3: Express the budget in Lighthouse CI
Lighthouse CI's configuration is documented in the Lighthouse CI configuration reference. The pieces we use are:
collect.numberOfRuns: how many times each URL is tested. The CLI default is 3; use 5 for timing assertions to reduce noise.assert.assertMatrix: an array of assertion sets, each with amatchingUrlPattern, so each template gets its own limits.preset: a starting assertion set (lighthouse:all,lighthouse:recommendedorlighthouse:no-pwa) you can extend.- Resource-summary assertions in the form
resource-summary:<resourceType>:(size|count), for exampleresource-summary:script:sizeorresource-summary:image:size. maxNumericValuefor the limit andaggregationMethodfor how multiple runs combine. The documented options aremedian,optimistic,pessimisticandmedian-run, and the default isoptimistic.
One unit trap: the documentation notes that maxNumericValue for file size is in bytes, while a budget.json file uses kilobytes. Also, budgetsFile cannot be combined with other assert options, so if you want timing and size assertions together, use assertions rather than a budgets file.
A configuration shape, described rather than reproduced as a code block: the top-level object has a ci key with collect and assert sections. In collect, list the template URLs and set numberOfRuns. In assert, define an assertMatrix with one entry per template. Each entry has matchingUrlPattern (a regular expression such as the path prefix for doctor profiles) and an assertions object. For the doctor profile template that object contains resource-summary:script:size set to error with a maxNumericValue in bytes, resource-summary:image:size likewise, resource-summary:third-party:count as a warn, and largest-contentful-paint as an error with a maxNumericValue in milliseconds and aggregationMethod set to median. For the blog template, keep the same keys with tighter script and image values.
Two practical notes. First, set most quantity assertions to error and most timing assertions to warn at the beginning. A flaky timing failure that blocks every merge teaches the team to disable the check. Promote timing assertions to error after a few weeks of stable results. Second, use matchingUrlPattern carefully: if two patterns both match a URL, both sets apply, so make patterns specific.




