Back to Blog
Blog
Guard
SEO Breakage
May 2026

Why Lighthouse Scores Lie and Still Matter for Search

Uptime reported 99.99% healthy. Lighthouse came back Performance 92, Accessibility 100, Best Practices 93, SEO 98. The dashboard said “fine” while search traffic was already taking damage. Here's how a green score masked a rendering failure that cost organic clicks overnight.

The Lie

What the dashboard showed

92

Performance

100

Accessibility

93

Best Practices

98

SEO

Uptime reported 99.99% healthy. Every synthetic check passed. The dashboard said everything was fine.

The Truth

What was actually happening

We shipped a render change that returned a 200 and an 8.42KB HTML shell. The client bundle failed to hydrate on 83.1% of requests because of an async import race.

8.42KB

HTML shell (was 42.7KB)

14.3s

Field LCP

7.1s

INP

-0.37%

Organic clicks overnight

Users got a blank main pane. Crawlers indexed empty bodies. Your green monitor was gaslighting you.

Executive TL;DR

If you skip the rest of this post, know this:

  • 1
    Root cause: split-chunk async import + transient feature flag + deploy race → intermittent 404s and SRI mismatch.
  • 2
    Symptom: server returned a tiny 8.42KB shell (was 42.7KB), DOM stopped at 12 nodes, crawlers indexed empty bodies.
  • 3
    Impact: field LCP to 14.3s, INP 7.1s, organic clicks down 0.37% overnight.
  • 4
    Fixes: render critical article content in initial HTML, fail closed with server-side fallbacks, verify CDN chunk availability and SRI before release, and monitor field LCP and HTML body size.

Technical Deep Dive

Failure Mechanism

We introduced a split-chunk async import in the client entry that depended on a transient runtime feature flag. The server rendered only the minimum shell HTML at 8.42KB and referenced a chunk that intermittently 404ed under concurrency.

Vite-generated SRI hashes drifted after a deploy pipeline race, so the browser refused the asset with no useful signal in APM. Lighthouse lab runs hit a warmed cache with the chunk present, so the score stayed inflated while field traffic absorbed the failure.

CrUX showed the damage, but aggregation hid the sharp edge of the incident. Meanwhile, the headless crawler fetched the same shell, timed out on the hydration promise, and indexed what was effectively an empty body - reducing crawl value on affected URLs and contributing to the 0.37% drop in organic clicks.

Reproduction Vector

1

Deploy pipeline uploads index.html

Referencing chunk/foo.[sha].js with SRI hash.

2

Concurrent deploys produce race

Some origin nodes get updated SRI and new chunk names while others serve the new index.html but still reference an old chunk or a missing chunk.

3

Client attempts dynamic import

Based on featureFlag; import intermittently 404s or fails SRI check.

4

App swallows the error

Using .catch(() => {}) and leaves an empty root; server responded 200 with a shell only.

5

Crawlers and users receive no content

Headless crawlers/indexers and many real users receive no article content until JS resolves - often too late or never.

The Code That Caused the Bug

Client entry - the problematic pattern
useEffect(() => {
  import(`./widgets/${featureFlag ? 'new' : 'old'}.js`)
    .then(mod => mod.init())
    .catch(() => {});
}, [featureFlag]);

Why This Is Toxic

  • Dynamic import + silent .catch hides the failure and prevents error telemetry from surfacing.
  • Runtime boolean for chunk path means different clients request different assets; it's brittle during rolling deploys.
  • SRI drift makes the browser refuse assets with no clear error sent to your APM, so HTTP 200s look healthy while the page is empty.

Googlebot & User Perspectives

Headless Googlebot Fetch

  • HTML shell: 8.42KB
  • Inline links present
  • One deferred script
  • No hydration → DOM stopped at 12 nodes
  • 0 bytes of rendered article text

User Perspective (Chrome)

  • LCP: 14.3s
  • First meaningful paint: 9.8s
  • Blank main pane until chunk arrived
  • Many users bailed before content loaded
  • Headers healthy, body empty

Metric Regressions

HTML Payload

Before

42.7KB

After

8.42KB

Delta

-34.28KB

Bots indexed an empty article and users waited on client JS - contributing to 0.37% drop in organic clicks.

LCP (lab)

Before

1.14s

After

14.3s

Delta

+13.16s

Perceived performance collapsed; abandonment increased and search landing-page engagement dropped measurably.

DOM Node Count

Before

1,420

After

12

Delta

-1,408

Search engines received a blank shell; synthetic tests diverged from field reality and indexable content vanished.

Field Debugging Checklist

Run these checks when you suspect lab/field divergence:

  • Compare synthetic Lighthouse lab scores against CrUX and RUM field metrics for the same URL and release window.
  • Curl the page and verify Content-Length, body size, and presence of expected article text in the initial HTML.
  • Check SRI hashes and chunk availability on the CDN during the deploy window.
  • Run a headless render with Puppeteer and record DOM node count plus innerText length for the main article container.
  • Audit deploy artifacts and confirm every chunk referenced by index.html exists on the CDN at the exact SHA.

Detection Signals We Saw

Do not ignore these signals:

  • Synthetics showed Performance 92 while real-user LCP exceeded 8.1s in CrUX and RUM, masking a rendering fault until traffic slipped.
  • Spike in 200 responses with HTML bodies under 9.8KB while the normal article payload was ~42.7KB - shells being served.
  • Support complaints about interactivity rose alongside a 0.37% drop in organic click-throughs from top landing pages.

