← All articles
8 min readPerformance,Web Vitals,Budgets,Web

How We Enforce a Performance Budget on Three Static Sites

By Mykhailo Boichuk · Chief Technology Officer

In short

BBMM Technologies writes its web performance budget as checks that stop a deploy. On flowoapp.com a test reads the size of every hero video and poster and fails above 1.3 MB and 40 KB per file, and a build validator fails when the entry stylesheet is a separate file or when a page loads the analytics chunk before consent. All three of our sites ship prerendered HTML, so the first paint needs the page and one image.

What the budget covers

Our performance budget is a short list of things the first screen is allowed to wait for. Each item on the list has a check that fails when someone breaks it.

The list is tied to the three Core Web Vitals. Largest Contentful Paint measures when the biggest element of the first screen appears. Cumulative Layout Shift measures how much the page moves while it loads. Interaction to Next Paint measures how long the page takes to answer a tap or a key press.

All three sites start from the same base. bbmmtech.com and textpack.app are Next.js static exports, and flowoapp.com is a Vite and React site whose public pages are prerendered at build time. A visitor receives finished HTML, so text and layout do not depend on a script.

A byte limit on hero media

The hero film on flowoapp.com has a byte budget stored as a constant, and a test compares every file on disk against it. The constant allows 1,300,000 bytes for one video file and 40,000 bytes for one poster image.

The film is a ten second silent loop. It is encoded in two cuts, 16:9 for wide screens and 9:16 for everything else, at two sizes each, 720p and 1080p, and in four codecs: AV1, HEVC, VP9 and H.264. That makes sixteen video files, and the test walks all of them. A re-encode that pushes one file over the limit fails the test run, and the deploy workflow runs the tests before the build.

The budget is per file because one visitor downloads one file. A function picks the smallest size that covers the pixels the film occupies on that screen to within 10 percent, and the smallest size on a 3G connection whatever the screen.

Some visitors get no film at all. The function that decides whether to fetch it returns false when the visitor asks for reduced motion, when Save-Data is on, or when the connection reports 2G. The poster stays, and it is the same frame the loop starts on.

What the first paint waits for

On flowoapp.com the first paint needs two things: the HTML document and the hero poster. We removed every other dependency from that path and wrote a check for each removal.

  • The entry stylesheet is inlined into every page by a small Vite plugin. The build validator fails if the home page still links a stylesheet file from the assets folder, if the inlined style block is missing, or if any script would fetch that stylesheet a second time.
  • The JavaScript bundle is fetched at low priority. A second plugin adds fetchpriority="low" to the module script and to its preload links, so the bundle does not compete with the poster for the first bytes on a phone.
  • The home page head holds the first paint until the hero markup has been parsed, using a link with rel="expect" and blocking="render". The validator checks that the link is present on every language version of the home page and that its target sits after the hero.
  • React waits for the prerendered poster to finish decoding before it replaces the static hero, with a ceiling of 600 milliseconds. Without that wait the largest paint could be counted after the bundle had run.

Scripts that load on demand

No page on our sites loads an analytics library before the visitor has agreed to analytics. The consent flow is described in our article on telemetry consent, and the same rule keeps a large script out of the first load.

On flowoapp.com the analytics library sits in its own chunk and is fetched with a dynamic import after consent. The build validator opens the built files and fails in two cases: the home page references the analytics chunk directly, or another script imports it statically. The second case is the subtle one. A bundler may place a shared helper inside the analytics chunk, and then every page that needs the helper downloads the library with it. We name those helpers in the router chunk, which every page loads anyway, and the check confirms the result.

The same validator guards the database client used by the feedback form. It must be its own chunk, no page may load it up front, and it is imported on the first submit of the form.

bbmmtech.com and textpack.app import the analytics package inside the function that runs after consent, so the static export puts it in a separate file.

Fonts and images

Two of the three sites make no font request at all. flowoapp.com and textpack.app use the system font stack, so text renders with a font the device already has and nothing swaps afterwards.

bbmmtech.com uses Inter. The framework downloads it at build time and serves it from our own domain, limited to the Latin subset and four weights, with display set to swap. The visitor's browser makes no request to a font service.

Images carry their dimensions. Every image in the bbmmtech.com page components has width and height attributes, so the browser reserves the box before the file arrives and the layout does not move. The hero screenshot is offered in three WebP widths with a JPEG fallback and is marked high priority. Images further down the page are lazy and decode asynchronously.

A static export has no image optimiser, so textpack.app resizes ahead of time. Product screenshots are stored as WebP files at five widths from 384 to 1920 pixels, and a custom image loader maps each requested width to the matching file.

Caching rules

A file whose name contains a hash of its content is cached for a year and marked immutable, and HTML is revalidated on every visit. A deploy is therefore visible at once, and a returning visitor downloads only the files that changed.

Media that keeps its name across re-encodes gets a different rule. The hero film is fresh for one day and may be served stale for a week while the browser revalidates it in the background.

We publish no Lighthouse or field scores here. A score describes one machine on one day, and the checks above are what keep the result from drifting.

Lab checks and their limits

The flowoapp.com repository carries a Lighthouse CI configuration that audits the built site and no live URL. It runs three times against the output folder and takes the median run, and it treats four category minimums as errors: performance, accessibility, best practices and SEO.

A lab run cannot stand in for real visitors, and Interaction to Next Paint depends on real input on real devices. Most of our budget is therefore written as structural checks. A file size or a static import is the same on every machine, so the check gives the same answer on a laptop and in CI.

Key takeaways

  • The hero film on flowoapp.com is limited to 1,300,000 bytes per video file and 40,000 bytes per poster, and a test reads the size of all sixteen video files.
  • The flowoapp.com build fails if the entry stylesheet is a separate file, because the stylesheet is inlined into every prerendered page.
  • A build validator fails when any page loads the analytics chunk up front or when another script imports it statically.
  • flowoapp.com and textpack.app use the system font stack, and bbmmtech.com serves Inter from its own domain in four weights.
  • Content-hashed files are cached for a year as immutable, and HTML is revalidated on every visit.

Frequently asked questions

What is a web performance budget?
A performance budget is a limit on something that slows a page, such as the bytes of an image or the number of files the first paint waits for. At BBMM Technologies each limit has a test or a build check behind it, so exceeding the limit stops the deploy.
How do you enforce a performance budget in CI?
We write the limit as a constant and a test that reads the built files. On flowoapp.com one test compares the size of every hero video and poster against a byte limit, and a validator script inspects the built HTML and scripts after the build. The deploy workflow runs lint, the tests and the build in that order, and a failure in any of them stops it.
How do you keep analytics from slowing the first load?
The analytics library is loaded with a dynamic import that runs only after the visitor consents. On flowoapp.com a build check confirms that no page references the analytics chunk and that no other script imports it statically. A visit without consent never downloads it.
Do system fonts help Core Web Vitals?
A system font needs no network request, so text paints with its final font and nothing shifts when a web font arrives. flowoapp.com and textpack.app use the system font stack for that reason. bbmmtech.com uses one web font, served from its own domain with display set to swap.

References

About the author

Mykhailo Boichuk

Chief Technology Officer

Mykhailo builds the systems behind the apps. He works on macOS and iOS performance, and on the sync problems that come with software that has to run offline.