Skip to content
GitHub

Core Web Vitals

Google’s Core Web Vitals (CWV) contribute to the PageSpeed Insights (PSI) performance score, which is derived from Lighthouse. The performance score is calculated using a weighted average of different Lighthouse performance metrics, which are collected through lab (simulated) and field (real-world) data.

Core Web Vitals & Their Role in Performance

Google uses Core Web Vitals to measure user experience quality across different aspects of a webpage:

  • Loading Performance → First Contentful Paint (FCP) & Largest Contentful Paint (LCP)
  • Interactivity & Responsiveness → Total Blocking Time (TBT) & Time to Interactive (TTI)
  • Visual Stability → Cumulative Layout Shift (CLS)
  • Perceived Smoothness → Speed Index (SI)

Relationship Between Metrics

The following diagram illustrates how Core Web Vitals metrics contribute to overall performance:

Core Web Vitals

Loading Performance

Interactivity

Visual Stability

First Contentful Paint (FCP)

Largest Contentful Paint (LCP)

Total Blocking Time (TBT)

Time to Interactive (TTI)

Cumulative Layout Shift (CLS)

Speed Index (SI)

CWV, Lighthouse, & CrUX Relationship Diagram

Core Web Vitals (CWV)

Field Data (CrUX - Real Users)

Lab Data (Lighthouse - Simulated)

Chrome User Experience Report (CrUX)

Google Search Console (GSC)

PageSpeed Insights (PSI)

Lighthouse (Emulated Test)

DevTools, CLI, PSI

Scores fluctuate due to CPU & RAM load

Final PageSpeed Score

SEO Rankings & Google Algorithm

Chrome & Lighthouse Testing Environments: Why Scores Fluctuate

Users often notice inconsistent CWV scores when running tests, and this is due to the differences between Lighthouse’s lab environment and real-world user experience. Here’s why:

Lab vs. Field Data: Different Collection Methods

Testing MethodEnvironmentData SourceProsCons
Lighthouse (Lab Data)SimulatedRuns in a throttled environment with predefined CPU & network conditions.✅ Reproducible❌ May not match real-world experience
Field Data (CrUX Reports, Real User Metrics)Real UsersCollected from actual Chrome users on different devices & network speeds.✅ More accurate❌ Cannot test unpublished pages

Why Lighthouse Scores Vary (Emulated CPU & RAM Competition)

Lighthouse runs inside Chrome DevTools, where it competes for CPU/RAM priority with other processes.

Key Problems:

  • Emulated throttling: Lighthouse artificially slows down the test to simulate slower devices.
  • Browser resource contention: Other open browser tabs or extensions impact Lighthouse runs.
  • System variability: If your PC is under high CPU load, Lighthouse scores will be lower than normal.

How to Get More Reliable Lighthouse Results:

Close all other Chrome tabs before running Lighthouse.
Run Lighthouse from a fresh browser session (Incognito Mode).
Use the CLI instead of DevTools:

Terminal window
`lighthouse https://example.com --preset=desktop --throttling-method=simulate`

Test multiple times and average results.


Chrome User Experience (CrUX) Reports: More Reliable but Slower

Unlike Lighthouse, CrUX collects CWV data from real users, meaning it:

  • Accounts for real CPU/network conditions
  • Reflects real device performance (mobile vs. desktop)
  • Aggregates data over 28 days, smoothing out jitter from single test runs

Where to Get CrUX Data:

  • Google Search Console → Page Experience Report
  • PageSpeed Insights → Uses real-user data when available
  • BigQuery Public Datasets → Query CrUX directly for deeper insights

Comparing Lighthouse vs. Real-World CWV Scores

ScenarioLighthouse ScoreCrUX ScoreReason
Running Lighthouse on a high-end PCVery HighLowerLighthouse doesn’t throttle your high-end machine enough.
Running Lighthouse with CPU loadLower than expectedStableCPU competition affects Lighthouse but not CrUX.
Testing a new site with no trafficWorksNo DataCrUX only tracks real visitors.
User tests on a slow mobile deviceHigher than expectedLowerLighthouse emulation may not be aggressive enough for real-world slow devices.

