Oxwyn Studio
All field reports
23 July 2026Performance

The 287ms budget: how we ship sub-300ms LCP routinely

Google calls 2.5 seconds good. We budget for 300 milliseconds, and the discipline of that number changes every build decision that follows.

Google's Core Web Vitals documentation says a Largest Contentful Paint (LCP, the moment the biggest visible element finishes rendering) under 2.5 seconds counts as "Good". We target 300 milliseconds. On a decent connection, roughly 13 milliseconds of that disappears into network physics before a single byte of your page matters, which leaves a working budget of about 287 milliseconds. This article is about how we spend it.

One honest note before the numbers: 300ms is our design target, verified against each build under lab conditions, not a promise about every visitor's phone on every train in the country. Real-world timings vary with device, network and distance. The point of a hard target is not that everyone hits it. The point is that designing for 300ms produces a site that is still fast when reality taxes it.

Where the 13ms goes

Even a perfectly built page cannot beat the speed of light and the realities of TCP. On a good fixed connection to a nearby edge node, you still pay for DNS resolution (often cached, sometimes a few milliseconds), the TCP and TLS handshakes (largely absorbed by connection reuse and TLS 1.3, but never free on a cold start), and the first round trip to fetch HTML. Call it 13 milliseconds as a floor on a warm, well-routed connection. It can be less; it is frequently more. It is never zero.

That floor is why edge delivery matters so much. If your HTML originates from a single server in Virginia and your visitor is in Manchester, the round trip alone can eat your entire budget several times over. Every subsequent decision assumes the first one: the document must come from somewhere close.

Static first, rendered once, served from the edge

The single biggest lever is not clever. It is refusing to compute pages per request. Oxwynstudio.com is built on Next.js 16 with the App Router, and almost every route is statically rendered at build time: the HTML exists as a finished file before any visitor arrives. Vercel then serves that file from an edge cache near the visitor.

The consequence is that the server "response time" for the document is effectively cache lookup plus transfer. No database query, no template rendering, no cold-started function sitting between the request and the paint. Dynamic data (our contact form submission, the admin panel behind it) lives in API routes and server actions that run after the page is already visible. The page never waits for Supabase; Supabase waits for the page.

If your LCP element depends on a per-request database call, no amount of image optimisation will save you. Move the personalisation to the client or to a later interaction, and ship the shell static.

A segmented bar of light: the 300ms budget divided between network floor, HTML, fonts and the LCP image, with gold headroom left over

The font pipeline: subset, self-host, swap

Web fonts are the classic silent LCP killer, because text is very often the largest contentful element. The failure mode is a chain: HTML loads, CSS loads, CSS reveals a font URL on a third-party domain, a new connection opens, the font downloads, and only then does the headline render.

We break every link in that chain:

  • Self-hosting via next/font. The framework downloads Google Fonts at build time and serves them from our own domain. No third-party connection, no extra DNS or TLS handshake, and the font files are fingerprinted and cached forever.
  • Subsetting. We ship the Latin subset only. A full font family with every script can run to hundreds of kilobytes; a Latin subset of a text face is typically a fraction of that.
  • display: swap. Text renders immediately in a fallback font and swaps when the web font arrives. The LCP clock stops at first render of the text, not at the swap.
  • Automatic preloading. next/font emits preload hints for the exact font files the page uses, so the browser fetches them alongside the CSS rather than after it.

In code, the entire pipeline is a few declarations in the root layout:

// app/layout.tsx
import { Inter, Sora, JetBrains_Mono } from "next/font/google"

const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" })
const sora = Sora({ subsets: ["latin"], variable: "--font-sora", display: "swap" })
const jetbrains = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap" })

Three families, one of them a monospace used for our terminal-styled UI flourishes, and none of them able to block a paint.

Image discipline: AVIF, exact sizes, priority once

When the LCP element is an image, three rules apply.

Serve modern formats, or pre-compress hard. AVIF typically compresses meaningfully smaller than JPEG at comparable visual quality, with WebP as a fallback, and Next.js image optimisation can negotiate the format per browser. On our own build we take a different route to the same end: every asset is resized and compressed before deploy and served as a static file, with runtime optimisation switched off entirely.

Tell the browser the truth about size. The sizes attribute exists so the browser can pick the right source before layout. Omit it and the browser guesses, usually pessimistically, and downloads a larger file than the slot needs. Every image on our site declares its real rendered widths.

Use priority exactly once per page. The priority prop on next/image emits a preload hint and disables lazy loading. It belongs on the LCP element and nowhere else. Sprinkle it on six images and you have simply recreated the congestion you were trying to avoid, because preloads compete with each other for the same early bandwidth.