Copyable CLI Checks

Run these immediately when investigating:

Check headers
curl -sI https://www.example.com/article/123 | grep -E '^Content-Length|^ETag|^Cache-Control'
Get body length
curl -s https://www.example.com/article/123 | wc -c

Example output we saw: Content-Length: 8523, but meaningful text length was 0 - so the crawler received a valid 200 with no article body and the page lost search value.

Quick Test: What Do Bots Actually See?

~30 seconds

Most people guess. Don't.

Run this test and look at the actual response your site returns to bots.

1

Fetch your page as Googlebot

Use your terminal:

curl -A "Googlebot" https://yourdomain.com

Look for:

  • Real visible text (not just <div id="root">)
  • Meaningful content in the HTML
  • Page size (should not be tiny)
2

Compare bot vs browser

Now test what a real browser gets:

curl -A "Mozilla/5.0" https://yourdomain.com

If these responses are different, Google is indexing a different page than your users see.

Stop guessing — measure it.

Real example: 253 words vs 13,547

We see this constantly. Here's a real example from production: Googlebot saw 253 words and 2 KB of HTML. A browser saw 13,547 words and 77.5 KB. Same URL — completely different content.

Bot vs browser comparison showing 253 words for Googlebot vs 13,547 words for a rendered browser on the same URL

If your HTML doesn't contain the content, Google doesn't either.

Compare Googlebot vs browser on your site → HTTP Debug Tool
3

Check for common failure signals

We see this all the time in production:

  • HTML under ~1KB → usually empty shell
  • Visible text under ~200 characters → thin or missing content
  • Missing <title> or <h1> → weak or broken page
  • Large difference between bot vs browser HTML → rendering issue

Use the DataJelly Visibility Test (Recommended)

You can run this without touching curl. It shows you:

  • Raw HTML returned to bots (Googlebot, Bing, GPTBot, etc.)
  • Fully rendered browser version
  • Side-by-side differences in word count, HTML size, links, and content
Run Visibility Test — Free

What this test tells you (no guessing)

After running this, you'll know:

  • Whether your HTML is actually indexable
  • Whether bots are seeing partial content
  • Whether rendering is breaking in production

This is the difference between "I think SEO is set up" and "I know what Google is indexing."

If you don't understand why this happens, read: Why Google Can't See Your SPA

If this test fails

You have three real options:

SSR

Works if you can keep it stable in production

Prerendering

Breaks with dynamic content and scale

Edge Rendering

Reflects real production output without app changes

If you do nothing, you will not rank consistently. Learn how Edge Rendering works →

This issue doesn't show up in Lighthouse. It shows up in rankings.

Immediate Remediation Steps

What to do in the next 24-72 hours:

1

Hotfix: Revert or disable

Revert the split-chunk change or disable the runtime feature flag that selects widget variants until the deploy pipeline and CDN are guaranteed consistent.

2

Fail closed

Change client entry to render fallback text server-side if dynamic import fails, and return a 5xx or a clear error in telemetry if critical assets are missing.

3

Add server-side gating

Do not return 200 with an HTML body that lacks expected article text. If article HTML length < 18,237 bytes (our rule), return a flagged response and trigger immediate alerting.

4

Deploy integrity check

Before cutting traffic, verify every chunk referenced by index.html exists on every CDN origin and SRI matches the build artifact.

5

Fix instrumentation

Un-suppress import failures - log stack/sha and surface to error tracking. Record hydration failures as a high-severity signal.

Long-term Hardening

Server-render critical content

Move critical article HTML into the server-rendered payload. Reserve client JS for progressive enhancement, not for delivering the only copy of content.

Remove silent catches

Remove silent catches around dynamic imports for critical UI. If you must catch, send detailed telemetry and surface an indicator in production.

CI deploy step

Add a CI deploy step: cross-check index.html references vs artifact manifest and reject deploy if any chunk is missing or SHA mismatched.

Field-first monitoring

Alert when RUM LCP or DOM node count deviates drastically from synthetic labs for the same URL/time window. Treat divergence as a first-class alarm.

Automated crawler validation

Run a headless Googlebot render (Puppeteer with userAgent: Googlebot) on sampling of landing pages post-deploy and compare innerText length of main article container to baseline.

How DataJelly Guard Catches It

Guard monitors real page output: HTML size, visible text, DOM structure, rendering failures, and required sections across deploys. It fires immediately on regression - before your traffic drops.

  • Tracks HTML size and visible text per URL
  • Detects lab vs field divergence
  • Flags DOM drops and blank pages
  • Catches SRI mismatches and chunk 404s
  • Monitors field LCP and INP in real-time
  • Alerts before analytics show the drop

Lighthouse won't lie about the presence of problems - it just runs under different odds than your production fleet.

Your monitors are not omniscient; they are probabilistic. If your synthetic is green while CrUX screams, treat that as an emergency, not an anomaly to ignore. Ship server-rendered content as a primary guarantee, instrument hydration, and make divergence between lab and field a first-class signal.

That's how you stop green dashboards from gaslighting you and keep search traffic from bleeding overnight.

- Senior Staff Engineer, DataJelly Guard

FAQ