I added this blog to the PageSpeed monitoring pipeline last week. The script hits Google's PageSpeed Insights API, parses Core Web Vitals, and fires the results to Telegram.
The report came back:
🤖 tedagentic 99/100
LCP 🟢 1.7s CLS 🟢 0 TBT 🟢 0ms
A 99 on mobile for a site that's three weeks old and hasn't been manually optimised for performance. No image compression tuning, no font preloading, no custom cache headers. Just the default Astro build pushed to Vercel.
That number isn't luck. It's the direct consequence of how Astro renders pages. The rest of this post is the breakdown — what each metric is, why this site scores the way it does, and what's missing from the picture.
What Core Web Vitals are
Google uses three field metrics as ranking signals under the Core Web Vitals programme:
- LCP — Largest Contentful Paint. How long until the largest visible element is rendered.
- INP — Interaction to Next Paint. How long the page takes to respond to a user input.
- CLS — Cumulative Layout Shift. How much the page layout moves unexpectedly during load.
These are field metrics — measured from real users in Chrome, aggregated in the Chrome User Experience Report (CrUX). PageSpeed Insights shows both field data (when it exists) and lab data from a simulated Lighthouse audit. For ranking purposes, Google uses field data.
The thresholds:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | < 2.5s | 2.5s – 4.0s | > 4.0s |
| INP | < 200ms | 200ms – 500ms | > 500ms |
| CLS | < 0.1 | 0.1 – 0.25 | > 0.25 |
FCP (First Contentful Paint) and TBT (Total Blocking Time) are lab-only diagnostics. They don't feed directly into rankings, but they're useful proxies — TBT in particular is a strong predictor of INP.
LCP: 1.7s
The largest contentful element on most posts here is the post title — an <h1> in plain HTML. No hero image, no above-the-fold video, no JavaScript-rendered component.
When Astro builds a page, it produces a static HTML file. The browser receives a complete document on the first request. The title is in the HTML. There's no render cycle to wait for.
On a CSR React site, the sequence looks like this:
browser request
→ server returns HTML shell (empty <div id="root">)
→ browser downloads JS bundle
→ browser parses bundle
→ React hydrates, renders content
→ LCP element becomes visible
The JS bundle download and parse adds latency before anything is visible. On a fast connection with a small bundle it might be 400–800ms. On mobile, on a slow network, with a bundle that's grown over time, it can be several seconds. That's before any data fetching.
On Astro, the sequence is:
browser request
→ server returns complete HTML
→ browser paints immediately
→ LCP recorded
No intermediate steps. The browser gets the finished document and renders it. 1.7s on mobile is almost entirely network latency and time-to-first-byte from Vercel's CDN edge.
CLS: 0
Cumulative Layout Shift measures visual instability — elements moving around after initial paint. The classic causes: images without dimensions, late-loading fonts that swap, ads injecting above existing content, JavaScript inserting elements above the fold.
This site has no images above the fold on most pages. The fonts (Inter and JetBrains Mono) are self-hosted and loaded via @font-face — they're included in the Astro build and served from the same origin. There's no web font swap from a third-party CDN, no flash of unstyled text from a late-loading Google Fonts request.
There's also no JavaScript inserting DOM elements after paint. No React hydration reshuffling the layout, no analytics widget pushing content down, no cookie banner appearing above the fold.
CLS of 0 is the natural result of serving a page that looks the same the moment the HTML lands as it does after everything has loaded. When nothing changes after initial paint, nothing shifts.
TBT: 0ms
Total Blocking Time measures time spent blocking the main thread during page load — specifically, the sum of time any task takes longer than 50ms. It's a lab proxy for how responsive the page feels.
The main thread gets blocked by long JavaScript tasks. Parsing a large bundle, executing framework initialisation code, running hydration — these all create tasks that lock the main thread and prevent it from responding to user inputs.
This site ships zero JavaScript to the browser by default. There's no bundle to parse. The inline script in the layout is eleven lines for scroll-based header styles and a hamburger toggle — nowhere near 50ms to execute.
┌─────────────────────────────────────────────────────┐
│ MAIN THREAD ACTIVITY │
├─────────────────────────────────────────────────────┤
│ │
│ CSR React blog (typical) │
│ ────────────────────────────────────────── │
│ [parse HTML] [████████████ parse bundle ████] ... │
│ └── blocking task > 50ms │
│ TBT: 200–600ms │
│ │
│ Astro static (this site) │
│ ────────────────────────────────────── │
│ [parse HTML] [inline script, 11 lines] │
│ └── < 50ms, not blocking │
│ TBT: 0ms │
│ │
└─────────────────────────────────────────────────────┘
0ms TBT means the main thread is free from the moment the HTML is parsed. Any user input — a tap, a scroll, a click — is handled immediately.
INP: n/a
This is the one that needs explaining.
INP shows as n/a in PageSpeed Insights. That doesn't mean it's zero — it means there's no data. INP is a field metric. It requires real users to interact with the page in Chrome, and that interaction data to be collected in CrUX.
This domain is three weeks old. There isn't enough real-user interaction data for Google to report an INP score. Once the site accumulates CrUX data — typically after a few months of real traffic — INP will appear.
TBT is the lab proxy in the meantime. 0ms TBT is a strong signal that INP will be good when it does appear — the two metrics are closely correlated. A page with no long main-thread tasks during load is a page that responds instantly to inputs.
The distinction matters for how you interpret PSI reports on new sites. A missing INP isn't a gap in performance — it's a gap in data collection. The absence of field data is normal for any site with limited traffic. Lab scores (TBT, FCP, LCP in lab mode) are still meaningful and actionable.
The monitoring pipeline
The script that produced this report runs weekly via cron, checks all four sites, and fires to Telegram via OpenClaw:
SITES = {
"tedagentic": ("https://tedagentic.com", "🤖"),
# other sites omitted
}
Each site gets audited against Google's PageSpeed Insights API, scores are stored to a JSON history file for trend tracking, and the report is formatted and sent to Telegram.
┌──────────────────────────────────────────────────────┐
│ WEEKLY PIPELINE │
├──────────────────────────────────────────────────────┤
│ │
│ cron (Sunday 10:30am) │
│ │ │
│ ▼ │
│ speed_test.py │
│ │ │
│ └─→ PSI API → tedagentic.com → score │
│ │ │
│ ▼ │
│ history.json (trend delta) │
│ │ │
│ ▼ │
│ OpenClaw → Telegram │
│ │
└──────────────────────────────────────────────────────┘
The trend delta is the part that matters over time. A score of 99 this week is useful context. A score that drops from 99 to 84 after a deploy is a signal to investigate immediately. The history file tracks the last score per site per strategy — the Telegram report shows the delta alongside the current number.
What the stack choice decides for you
The 99/100 on this blog required no performance work. No audit pass, no image optimisation sprint, no lazy loading configuration.
That's not because Astro is magic. It's because the framework's defaults eliminate the performance failure modes that require active remediation on other stacks.
Zero JavaScript by default means no bundle to block the main thread. Static HTML by default means no render cycle to delay LCP. Self-hosted assets by default means no third-party round trips to create layout shifts.
On a React CSR blog the starting point is roughly the opposite: a JS bundle is required, it blocks the main thread during parse, and LCP is gated behind hydration. Getting from that baseline to a 90+ score requires deliberate work — code splitting, lazy hydration, image optimisation, font strategy. It's achievable. It's also ongoing. Every new dependency added, every new component rendered above the fold, requires re-evaluation.
The Astro baseline is 99. The work is keeping it there, which mostly means not introducing the things that break it: large above-the-fold images without dimensions, third-party scripts in the <head>, client-side components that hydrate on load rather than on interaction.
The score you see in PSI is a consequence of the rendering decision you made when you chose the stack. Fix the stack, and most of the performance work is already done.


Top comments (0)