First Contentful Paint in the poor range
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
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.
Remove redirects in front of the page. Each hop is a full round trip before the HTML request can even start.
Unblock rendering. Inline the CSS the first screen needs, load the rest without blocking, and add
deferorasyncto scripts in the<head>.Let text show while fonts load. Add
font-display: swap(oroptional) to every@font-face, so text paints in a fallback font instead of staying invisible.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.
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
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.
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.
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_MSin 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.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.
Raises the issue only above 3 seconds -- Google's own "poor" bound. A page at exactly 3 seconds is "needs improvement" and raises nothing.
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.