Skip to content
Issue docs

First Contentful Paint in the poor range

Standardpoor_fcpIssue 174

What is this issue?

First Contentful Paint (FCP) measures how long it takes for the browser to paint the first piece of content -- any text, image or non-blank canvas -- after somebody opens the page. It is the moment the visitor stops looking at a blank screen.

Google puts every page into one of three bands:

FCP Verdict
1.8 seconds or less Good
1.8 to 3 seconds Needs improvement
more than 3 seconds Poor

This check reports pages in the poor band, measured at the 75th percentile of real Chrome users where that data exists.

FCP is not one of the three Core Web Vitals. It is the diagnostic that comes before them: nothing can be the largest contentful paint until something has painted at all.

Why it matters

  • A blank screen is when visitors leave. Until the first paint the page looks broken, and on a phone over a mobile connection three seconds of white is long enough to go back to the search results.
  • It caps every other loading metric. Largest Contentful Paint can never be earlier than FCP, so a poor FCP usually means a poor LCP too -- and LCP is a Core Web Vital Google does rank on.
  • It points at the cause. A slow first paint is almost always a slow server, render-blocking stylesheets or scripts in the head, or web fonts hiding text. Those are cheaper to fix than the symptoms they produce later in the load.
  • The same scale as Google's tools. These are web.dev's published bands; PageSpeed Insights and Search Console show the same number.

Fixing it removes a standard-severity finding from the site's health score.

How to fix it

  1. Cut the server's response time. Nothing can paint before the HTML arrives. If "Server takes too long to send the first byte" is also failing, fix that first.

  2. Remove redirects in front of the page. Each hop is a full round trip before the HTML request can even start.

  3. Unblock rendering. Inline the CSS the first screen needs, load the rest without blocking, and add defer or async to scripts in the <head>.

  4. Let text show while fonts load. Add font-display: swap (or optional) to every @font-face, so text paints in a fallback font instead of staying invisible.

  5. Preconnect to the origins the first paint depends on -- a font host or CDN -- so the connection is already open when the request is made.

  6. Cache the HTML at the edge. A page served from a CDN cache paints far sooner than one rendered per request.

Examples

1. A chain of render-blocking stylesheets

The page linked four stylesheets in the head, one of which @imported a fifth from a font host. The browser could not paint until all five had downloaded.

Fails -- FCP 3.6 s

<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/theme.css">  <!-- @import url(https://fonts.example.net/...) -->
<link rel="stylesheet" href="/css/carousel.css">
<link rel="stylesheet" href="/css/footer.css">

Passes -- FCP 1.6 s

<style>/* the few rules the first screen needs */</style>
<link rel="preload" href="/css/site.css" as="style" onload="this.rel='stylesheet'">

2. Text hidden behind a web font

The heading was the first content on the page, but its font had no font-display, so the browser kept it invisible until the font file arrived.

Fails -- FCP 3.2 s

@font-face { font-family: Brand; src: url(/fonts/brand.woff2); }

Passes -- FCP 1.4 s

@font-face { font-family: Brand; src: url(/fonts/brand.woff2); font-display: swap; }

How PixyScan detects this

  1. Chooses which pages to measure. After the crawl, PixyScan measures the homepage plus the pages your own site links to most, up to a per-scan budget (ten pages by default, at most fifty). Measuring every page would take hours and exhaust the PageSpeed Insights quota of the API key it runs under.

  2. Asks the PageSpeed Insights API about each of them, on mobile. This needs the Hobby plan or above and a PageSpeed Insights API key on the site or its workspace.

  3. Prefers real-user data. Where the Chrome UX Report has enough traffic for that exact URL, FCP is the 75th percentile of what real Chrome users experienced over the last 28 days (FIRST_CONTENTFUL_PAINT_MS in the PageSpeed response). Where it does not, the FCP from the Lighthouse lab run (first-contentful-paint) is used instead, and the finding says which of the two it used.

  4. Ignores origin-level data for a page verdict. When the report has too little data for the URL, PageSpeed answers with the whole site's distribution instead. That is a statement about the site, not about this page, so it is not used here.

  5. Raises the issue only above 3 seconds -- Google's own "poor" bound. A page at exactly 3 seconds is "needs improvement" and raises nothing.

  6. Raises nothing on a page it did not measure. A page outside the sample, and a page whose measurement failed, are both reported as Not measured. Neither is treated as passing, and neither can fail this check.

What we store

Storage Level

Page Level


Database Table / Prisma Model

PagePerformance (page_performance), with the finding itself on audit_issues.details.


Fields Used

Field Type Description
strategy String mobile or desktop. Every number in the row belongs to one form factor.
fetchedAt DateTime When PageSpeed answered. "Measured three weeks ago" is not "not measured".
errorReason String? Why a sampled page still has no numbers. Null when the call succeeded.
labFcpMs Int? FCP from the PageSpeed lab run (first-contentful-paint), in milliseconds.
fieldFcpMs Int? FCP from the Chrome UX Report for this URL (FIRST_CONTENTFUL_PAINT_MS, p75). Null when the report has no data for it.

On the finding (audit_issues.details): message, plus metric (fcp), source (field or lab), valueMs and thresholdMs (3000).


Detection Dependencies

  • PageSpeed Insights API v5 (lighthouseResult), mobile strategy
  • Chrome UX Report field data (loadingExperience), where the URL has the traffic
  • The PageSpeed plan feature (Hobby and above) and a PageSpeed Insights API key
  • The crawl's internal link graph, which decides which pages are sampled

Note

A page with no row was never measured. The pass samples the homepage plus the most-linked pages within a per-scan budget (default 10, at most 50), so most pages of a site have no page_performance row at all. That is the normal state, it is drawn as Not measured, and this check cannot be raised against it.

A row whose metric columns are all null with an errorReason set is a different state again: the page WAS sampled and the measurement failed. It is still never reported as passing.

Further reading