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 gate is the same in every framework; only the place the fix lands differs.
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
Two checks together separate the two failure classes, and running only one of them is why rendering problems are so often misdiagnosed.
| 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
Building a diagnostic habit rather than a diagnostic script
The tooling in this section is straightforward; what makes it effective is running it on a schedule rather than in response to an incident. A rendering regression that is caught by a nightly check is a small fix, while the same regression caught six weeks later by a traffic report is an investigation that starts with the question “what changed?” and no evidence to answer it.
The habit worth establishing is a small, fixed set of measurements taken at the same cadence: the critical-element diff on each template, a Lighthouse run against the same representative URLs, and the crawler cache-hit ratio. All three are cheap, all three produce a number, and the number is only interesting when it moves. Storing the history is what turns them from checks into diagnostics, because a value in isolation rarely identifies a cause and a change in a value almost always does.
It is equally important to be explicit about what these measurements do not cover. They say nothing about content quality, about whether the page satisfies the query, or about whether the URL should exist at all. Treating a green diagnostic dashboard as evidence that SEO is fine is a common and expensive mistake — the dashboard confirms that the technical layer is not the constraint, which is useful precisely because it directs attention elsewhere.
When the diagnosis is the deployment pipeline
A recurring pattern in decoupled stacks is that the rendering problem is not in the rendering code at all. A route renders correctly in development and in preview, and incorrectly in production, because production runs a different adapter, a different Node version, or an edge runtime with a different set of available APIs.
When the same code produces different HTML in two environments, stop debugging the code and start diffing the environments. The three variables worth checking first are the runtime target, the environment variables available at render time, and whether the response passed through a transform at the edge. One of those three explains the large majority of environment-specific rendering differences, and none of them is visible from the application source.
Two numbers worth watching continuously
Most of the diagnostics on this page are run in response to a question. Two are worth running continuously regardless, because they are leading indicators for problems that are expensive once they arrive.
The first is the size of the critical-element diff per template — the count of head tags, structured-data blocks, and internal links present after rendering but absent from the server response. It should be zero, it changes only when someone moves code across the client boundary, and it detects that move on the day it happens rather than in the quarter’s traffic report.
The second is client JavaScript bytes per route. It has no direct SEO effect, but it is the single best predictor of interaction-latency regressions and it moves gradually rather than suddenly, which makes a trend far more informative than any individual measurement. Both numbers come out of the build for free; the only work is recording them.
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