25 Oct, 2026 · 15 min read · Web · Performance

Web Performance, Part 3: Get to first paint faster

In this post, we cover how to make the page faster without changing much of the code. We follow the first seconds of a slow checkout page and cut TTFB and FCP one wait at a time.

Before a customer can tap Pay, they have to see the page.

In the previous post, we found out why the Pay button hangs. We left that fix for later in the series. First, we go back to the very start of the page load.

Picture the same customer. They tap the checkout link in an email. They are on a train, on a mid-range phone, with a shaky 4G signal. The screen stays white for more than 3s. Then the page appears.

Where did those 3s go?

The white screen has two parts. First, the customer waits for the first byte of the page to arrive. Then they wait for the browser to paint something. Each part has its own stopwatch.

Two stopwatches

TTFB (Time to First Byte) runs from the tap until the first byte of the page arrives. FCP (First Contentful Paint) runs from the tap until the browser first paints any content, such as text, an image or a non-white canvas.

Here are the targets from the first post:

MetricGoodNeeds improvementPoor
TTFB≤ 0.8 s≤ 1.8 s> 1.8 s
FCP≤ 1.8 s≤ 3.0 s> 3.0 s

Sources: TTFB and FCP. The TTFB range is a rough guide, not a strict target.

Our page scores a TTFB of 1.9 s and an FCP of 3.2 s. Both are poor.

Notice how the two connect. FCP includes TTFB. If the first byte is late, the first paint is late too. No front-end trick can paint a page that has not arrived. So we start with the first byte.

Follow the request

Where do the 1.9s go? A page request is a relay race. Each runner waits for the one before. In order, the browser has to:

  1. Follow any redirects.
  2. Look up the server's address (DNS).
  3. Open a connection (TCP).
  4. Make the connection secure (TLS).
  5. Send the request and wait while the server does its work.

Then the first byte arrives. Here is our page:

Redirects and server work take most of the time. To see this on your own page, open the Network panel, click the page's first request, and open the Timing tab. You can also read the numbers from code. Paste this into the DevTools console after the page loads:

const [nav] = performance.getEntriesByType('navigation');
 
console.table({
  'redirects (ms)': nav.redirectEnd - nav.redirectStart,
  'DNS (ms)': nav.domainLookupEnd - nav.domainLookupStart,
  'TCP (ms)': (nav.secureConnectionStart || nav.connectEnd) - nav.connectStart,
  'TLS (ms)': nav.secureConnectionStart ? nav.connectEnd - nav.secureConnectionStart : 0,
  'request to first byte (ms)': nav.responseStart - nav.requestStart,
  'TTFB total (ms)': nav.responseStart,
});
 
console.log('redirect count:', nav.redirectCount);

A 0 can mean a skipped step, such as a cached DNS entry. It can also mean the browser hides cross-origin details. The MDN page explains each timestamp.

Now we know where the time goes. Let's shorten each wait, one runner at a time.

Wait one: the redirects

The email link points to http://shop.example/checkout. The browser then follows a chain. It goes from http to https, then from the bare domain to www, then adds a trailing slash. Link shorteners and tracking links can add one more redirect on another domain. Every hop costs a round trip. Chained redirects cost more.

The fix is simple. Link straight to the final URL:

<!-- Before: three redirects (http to https, bare domain to www, add a slash) -->
<a href="http://shop.example/checkout">Pay now</a>
 
<!-- After: the final URL, no redirects -->
<a href="https://www.shop.example/checkout/">Pay now</a>

Then add HSTS. It tells the browser to use HTTPS for your site from now on, so it skips the http-to-https hop on later visits:

Strict-Transport-Security: max-age=31536000; includeSubDomains

To check, run the snippet again. redirect count should be 0. In our story, this saves 350 ms.

Lab tools often start at the final URL, so they miss redirect time. This is another way lab data lies a little. Field data shows the full wait.

Wait two: the server's own work

Now the biggest bar: 1,100 ms of request and server work. The server runs database queries, builds the HTML and sends it. If every customer triggers the same work, that is a lot of waste.

Start by finding out where the time goes. The server can report it in a Server-Timing header:

Server-Timing: db;dur=620, render;dur=380

The browser then shows these numbers in the Timing tab. You can read them from code too:

const [nav] = performance.getEntriesByType('navigation');
console.table(nav.serverTiming.map(({ name, duration }) => ({ name, duration })));

Then pick the fixes that fit:

  • Right-size the hosting. Make sure the host has enough memory for your traffic, and keep the backend stack up to date.
  • Pre-build what you can. If a page doesn't need personal data, generate the HTML at build time.
  • Stream the HTML. The browser can start parsing before the server finishes.
  • Send 103 Early Hints. The server tells the browser to start fetching CSS and fonts while it still builds the page. This can hide slow server work, so keep measuring server time.
  • Cache the page. This is our biggest win, so let's look at it closely.