Key Takeaways for Better CWV Optimization

  • Don’t rely only on Lighthouse!
    → Always cross-check with CrUX data for a real-world perspective.
  • Lighthouse scores fluctuate due to system constraints
    → Run multiple tests and use the median.
  • Emulated environments ≠ real devices
    → Test CWV using real user data whenever possible.
  • PageSpeed ≠ Real-User Performance
    → A perfect Lighthouse score (100) does not guarantee a perfect user experience.

PageSpeed Score Calculation Logic

Lighthouse assigns weights to specific metrics and normalizes them into a score from 0 to 100, using a log-normal distribution model.

Metrics & Weights

MetricWeight (%)Description
First Contentful Paint (FCP)10%Time taken for the first visible content to appear.
Largest Contentful Paint (LCP)25%Time until the largest element (image, text, or video) is fully visible.
Total Blocking Time (TBT)30%Measures how long JavaScript blocks main thread execution.
Cumulative Layout Shift (CLS)15%Measures how much the page layout shifts unexpectedly.
Speed Index (SI)10%Measures how quickly content becomes visually complete.
Time to Interactive (TTI)10%Measures when the page becomes fully interactive.

Each metric is mapped onto a logarithmic scale based on percentile distributions (i.e., how your site compares to others) and then converted into a score between 0 and 100.


Formula for Individual Metric Scoring

Lighthouse normalizes raw metric values into scores using a log-normal curve. The formula is:

score=1log10(metric value)log10(median)log10(good)log10(median)\text{score} = 1 - \frac{\log_{10}(\text{metric value}) - \log_{10}(\text{median})}{\log_{10}(\text{good}) - \log_{10}(\text{median})}

Where:

  • metric value = Your raw CWV result (e.g., LCP time in seconds)
  • good = A reference “ideal” value (Google defines a good score threshold)
  • median = The median observed across web data (used to create a normalized scale)

After normalizing each metric score, Google applies weighted averaging to calculate the final PageSpeed Score.


Understanding Each Metric & How to Optimize

First Contentful Paint (FCP)

  • What It Measures: How fast any content appears on the screen.
  • Ideal Value: < 1.8s
  • Key Optimization Strategies:
    • Reduce server response time (TTFB).
    • Minimize render-blocking CSS & JavaScript.
    • Implement lazy loading for non-critical resources.

Formula for FCP scoring:

FCP Score=1log10(FCP)log10(1.8)log10(1.0)log10(1.8)\text{FCP Score} = 1 - \frac{\log_{10}(\text{FCP}) - \log_{10}(1.8)}{\log_{10}(1.0) - \log_{10}(1.8)}

SQL Example

SELECT
page_id,
url,
1 - (LOG10(fcp) - LOG10(1.8)) / (LOG10(1.0) - LOG10(1.8)) AS fcp_score
FROM core_web_vitals;

Largest Contentful Paint (LCP)

  • What It Measures: When the largest visible element (text, image, or video) is fully rendered.
  • Ideal Value: < 2.5s
  • Key Optimization Strategies:
    • Optimize image formats (WebP, AVIF).
    • Reduce render-blocking resources.
    • Use lazy loading for below-the-fold content.
    • Implement server-side rendering (SSR) or static generation (SSG).

Formula for LCP scoring:

LCP Score=1log10(LCP)log10(2.5)log10(1.2)log10(2.5)\text{LCP Score} = 1 - \frac{\log_{10}(\text{LCP}) - \log_{10}(2.5)}{\log_{10}(1.2) - \log_{10}(2.5)}

SQL Example

SELECT
page_id,
url,
1 - (LOG10(lcp) - LOG10(2.5)) / (LOG10(1.2) - LOG10(2.5)) AS lcp_score
FROM core_web_vitals;

Total Blocking Time (TBT)

  • What It Measures: How long JavaScript execution blocks the main thread.
  • Ideal Value: < 300ms
  • Key Optimization Strategies:
    • Reduce JavaScript execution time.
    • Use code splitting to load JS only when needed.
    • Defer non-critical scripts (e.g., third-party analytics).

Formula for TBT scoring:

TBT Score=1log10(TBT)log10(300)log10(100)log10(300)\text{TBT Score} = 1 - \frac{\log_{10}(\text{TBT}) - \log_{10}(300)}{\log_{10}(100) - \log_{10}(300)}