<Image
  src="/hero-workspace.avif"
  alt="Studio workspace"
  width={1280}
  height={720}
  sizes="(max-width: 768px) 100vw, 60vw"
  priority
/>

Everything below the fold lazy-loads by default and never touches the budget at all.

What hydration actually costs

Here is the uncomfortable part for anyone shipping a large single-page application: JavaScript does not just cost download time. After the bundle arrives it must be parsed, compiled and executed, and in a hydrating framework the framework then re-walks the server-rendered DOM to attach event handlers. On a mid-range phone, hundreds of kilobytes of JavaScript can occupy the main thread for longer than our entire budget, and while the main thread is busy, paints queue behind it.

React 19 server components change the economics. Components that never need interactivity (which on a marketing site is most of them) render on the server and ship zero JavaScript to the client. Only the genuinely interactive islands (our contact form with its staged log, the consent banner, the pricing currency switcher) hydrate. The LCP element itself is server-rendered HTML and an optimised image; it needs no JavaScript to appear, so hydration happens after the paint rather than before it.

The rule of thumb we apply: if a component's JavaScript could delay the first paint, that component must justify existing as JavaScript at all.

An operations console with live metrics: budgets only work if you measure against them on every build

Why aim 8x under the documented threshold

Google's 2.5 second "Good" boundary, measured at the 75th percentile of real Chrome users via the CrUX dataset, is a public, documented fact, and it is the honest baseline for the industry. So why set a target more than eight times stricter?

Because targets shape decisions long before they shape metrics. A team aiming at 2.5 seconds will approve the third-party script, the 400KB hero video, the client-side data fetch, because each fits inside a lenient budget. A team aiming at 300ms cannot approve any of them, and the arguments end quickly. The strict number functions as a design constraint, the same way a strict weight limit shapes an aircraft: it forces every part to earn its place.

There is also a practical margin argument. If the lab build hits 300ms, a visitor on a congested 4G connection with a three-year-old handset might see something several times slower, and still land comfortably inside "Good" with room to spare. Aim at the threshold itself and the same real-world tax pushes your slowest quartile over the line.

The caveats, stated plainly

We verify the target per build, in lab conditions, on each release of oxwynstudio.com. We do not claim every visitor experiences 300ms, because nobody can claim that honestly: field data always spreads wider than lab data. Slow devices pay a CPU tax no CDN can refund. Cold connections pay handshake costs that warm ones skip. What we can say is that the architecture (static HTML from the edge, self-hosted subset fonts with swap, one prioritised hero image, hydration deferred behind the paint) removes every delay that is removable, and that the discipline compounds: a page designed to a 287ms working budget degrades gracefully, because there was never any fat to slow it down.

This is the standard we build to for Website as a Service clients as well as our own site, and the methodology behind our performance work generally.

Key takeaways

  • A 300ms LCP target minus roughly 13ms of unavoidable network floor leaves a 287ms working budget, and every build decision should be priced against it.
  • Static rendering plus edge caching is the biggest single lever: pages that exist before the request beats pages computed during it.
  • Fonts must be self-hosted, subset to the scripts you use, preloaded and set to display: swap so text never waits.
  • Give the priority prop to the LCP image only, declare honest sizes, and do the compression work before the browser ever asks.
  • Google's 2.5s CrUX threshold is the documented baseline; targeting far under it is a design discipline that buys real-world margin, not a guarantee for every device.

Frequently asked

Is sub-300ms LCP realistic for every website?

For a static-first marketing or content site served from an edge network, yes, as a lab target. For pages that must render per-request data before the largest element can paint, it is much harder, and the right first move is restructuring so the LCP element does not depend on that data. Heavy client-side applications will struggle regardless of hosting.

Does LCP alone make a site feel fast?

No. LCP measures when the main content paints, but interactivity (INP), layout stability (CLS) and plain navigation speed all shape perceived performance. LCP is simply the metric most correlated with the first impression, which is why we budget it hardest.

Why not just aim for Google's 2.5 second threshold?

Because thresholds measured at the 75th percentile leave your slowest visitors far behind, and because a lenient budget approves lenient decisions. Building to a strict lab target means the inevitable real-world variance still lands well inside "Good" for most of your audience.

Do web fonts always hurt performance?

Not if the pipeline is right. Self-hosted, subset fonts with preload hints and display: swap add negligible delay to first paint, because the browser renders fallback text immediately. The damage comes from third-party font hosts, unsubset files and blocking display behaviour, all of which are avoidable.

Put this to work

Want a system built like this behind your business?

Brief the studio