Our checkout page is personal, but only in part. The page shell is the same for everyone. Only the cart is personal. So we cache the shell and load the cart with a separate API call. Make sure the shell holds no personal data. Caches usually skip responses that set cookies, so check for Set-Cookie too.

# Before: every visit goes all the way to the origin server
Cache-Control: no-store
 
# After: a CDN can keep the shell for 60s
Cache-Control: public, max-age=0, s-maxage=60

s-maxage is for shared caches such as a CDN. Browsers still check with the server each time. Even a short cache time helps when many customers ask for the same page.

Wait three: the distance matters

Before the server can answer, the phone has to reach it. DNS, TCP and TLS take up to three round trips. On a train, each round trip is slow, and a far-away server makes it worse.

A CDN puts servers close to your customers. The phone connects to a nearby edge server. The edge keeps warm connections to your origin, and it can answer from its own cache.

Two more details help here. TLS 1.3 shortens the handshake from two round trips to one. Check your CDN's docs for the response header that tells you whether a request was a hit or a miss.

In our story, the redirect fix, the cached shell and the closer connection bring TTFB from 1.9 s down to 0.7 s. That is a good result for the first stopwatch.

The first byte now arrives sooner. Next, let's make the trip lighter.

Make the trip lighter

Compress text

Fewer bytes arrive sooner. Compression shrinks text files before they travel. It works well on HTML, CSS, JS and SVG. It does almost nothing for images, because image formats already compress themselves.

For images, the format you pick matters instead. WebP files are usually 25–35% smaller than JPEG and PNG, and AVIF also compresses better than older formats.

The two common choices are gzip and Brotli. In web.dev's tests on popular libraries, Brotli beat gzip every time. For larger text files, both often reach 70–90%.

The browser says what it supports in the Accept-Encoding header. Your host or CDN has to answer with a compressed file. Many do this by default. Check yours:

curl -sI -H "Accept-Encoding: br, gzip" https://example.com/main.js | grep -i content-encoding

You want to see content-encoding: br or content-encoding: gzip. In DevTools, the bottom of the Network panel also shows the transferred size next to the resource size.

Use HTTP/2 and HTTP/3

A checkout page asks for many files. How the browser asks matters.

With HTTP/1.1, browsers open up to six connections per site. Extra requests wait in a queue. HTTP/2 fixes this. It sends many requests over one connection, and it also compresses repeated headers.

But HTTP/2 runs on TCP. If one packet is lost, TCP can stall every request on the connection. HTTP/3 runs on QUIC, which uses UDP. It recovers from loss for each stream on its own, so only the stream that lost a packet waits.

To see which protocol your page uses, open the Network panel, right-click a column header and add Protocol. You will see h2 for HTTP/2 and h3 for HTTP/3. To switch, turn it on at your host or CDN. It is usually a setting, not a code change.

The first byte arrives sooner and the bytes travel lighter. But the screen is still white. Why?

Why is the screen still white?

The browser has the HTML. It still can't paint. Two things hold it back.

  • CSS blocks painting. The browser must download and parse CSS before it shows the page.
  • Scripts block parsing. A <script> in the <head> without defer or async stops the browser from reading the rest of the HTML.

There is a third problem. Some files are found late. The browser finds the font only after it has read the CSS. That creates a chain: HTML, then CSS, then font.

Our plan has three moves:

  1. Don't wait for what we don't need yet. We put the small CSS that the top of the page needs inside the HTML. The rest of the CSS loads in the background.
  2. Don't let scripts stop the parser. We load them with defer or async.
  3. Start the slow things early. We preload the font and connect early to other domains. We also show text right away in a fallback font.

The picture below shows what these moves do. The top half is our page today. The bottom half is the same page after the three moves.

Lighthouse lists the blockers. Look for the Render-blocking requests insight in the report. In DevTools, the Coverage tab shows which code the first paint needs. Let's fix each blocker.

Inline the critical CSS

Critical CSS is the small set of rules that the top of the page needs. Put it inside the HTML, so the browser needs no extra request to paint. Load the rest without blocking:

<!-- Before: nothing paints until the whole app.css arrives -->
<link rel="stylesheet" href="/app.css" />
 
<!-- After: critical rules ship inside the HTML, the rest loads without blocking -->
<style>
  body {
    margin: 0;
    font-family: system-ui, sans-serif;
  }
  .summary {
    display: grid;
    gap: 16px;
    padding: 16px;
  }
  .pay-button {
    width: 100%;
    padding: 16px;
  }
</style>
<link rel="preload" href="/app.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/app.css" /></noscript>

Keep above-the-fold CSS under 14 KB compressed. Don't inline too much. It delays the rest of the HTML and the browser can't cache inlined CSS. Tools such as Critical and Penthouse can extract the rules for you. You can also combine small CSS files, so the browser has fewer blocking requests.