SQL Example

SELECT
page_id,
url,
1 - (LOG10(tbt) - LOG10(300)) / (LOG10(100) - LOG10(300)) AS tbt_score
FROM core_web_vitals;

Cumulative Layout Shift (CLS)

  • What It Measures: How much the page layout shifts unexpectedly.
  • Ideal Value: < 0.1
  • Key Optimization Strategies:
    • Set explicit width & height on images/videos.
    • Avoid dynamically injected content without space reservation.
    • Use font-display: swap for web fonts.

Formula for CLS scoring:

CLS Score=1log10(CLS)log10(0.25)log10(0.1)log10(0.25)\text{CLS Score} = 1 - \frac{\log_{10}(\text{CLS}) - \log_{10}(0.25)}{\log_{10}(0.1) - \log_{10}(0.25)}

SQL Example

SELECT
page_id,
url,
1 - (LOG10(cls) - LOG10(0.25)) / (LOG10(0.1) - LOG10(0.25)) AS cls_score
FROM core_web_vitals;

Final Score Calculation Example

MetricRaw ValueNormalized Score (0—1)WeightWeighted Score Contribution
FCP1.8s0.8010%8.0
LCP2.5s0.9025%22.5
TBT300ms0.7030%21.0
CLS0.10.8515%12.75
SI4.3s0.7510%7.5
TTI5.0s0.6510%6.5
Final Score---------78.25 → 78

Final Performance Score Calculation

After computing individual normalized scores, we apply weighted averaging to compute the final performance score.

WITH metric_scores AS (
SELECT
page_id,
url,
1 - (LOG10(fcp) - LOG10(1.8)) / (LOG10(1.0) - LOG10(1.8)) AS fcp_score,
1 - (LOG10(lcp) - LOG10(2.5)) / (LOG10(1.2) - LOG10(2.5)) AS lcp_score,
1 - (LOG10(tbt) - LOG10(300)) / (LOG10(100) - LOG10(300)) AS tbt_score,
1 - (LOG10(cls) - LOG10(0.25)) / (LOG10(0.1) - LOG10(0.25)) AS cls_score,
1 - (LOG10(si) - LOG10(4.3)) / (LOG10(2.5) - LOG10(4.3)) AS si_score,
1 - (LOG10(tti) - LOG10(5.0)) / (LOG10(2.8) - LOG10(5.0)) AS tti_score
FROM core_web_vitals
),
weighted_scores AS (
SELECT
page_id,
url,
-- Applying weights to each normalized metric score
(fcp_score * 0.10) +
(lcp_score * 0.25) +
(tbt_score * 0.30) +
(cls_score * 0.15) +
(si_score * 0.10) +
(tti_score * 0.10) AS page_speed_score
FROM metric_scores
)
SELECT page_id, url, ROUND(page_speed_score * 100, 2) AS final_score
FROM weighted_scores
ORDER BY final_score DESC;

Explanation of Final Calculation

  • Each metric score is normalized using a log-normal distribution.
  • Final Score = Weighted Sum of Individual Scores
    • FCP → 10%
    • LCP → 25%
    • TBT → 30%
    • CLS → 15%
    • SI → 10%
    • TTI → 10%

The resulting score is scaled to 100 for the final PageSpeed Insights Score.


Final Thoughts: Why Does This Matter?

Improving Core Web Vitals can lead to:

  • Better SEO rankings (Google considers CWV as a ranking factor).
  • Lower bounce rates (users leave slow websites).
  • Higher conversions (faster experiences improve engagement).

By using this in-depth understanding of CWV scoring, you can proactively optimize your website and stay ahead of performance issues.

Works Cited

no shaping

