Debugging & Diagnostics for Headless Rendering
In a headless stack the HTML a crawler receives is produced by a build step, an edge function, or a hydration boundary โ any of which can silently drop content or slow first paint. This section is a repeatable diagnostic workflow that catches rendering and Core Web Vitals regressions in the deploy pipeline, before they reach the index.
Prerequisites
Before wiring diagnostics into your pipeline, confirm these are in place:
- A CI runner (GitHub Actions, GitLab CI, or similar) that can run on every pull request
@lhci/cli0.13+ installed as a dev dependency- A preview deployment URL per pull request (Vercel, Netlify, or Cloudflare Pages preview) so Lighthouse tests the real build, not a local dev server
- Chrome headless available in CI โ the
lhciDocker image orbrowser-actions/setup-chromeprovides it - Node 18+ and access to your production Core Web Vitals baseline for comparison
A working understanding of ISR vs SSG vs CSR routing is assumed โ the render mode of each route determines what a crawler sees before hydration, which is exactly what these diagnostics verify.
The Rendering Diagnostic Gate
The diagram below shows where the diagnostic step sits in the pipeline and how it gates promotion.
Step-by-Step Implementation Workflow
Step 1 โ Add Lighthouse CI to the pipeline
Install the runner and add a collect step that targets the preview deployment URL. Running against the deployed preview โ not a local server โ measures the same edge caching and network path a crawler experiences.
npm install --save-dev @lhci/cli
# In CI, after the preview URL is known:
npx lhci autorun \
--collect.url="$PREVIEW_URL" \
--collect.numberOfRuns=3
Step 2 โ Set assertion budgets
Declare budgets in lighthouserc.js. Median of three runs smooths variance; fail on the metrics that map to Core Web Vitals plus the SEO category score.
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'categories:seo': ['error', { minScore: 1 }],
},
},
},
};
Step 3 โ Detect render-blocking resources
Turn the render-blocking-resources audit into a hard assertion so a newly-introduced synchronous script fails the build rather than merely warning.
// add to the assertions block above
'render-blocking-resources': ['error', { maxLength: 0 }],
'unused-javascript': ['warn', { maxNumericValue: 40000 }],
Step 4 โ Verify server HTML contains content
Lighthouse renders with JavaScript, so it cannot tell you whether content exists in the server HTML. Add a scripts-disabled fetch that asserts the main content and canonical are present before hydration.
# Fetch raw HTML (no JS execution) and assert main content is present
html=$(curl -s "$PREVIEW_URL")
echo "$html" | grep -q '<h1' || { echo "No server-rendered H1"; exit 1; }
echo "$html" | grep -qi 'rel="canonical"' || { echo "No canonical in HTML"; exit 1; }
Step 5 โ Block promotion on regression
Gate the promote job on the diagnostic job. In GitHub Actions, a failed assertion returns a non-zero exit code, so a dependent job never runs.
# .github/workflows/deploy.yml
jobs:
diagnostics:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx lhci autorun --collect.url=${{ env.PREVIEW_URL }}
promote:
needs: diagnostics # only runs if diagnostics passed
runs-on: ubuntu-latest
steps:
- run: ./scripts/promote-to-production.sh
Framework-Specific Code Examples
The diagnostic that most often fails is Step 4 โ content missing from server HTML. How you confirm server rendering differs per framework.
Next.js App Router
# Inspect the built output: a statically rendered route emits HTML at build time
next build
grep -rl '<h1' .next/server/app | head
# At runtime, confirm the deployed route serves content without JS
curl -s "$PREVIEW_URL/blog/post" | grep -c '<article'
SEO impact: A route that unexpectedly opts into dynamic rendering (via cookies(), headers(), or searchParams) ships an empty shell to crawlers on cold requests. Confirming content in .next/server/app proves the route was statically rendered.
Validation: curl the deployed route and assert the <article> count is greater than zero without a browser.
SvelteKit
# A prerendered route writes a static .html file containing content
vite build
find .svelte-kit/output/prerendered -name '*.html' \
-exec grep -L '<h1' {} \; # lists files MISSING an H1 โ should be empty
SEO impact: With export const prerender = true, SvelteKit emits static HTML with full content and head tags. A route left as client-side-rendered ships an empty <div id="svelte">, which crawlers may index as thin.
Validation: The grep -L list must be empty โ every prerendered file contains an <h1>.
Nuxt 3
# Nitro server render: confirm content is in the initial payload, not only hydrated
curl -s "$PREVIEW_URL/products/item" \
| grep -o 'data-server-rendered="true"'
curl -s "$PREVIEW_URL/products/item" | grep -c '<h1'
SEO impact: Nuxt routes set to ssr: false or a client-only routeRules mode return an empty app root. Checking for server-rendered markers confirms Nitro produced content-bearing HTML.
Validation: The <h1> count must be non-zero and the server-rendered marker present in the raw response.
HTTP Headers & CDN Directives Reference
| Header | Required value | Rationale |
|---|---|---|
Server-Timing |
render;dur=<ms> |
Surfaces server render duration in the diagnostic run, separating slow origin from slow network |
Cache-Control |
public, max-age=0, s-maxage=86400, stale-while-revalidate=59 |
Confirms the preview is served from the edge, so Lighthouse measures cached TTFB like a crawler |
Content-Encoding |
br or gzip |
Missing compression inflates transfer size and Total Blocking Time on script-heavy pages |
X-Robots-Tag |
index, follow |
Ensures a diagnosed route is not accidentally blocked from indexing at the header layer |
Vary |
Accept-Encoding |
Prevents a CDN from serving an uncompressed variant that skews render-blocking measurements |
Configure these in line with edge caching behavior for SEO so the preview environment reflects production timings rather than an uncached origin.
Validation Protocol
Local Lighthouse smoke run
# Reproduce a CI failure locally against the same preview URL
npx lhci collect --url="$PREVIEW_URL" --numberOfRuns=3
npx lhci assert --config=lighthouserc.js
Scripts-disabled crawl check
# What a non-rendering crawl sees: strip nothing, execute nothing
curl -s "$PREVIEW_URL/blog/post" > raw.html
wc -c raw.html # near-empty file signals client-only rendering
grep -c '<p' raw.html # paragraph count in server HTML
History-backed regression check
# Upload results to compare against a rolling baseline instead of a fixed number
npx lhci upload --target=temporary-public-storage
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Lighthouse passes but indexed content is thin | Page renders client-side; Lighthouse executes JS, crawler snapshot did not | Add the scripts-disabled check from Step 4; move main content to server rendering |
| LCP regression only in CI, not locally | Local dev server skips edge cache and compression | Always test the deployed preview URL, not localhost |
render-blocking-resources flags a vendor script |
Synchronous third-party tag in <head> |
Move to defer/async or load after hydration โ see diagnosing render-blocking JavaScript in headless frontends |
| Assertions flap between pass and fail | Single run captures network variance | Set numberOfRuns: 3 and assert on the median |
| Job passes despite a broken route | Lighthouse only tested the home page | Add every critical template URL to collect.url |
| Preview URL 401s in CI | Preview deployment is password-protected | Pass a bypass token or use a public preview channel for the diagnostic job |
Pages in This Section
- Integrating Lighthouse CI Into Headless Deploy Pipelines โ install lhci, assert Core Web Vitals and SEO budgets against preview URLs, and block promotion on regression
- Diagnosing Render-Blocking JavaScript in Headless Frontends โ find client-only content and blocking bundles, then confirm crawler-visible HTML without executing scripts
Frequently Asked Questions
How do I fail a deploy on a Core Web Vitals regression?
Run Lighthouse CI against the preview URL in the same pipeline that gates promotion, and declare assertions for LCP, Total Blocking Time, and CLS. When an assertion breaches, lhci assert exits non-zero, the job fails, and the dependent promote job never runs. Store history so you also catch gradual drift against a rolling baseline, not just an absolute threshold.
How do I detect render-blocking JavaScript?
Turn the Lighthouse render-blocking-resources audit into a hard assertion, then confirm each finding by loading the page with JavaScript disabled. Main content that disappears without JS is hydration-dependent. The dedicated guide on diagnosing render-blocking JavaScript in headless frontends walks through splitting and deferring the offending bundles.
Can Lighthouse CI catch CSR-only content? Not on its own โ Lighthouse renders with JavaScript, so a purely client-rendered page can still score well. Pair it with the scripts-disabled fetch in Step 4 that asserts the main content exists in the raw server HTML. Together they fail the build both when performance regresses and when server rendering silently breaks.
Is this pipeline different from crawl and indexation debugging? Yes. This section diagnoses how a page renders โ whether content and metadata reach the crawler quickly and completely. Diagnosing whether crawled URLs actually get indexed is a routing concern covered under debugging and diagnostics for headless routing. Rendering problems usually surface first as thin or slow pages; indexation problems surface as coverage gaps.
Part of: Headless Architecture & Rendering Strategy Fundamentals
Related
- ISR vs SSG vs CSR Routing โ the render mode of each route determines what these diagnostics can see before hydration
- Edge Caching Behavior for SEO โ configure the preview so measured TTFB reflects production edge caching
- Debugging & Diagnostics for Headless Routing โ the routing-side counterpart for crawl and indexation gap analysis
- Integrating Lighthouse CI Into Headless Deploy Pipelines โ the deep dive on assertion budgets and promotion gating