Load scripts without blocking

Each way to load a script behaves differently:

AttributeDownloadsRunsUse it for
noneBlocks the parserAt onceRarely the right choice
asyncIn parallelAs soon as it arrives, even in the middle of parsingIndependent scripts, such as analytics
deferIn parallelAfter parsing, in orderApp code that needs the DOM
type="module"In parallelDeferred by defaultModern ES modules

For our page, the fix is one word per script:

<!-- Before: the parser stops at each script -->
<script src="/app.js"></script>
<script src="/analytics.js"></script>
 
<!-- After -->
<script defer src="/app.js"></script>
<script async src="/analytics.js"></script>

defer has no effect on inline scripts. Also, if you set both defer and async, async wins.

Start connections early

Our payment SDK lives on another domain. The browser opens a connection to it only when it finds the script. preconnect starts DNS, TCP and TLS early, so the connection is ready when the browser needs it. web.dev reports that early connections to important third-party origins can save 100–500 ms.

<link rel="preconnect" href="https://js.payments.example" />
<link rel="dns-prefetch" href="https://js.payments.example" />

Use two separate tags. The dns-prefetch tag is a fallback for browsers with weaker preconnect support.

Don't overdo it. Preconnect only to the few origins you need early. The browser closes a connection that nobody uses after 10s.

Load the font earlier

The font is the last link in our chain. The browser finds it only after the CSS. A preload tag lets it start the download right away, in parallel with the CSS:

<link rel="preload" href="/fonts/body.woff2" as="font" type="font/woff2" crossorigin />

Fonts need crossorigin even when they live on your own domain. Without it, the browser downloads the font twice. Preload only your most important files. If you preload too many, the browser prioritizes none of them.

Show text while the font loads

Even with a preload, the font can arrive late. What should the customer see in the meantime? The font-display rule decides:

/* Before: text stays invisible until the font arrives */
@font-face {
  font-family: 'Body';
  src: url('/fonts/body.woff2') format('woff2');
  font-display: block;
}
 
/* After: show fallback text now, swap in the font when it arrives */
@font-face {
  font-family: 'Body';
  src: url('/fonts/body.woff2') format('woff2');
  font-display: swap;
}

Here are the five values, from MDN:

ValueBlock period (invisible text)Swap period (fallback text, then the font)
blockShortInfinite
swapExtremely smallInfinite
fallbackExtremely smallShort
optionalExtremely smallNone
autoUp to the browserUp to the browser

For most pages, swap is a good start. One warning: when the font swaps in, the text can change size and move the page. We deal with that shift later in the series.

Did it work?

Let's add up our fixes. These numbers are made up, but they follow the story:

FixMetricTime saved
Remove the redirectsTTFB350 ms
Cache the shell and use a closer connectionTTFB850 ms
Brotli and HTTP/2FCP200 ms
Inline critical CSS and defer the scriptsFCP400 ms
Preconnect, preload the font, font-displayFCP200 ms

The same customer, on the same train, now sees the page at 1.2 s. Both stopwatches are back in the good range.

Don't trust a single lab run, as we learned in the previous post. Confirm the win with real visitors. web-vitals measures both metrics in the field:

import { onFCP, onTTFB } from 'web-vitals';
 
onTTFB(({ value, rating }) => console.log('TTFB', Math.round(value), 'ms', rating));
onFCP(({ value, rating }) => console.log('FCP', Math.round(value), 'ms', rating));

Send these to your RUM endpoint, then watch the p75 and p95. Keep the FCP budget from the previous post in your Lighthouse run. Then set a TTFB alert in your RUM next to it.

The story so far

We started with a white screen: 1.9 s to the first byte and 3.2 s to the first paint. We followed the request and found five waits. We cut the redirects. We cached the page near the customer. We shrank the bytes. Then we stopped CSS and scripts from blocking the first paint.

The customer now sees the page at 1.2 s. But the Pay button still hangs. That is the next job.

Key takeaways

  • FCP includes TTFB. A late first byte means a late first paint, so fix TTFB first.
  • A page request is a chain of waits: redirects, DNS, TCP, TLS and server work. Measure each one with Navigation Timing or the Timing tab.
  • Remove redirects, cache pages at a CDN edge, compress text with Brotli or gzip, and turn on HTTP/2 or HTTP/3.
  • CSS blocks painting and scripts block parsing. Inline critical CSS, and use defer or async for scripts.
  • Start important connections early with preconnect, preload the font, and pick a font-display value so text shows right away.

Next in the series

In the next post, we fix what the customer sees and feels next: LCP, CLS and INP. That includes the Pay tap that hangs. We also close the series with a one-page checklist. I will link it here when it is published.

Further reading