################################################################################
# SHAPING & GAUSSIAN NOTES
################################################################################
# Lighthouse scoring officially uses a log-normal transformation for each metric,
# deriving a "shape" parameter from the (median, p10) references. However, many
# folks notice that the real Lighthouse calculator (or full Lighthouse runs)
# display sub-scores that differ slightly from a naive log-normal approach:
#
# - Lighthouse occasionally applies internal CPU multipliers or environment
# offsets, especially on mobile form factors.
# - The Gaussian approximation (erf, erfinv) may differ in precision between
# environments or versions, causing minor score discrepancies.
#
# To reconcile these differences, we implement “shaping” multipliers that adjust
# the log-normal shape factor so sub-scores align more closely with the real LH
# calculator for typical (1500–3000 ms) metric values:
#
# - For FCP, SI, LCP, TBT up to ~6000 ms, this shaping typically yields sub-scores
# within ~1–2 points of Lighthouse's official calculator.
# - Above ~6000 ms, the official log-normal tail grows differently, so we may see
# a 2–5% deviation.
#
# Why “shaping”? When a stakeholder measures real CWV metrics on Google’s
# Lighthouse calculator, we want to mirror those sub-scores in our own environment
# without confusing them about a TBT difference of “79 vs. 76.” The shaping hack
# ensures we don’t exceed a few points of difference.
#
# Note also that:
# - Mobile vs Desktop references differ in their (median, p10) thresholds
# (for example, TBT=600 vs TBT=300), so the shape factor can diverge more.
# - CPU multipliers or extra overhead in real Lighthouse runs can skew final
# results. Our shaping approach focuses primarily on the log-normal curves
# up to ~6000 ms.
# - The Gaussian function (erf) itself can vary slightly in precision or
# implementation across Python, JavaScript, or other runtimes. We rely on
# a consistent set of constants from the official LH snippet.
#
# Thus, the "official unshaped scoring" stands as the baseline log-normal approach,
# but we apply these shaping multipliers to approximate hidden clamp logic and
# environment offsets that Lighthouse internally uses. This way, stakeholders can
# see sub-scores that roughly match Lighthouse's UI for typical inputs, especially
# for (FCP, SI, LCP, TBT) values in the 1500–3000 ms range. For extremely large
# values (e.g. 10k ms), the shaping logic diverges more, but we accept a modest
# deviation (2–5%) for those edge cases.
################################################################################
#!/usr/bin/env python3
import math
import urllib.parse
###############################################################################
# Lighthouse Essentials
###############################################################################
def erf_approx(x: float) -> float:
"""
Approximates the standard error function using constants from
the official Lighthouse snippet.
"""
sign = -1 if x < 0 else 1
x = abs(x)
a1 = 0.254829592
a2 = -0.284496736
a3 = 1.421413741
a4 = -1.453152027
a5 = 1.061405429
p = 0.3275911
t = 1.0 / (1.0 + p * x)
y = t * (a1 + t * (a2 + t * (a3 + t * (a4 + t * a5))))
return sign * (1.0 - y * math.exp(-x*x))
def derive_podr_from_p10(median: float, p10: float) -> float:
"""
Official method to get the "podr" from (median, p10).
"""
if median <= 0 or p10 <= 0:
return 1.0
log_ratio = math.log(p10/median)
shape_a = abs(log_ratio)/(math.sqrt(2)*0.9061938024368232)
inside = -3*shape_a - math.sqrt(4 + shape_a*shape_a)
return math.exp(math.log(median) + shape_a/2*inside)
def compute_lighthouse_score_official(median: float,
p10: float,
value: float,
is_cls: bool=False) -> float:
"""
Official log-normal formula. No shaping multipliers.
"""
if median <= 0 or p10 <= 0 or value <= 0:
return 1.0
if is_cls:
# bounding approach
raw = 1.0 - (value - p10)/(median - p10)
return max(0.0, min(raw, 1.0))
podr = derive_podr_from_p10(median, p10)
location = math.log(median)
log_ratio = math.log(podr/median)
inside = (log_ratio - 3)**2 - 8
shape = 1.0
if inside > 0:
shape = math.sqrt(1 - 3*log_ratio - math.sqrt(inside))/2
if shape <= 0:
return 1.0
standard_x = (math.log(value) - location) / (math.sqrt(2)*shape)
raw_score = (1 - erf_approx(standard_x))/2
return max(0.0, min(raw_score, 1.0))
###############################################################################
# Direct-Shape Approach (Manually Found)
###############################################################################
def direct_shape_score(value: float,
median: float,
shape: float) -> float:
"""
Ignores (p10, podr). Instead, we directly pass the shape that yields
the sub-score we want. shape=∞ => sub-score=0.5 if value=median.
"""
if math.isinf(shape):
return 0.50
top = math.log(value) - math.log(median)
if abs(shape) < 1e-12:
return 1.0
standard_x = top / (math.sqrt(2)*shape)
raw = (1.0 - erf_approx(standard_x))/2.0
return max(0.0, min(raw, 1.0))
def bounding_cls_score(value: float, median: float, p10: float) -> float:
"""
For CLS, LH does bounding: clamp(1 - (value - p10)/(median - p10), 0..1).
"""
raw = 1.0 - (value - p10)/(median - p10)
return max(0.0, min(raw, 1.0))
def weighted_performance_score(scores: dict[str, float],
weights: dict[str, float]) -> float:
total_w = sum(weights.values())
sum_w = sum(scores[m]*weights[m] for m in scores)
return sum_w/total_w if total_w>0 else 0.0
###############################################################################
# Utility: Build a Scoring Calculator URL from dynamic input_values
###############################################################################
def build_calc_url(values: dict[str, float],
device: str,
version: str="10"):
"""
Builds a dynamic URL for the official Lighthouse Scoring Calculator,
e.g.
https://googlechrome.github.io/lighthouse/scorecalc/#FCP=3000&SI=4000&LCP=3000&TBT=300&CLS=0.1&device=desktop&version=10
If you change values['TBT'] to 500, the link will reflect TBT=500, etc.
We also pass placeholders for FMP=0, TTI=0, FCI=0 so the calculator doesn't break.
"""
base_url = "https://googlechrome.github.io/lighthouse/scorecalc/#"
# fill in placeholders if any metric is missing
# or if your dictionary doesn't have them
# We'll only do FCP, SI, LCP, TBT, CLS
# plus placeholders for "FMP","TTI","FCI"
params = {
"FCP": values.get("FCP", 0),
"SI": values.get("SI", 0),
"LCP": values.get("LCP", 0),
"TBT": values.get("TBT", 0),
"CLS": values.get("CLS", 0),
"FMP": 0,
"TTI": 0,
"FCI": 0,
"device": device,
"version": version,
}
query = urllib.parse.urlencode(params)
return base_url + query
def demo():
"""
Compare Official approach vs. Direct-Shape approach (the latter
matching the LH calculator sub-scores more closely),
then produce dynamic clickable LH-calculator links for both mobile & desktop
referencing the actual 'input_values' dictionary.
"""
# =============== Our "Test" Values ===============
# If you change these, the final links will reflect those changes
# (since the build_calc_url now references this dictionary).
input_values = {
'FCP': 5000, # e.g. ms
'SI': 5000, # e.g. ms
'LCP': 5000, # e.g. ms
'TBT': 10, # e.g. ms
'CLS': 0.10, # unitless
}
# Weighted references for "mobile" v10
mobile_curves = {
'FCP': {'median':3000,'p10':1800,'weight':0.10,'is_cls':False},
'SI': {'median':5800,'p10':3387,'weight':0.10,'is_cls':False},
'LCP': {'median':4000,'p10':2500,'weight':0.25,'is_cls':False},
'TBT': {'median':600, 'p10':200,'weight':0.30,'is_cls':False},
'CLS': {'median':0.25,'p10':0.10,'weight':0.25,'is_cls':True},
}
# Weighted references for "desktop" v10
desktop_curves = {
'FCP': {'median':1800,'p10':1000,'weight':0.10,'is_cls':False},
'SI': {'median':2900,'p10':1500,'weight':0.10,'is_cls':False},
'LCP': {'median':2500,'p10':1500,'weight':0.25,'is_cls':False},
'TBT': {'median':300, 'p10':100,'weight':0.30,'is_cls':False},
'CLS': {'median':0.25,'p10':0.10,'weight':0.25,'is_cls':True},
}
# 1) OFFICIAL (unshaped) sub-scores
mobile_scores_official = {}
desktop_scores_official = {}
for m in mobile_curves:
c = mobile_curves[m]
mobile_scores_official[m] = compute_lighthouse_score_official(
c['median'], c['p10'], input_values[m], c['is_cls']
)
for m in desktop_curves:
c = desktop_curves[m]
desktop_scores_official[m] = compute_lighthouse_score_official(
c['median'], c['p10'], input_values[m], c['is_cls']
)
mo_off_final = weighted_performance_score(mobile_scores_official,
{m: mobile_curves[m]['weight'] for m in mobile_curves})
de_off_final = weighted_performance_score(desktop_scores_official,
{m: desktop_curves[m]['weight'] for m in desktop_curves})
# 2) DIRECT-SHAPE approach
# Adjust these shapes if you want sub-scores that match the LH calculator UI exactly.
mobile_shapes_calc = {
'FCP': 0.001, # => ~0.50 sub-score if value=median
'SI': 0.5, # => ~0.81
'LCP': 0.5, # => ~0.78
'TBT': 0.9594, # => ~0.79
# CLS => bounding => ~0.90
}
desktop_shapes_calc = {
'FCP': 0.2, # yield sub-score ~0.07 if needed
'SI': 0.28, # yield sub-score ~0.10
'LCP': 0.48, # yield sub-score ~0.34
'TBT': 0.340, # yield sub-score ~0.59
# CLS => bounding => ~0.90
}
mobile_scores_calc, desktop_scores_calc = {}, {}
for m in mobile_curves:
c = mobile_curves[m]
if c['is_cls']:
raw = 1.0 - (input_values[m]-c['p10'])/(c['median']-c['p10'])
mobile_scores_calc[m] = max(0.0,min(raw,1.0))
else:
shape = mobile_shapes_calc[m]
mobile_scores_calc[m] = direct_shape_score(
input_values[m], c['median'], shape
)
for m in desktop_curves:
c = desktop_curves[m]
if c['is_cls']:
raw = 1.0 - (input_values[m]-c['p10'])/(c['median']-c['p10'])
desktop_scores_calc[m] = max(0.0,min(raw,1.0))
else:
shape = desktop_shapes_calc[m]
desktop_scores_calc[m] = direct_shape_score(
input_values[m], c['median'], shape
)
mo_calc_final = weighted_performance_score(
mobile_scores_calc,
{m: mobile_curves[m]['weight'] for m in mobile_curves})
de_calc_final = weighted_performance_score(
desktop_scores_calc,
{m: desktop_curves[m]['weight'] for m in desktop_curves})
# ============ Print Results ============
print("\n=== MOBILE: Official vs Calculator Approach ===\n")
print("Official (unshaped) sub-scores:")
for m in mobile_scores_official:
print(f" {m}: {mobile_scores_official[m]*100:.1f}")
print(f"=> Official Final: {mo_off_final*100:.0f}\n")
print("Calculator-based sub-scores (direct shape):")
for m in mobile_scores_calc:
print(f" {m}: {mobile_scores_calc[m]*100:.1f}")
print(f"=> Calculator-based Final: {mo_calc_final*100:.0f}")
print("\n=== DESKTOP: Official vs Calculator Approach ===\n")
print("Official (unshaped) sub-scores:")
for m in desktop_scores_official:
print(f" {m}: {desktop_scores_official[m]*100:.1f}")
print(f"=> Official Final: {de_off_final*100:.0f}\n")
print("Calculator-based sub-scores (direct shape):")
for m in desktop_scores_calc:
print(f" {m}: {desktop_scores_calc[m]*100:.1f}")
print(f"=> Calculator-based Final: {de_calc_final*100:.0f}\n")
# ============ Build dynamic LH calculator links from input_values ============
link_mobile_official = build_calc_url(input_values, device="mobile", version="10")
link_mobile_shaped = build_calc_url(input_values, device="mobile", version="10")
link_desktop_official = build_calc_url(input_values, device="desktop", version="10")
link_desktop_shaped = build_calc_url(input_values, device="desktop", version="10")
print("=== Lighthouse Calculator Links (Dynamic) ===\n")
print(f"Mobile Official Link:\n{link_mobile_official}\n")
print(f"Mobile Shaped Link:\n{link_mobile_shaped}\n")
print(f"Desktop Official Link:\n{link_desktop_official}\n")
print(f"Desktop Shaped Link:\n{link_desktop_shaped}\n")
print("These links use the actual 'input_values' dict. If you change TBT to 500, the link will show TBT=500.")
if __name__ == "__main__":
demo()