没有不值得去解决的问题,也没有不值得去学习的技术!

The Page Loads Fine, So Why Does Headless Chrome Still Fail Production?

Figure 1: Both Production Machine Acceptance checks passed, but Headless Chrome determined that the page did not finish rendering on / and /tour/ respectively

作者:

The previous article just resolved the Production public network acceptance stability issue for the A Tour of Go multilingual project: I migrated a large number of public HTTP checks from the local cross-border SOCKS link to zgocloud direct.

I originally thought that after fixing the network issues, the first Production of the Italian site would wrap up smoothly.

As it turned out, after machine acceptance fully passed, it got stuck again at the next stage:

Plaintext
[production-browser] FAILED: page did not render

And the first failure was on the homepage:

Plaintext
https://it-go-dev.shuijingwanwq.com/

After running it again, the homepage passed, but then it failed on:

Plaintext
https://it-go-dev.shuijingwanwq.com/tour/

Stranger still, accessing these pages directly from the same machine using curl for 20 consecutive requests all succeeded, each taking less than 1 second.

Ultimately, I determined that this and the previous cross-border public network issue were two completely independent problems.

What actually needed adjusting was Headless Chrome’s:

render readiness criteria.


1. Machine Acceptance has PASSED, but Browser Acceptance failed

In the formal Production workflow, machine acceptance and browser acceptance are two independent stages.

The former primarily verifies:

Plaintext
public routes
HTML identity
sitemap
socket boundary
CDN cache

The latter actually launches Headless Chrome and uses the Chrome DevTools Protocol to check page rendering, desktop and mobile views, the editor, Run / Format / Reset, SPA, ads, and other browser behaviors.

When entering browser acceptance for the first time, the public machine gate had already succeeded:

Plaintext
PRODUCTION MACHINE ACCEPTANCE: PASS
[首次生产] 公网验收:PASS(71.1s)

But immediately after:

Plaintext
[production-browser] FAILED: page did not render:
https://it-go-dev.shuijingwanwq.com/

The entire first Production therefore halted at:

Plaintext
stage: browser

I then formally resumed it.

The second machine acceptance still passed:

Plaintext
PRODUCTION MACHINE ACCEPTANCE: PASS
[首次生产] 公网验收:PASS(49.1s)

This time the homepage seemed fine, but the error moved to /tour/:

Plaintext
[production-browser] FAILED: page did not render:
https://it-go-dev.shuijingwanwq.com/tour/
Figure 1: Both Production Machine Acceptance checks passed, but Headless Chrome determined that the page did not finish rendering on / and /tour/ respectively
Figure 1: Both Production Machine Acceptance checks passed, but Headless Chrome determined that the page did not finish rendering on / and /tour/ respectively

This phenomenon made me start to wonder:

Did the page access really fail?

If Production itself had a persistent failure, the failure location would usually be more stable.

But instead it was:

Plaintext
First time: /
Second time: /tour/

This looked more like an issue with some transient check within browser acceptance itself.


2. First, confirm whether ordinary public network access is actually unstable

Since the previous article had just dealt with cross-border network issues, the easiest mistake to make here was to continue attributing the new browser failure to:

Plaintext
zgocloud
Cloudflare
cross-border network

But this time, I had to first clarify the actual link.

The public network portion of machine acceptance is now:

Plaintext
local maintenance machine
    ↓ SSH
zgocloud

Cloudflare

Production

Browser acceptance, however, does not run on zgocloud.

Headless Chrome actually runs on my local ThinkPad:

Plaintext
ThinkPad

Cloudflare

Production

Therefore:

A browser acceptance failure does not directly indicate a problem with zgocloud.

To determine whether the local public network link itself was unstable, I continuously tested the two URLs that had failed directly from the same ThinkPad.

Homepage:

Plaintext
https://it-go-dev.shuijingwanwq.com/

10 consecutive requests.

/tour/:

Plaintext
https://it-go-dev.shuijingwanwq.com/tour/

Also 10 consecutive requests.

Result:

Plaintext
20 / 20 HTTP 200

And the total time for most of them was only:

Plaintext
about 0.56 to 0.84 seconds
Figure 2: Accessing / and /tour/ directly from the same ThinkPad returned HTTP 200 for all 20 consecutive requests
Figure 2: Accessing / and /tour/ directly from the same ThinkPad returned HTTP 200 for all 20 consecutive requests

This does not prove that the network will never experience transient issues.

But it at least provides a very important basis for judgment:

Current ordinary HTTP public network access does not exhibit anomalies that could explain the consecutive browser failures.

Therefore, there was no need to modify the zgocloud, Cloudflare, or Production network architecture again.

The investigation should continue into Headless Chrome’s own rendering checks.


3. The old render readiness check was too strict

After continuing to inspect the browser acceptance implementation, I found that the old logic, after navigating to the page, would wait for the page to meet a condition similar to this:

Plaintext
document.readyState == "complete"
+
body has already presented sufficient text content

The wait time was up to about 30 seconds.

At first glance, this check seems reasonable:

Only when the page enters complete is it considered fully loaded.

But for real web pages, complete is not necessarily the best condition for determining that a “page is ready for acceptance.”

Browsers typically go through:

Plaintext
loading

interactive

complete

When the state enters:

Plaintext
interactive

the DOM has finished parsing, and the main body of the page can usually be interacted with normally.

But:

Plaintext
complete

requires waiting for more external resources to finish.

And a real Production page contains more than just its own HTML, CSS, and JavaScript.

It may also include:

Plaintext
analytics scripts
ad scripts
third-party resources
network requests

The loading state of these resources does not necessarily affect whether the course page itself has rendered correctly.

So a situation could arise where:

Plaintext
the main body of the page has already appeared
and the user can actually already use it

but some peripheral resource has not finished yet

readyState does not meet the old condition for a long time

browser acceptance determines:
page did not render

This produced a false failure.


4. Why does the failing page change?

This also explains a phenomenon that previously seemed rather strange.

First time:

Plaintext
/

failed.

Second time:

Plaintext
/

passed, but:

Plaintext
/tour/

failed again.

If the page itself had a deterministic HTML or JavaScript error, it should usually be stably reproducible on the same route.

However, if the problem stems from:

Plaintext
the page has mostly rendered
+
fluctuation in the completion time of peripheral resources
+
the readiness condition requires complete

then which page a navigation timeout occurs on could be coincidental.

This aligns better with the actually observed phenomenon.


5. You can’t simply change the 30-second timeout to 60 seconds

After discovering this issue, the simplest fix seemed to be:

Plaintext
30 seconds

60 seconds

or even:

Plaintext
120 seconds

But this does not actually solve the problem with the criteria itself.

If a page is in the:

Plaintext
interactive

state:

  • the DOM already exists
  • the page body has already appeared
  • and subsequent semantic checks can also be executed

then hanging the entire browser acceptance for tens of seconds just to wait for a non-critical resource is not very valuable.

Conversely, if the page truly did not render, simply extending the timeout only makes the failure slower.

So this time I did not simply increase the total wait time.


6. New strategy: interactive or complete + actual page content

The adjusted readiness check no longer only accepts:

Plaintext
readyState == complete

but allows:

Plaintext
interactive
or
complete

while still requiring:

Plaintext
the page body has already presented valid text

That is to say:

Instead of only looking at the browser state string, it combines the browser lifecycle state with the actual page content.

The logic roughly becomes:

Plaintext
readyState ∈ {interactive, complete}
+
bodyTextLength > minimum threshold

This avoids a certain type of error:

Plaintext
the DOM is normal
the page is already displayed
only peripheral resources have not all finished

yet it is determined as page did not render

At the same time, we cannot only check for:

Plaintext
interactive

Otherwise, an almost blank page could also be mistaken for normal.

So the body content check is still retained.


7. One long wait changed to 3 independent navigations

There was another adjustment this time.

The old logic was closer to:

Plaintext
one navigation

maximum wait of about 30 seconds

success / failure

Now it is changed to:

Plaintext
navigation 1
wait up to about 10 seconds

if render readiness is not met

navigation 2
wait up to about 10 seconds

still not met

navigation 3
wait up to about 10 seconds

only fail closed if it still fails

That is, a maximum of 3 independent navigations.

An important point here is:

It is not simply retrying one navigation indefinitely.

The overall time budget for render readiness remains roughly at the same scale as before.

The difference is that if a particular navigation happens to encounter a transient state in the browser or resource loading, a clean page navigation can be re-established.

Compared to:

Plaintext
hanging the same abnormal navigation for a full 30 seconds

this approach is more friendly to transient render failures.


8. But true semantic errors cannot be retried into passing

Adding retries easily introduces another risk:

What if a real browser acceptance error is tried a few more times and coincidentally bypassed?

So this time, the retry scope was strictly limited.

What is allowed to be retried is only:

Plaintext
render readiness

That is:

Plaintext
whether the page has entered a state where acceptance can continue

Once page readiness is met, the subsequent formal semantic checks are still:

Plaintext
single run
+
fail closed

For example:

Plaintext
whether canonical is correct
whether html lang is correct
whether the course content is correct
whether Run works
whether Format works
whether Reset works
whether SPA is normal
whether the ad mount is normal

If these checks fail, it is a real Production browser acceptance failure.

You cannot “refresh it into a PASS” by refreshing 3 or 5 times.

This is the boundary I value most in this modification:

Only give retries to parts that might truly be transient; do not give retries to semantic errors.


9. After the modification, the same Production release formally passed

After completing the adjustments, I did not re-publish or re-deploy.

I continued using the previously deployed formal it-IT release and resumed first-production again.

Machine acceptance:

Plaintext
PRODUCTION MACHINE ACCEPTANCE: PASS
[首次生产] 公网验收:PASS(31.1s)

Then Browser Acceptance:

Plaintext
[production-browser] desktop routes: PASS
[production-browser] mobile /tour/moretypes/1: PASS
[production-browser] Run / Format / Reset / SPA / ads: PASS

PRODUCTION BROWSER ACCEPTANCE: PASS
[首次生产] 浏览器验收:PASS(41.4s)

Finally, the process formally advanced to:

Plaintext
[首次生产] READY FOR HUMAN VISUAL GATE
Figure 3: After adjusting render readiness and transient navigation retries, the formal Production Browser Acceptance fully passed
Figure 3: After adjusting render readiness and transient navigation retries, the formal Production Browser Acceptance fully passed

This result also proves:

This modification was not intended to skip browser acceptance.

The subsequent:

Plaintext
desktop routes
mobile
Run
Format
Reset
SPA
ads

were all still actually executed and passed.

It only fixed the initial:

Plaintext
"whether the page is already ready to begin acceptance"

check.


10. This issue and the previous network issue are actually completely different

These two issues happened back-to-back, making it easy to conflate them.

But the actual links are completely different.

The previous issue was:

Plaintext
local

cross-border SOCKS

zgocloud

Cloudflare

A large number of public network requests went through an unnecessarily existing cross-border link, resulting in:

Plaintext
curl 28
curl 97

The final solution was:

Plaintext
zgocloud direct

But this time:

Plaintext
Machine Acceptance: PASS

local Headless Chrome

page did not render

Ordinary curl also had:

Plaintext
20 / 20 PASS

What actually needed adjusting was:

Plaintext
Headless Chrome render readiness

So even though both were:

Plaintext
Production FAILED

the correct path to resolve them was completely different.

The former required fixing:

Plaintext
network execution point

The latter required fixing:

Plaintext
browser readiness semantics

This is also why I increasingly dislike seeing FAILED and immediately:

Plaintext
redeploying
re-executing
increasing timeout
increasing retries

First, you should still look at:

Plaintext
stage
+
evidence

11. The hardest part of automated acceptance is not being “strict”, but being “reasonably strict”

Automated Production gates can easily swing to two extremes.

One is being too lenient:

Plaintext
PASS even when there are issues

This is obviously meaningless.

The other is:

Plaintext
the stricter the check condition, the safer

But in reality, that’s not necessarily true.

For example:

Plaintext
must have readyState == complete

looks stricter than:

Plaintext
interactive or complete

But if this extra restriction has no direct relation to the actual Production semantics that need to be verified, then what it brings may not be higher safety, but:

Plaintext
more false failures

The truly reasonable standard should be:

whether the page has reached the state required to execute the subsequent formal semantic acceptance.

rather than:

whether all possible peripheral resources have completely finished.

These two goals are not entirely the same.


12. Summary

This issue initially looked very strange:

Plaintext
Machine Acceptance: PASS

but:

Plaintext
Browser Acceptance: FAILED
page did not render

After running it again, the failing page even moved from:

Plaintext
/

to:

Plaintext
/tour/

While the same machine executing ordinary HTTP requests directly had:

Plaintext
20 / 20 HTTP 200

Ultimately, I found that the real problem was not in the Production page itself, nor in the zgocloud network path that had just been adjusted in the previous article.

But rather in:

Plaintext
Headless Chrome render readiness

The original check relied too heavily on:

Plaintext
document.readyState == complete

It has now been adjusted to:

Plaintext
interactive or complete
+
valid body content
+
up to 3 independent navigations

While continuing to maintain:

Plaintext
formal semantic checks
=
single run
+
fail closed

This both reduced false failures caused by transient browser states and did not lower the standards of Production Browser Acceptance itself.

What left the deepest impression on me this time was actually:

A good automated gate should not only be able to catch errors, but also accurately distinguish between real errors and the acceptance tool’s own transient failures.

Otherwise, the acceptance tool itself will become a new source of instability in the Production workflow.

系列导航

需要长期技术维护或远程问题排查?

我是拥有 15+ 年经验的 PHP / Go 后端工程师,长期关注已有系统维护、Bug 修复、性能优化、服务器排查、WordPress 网站维护和小功能迭代。

如果你的项目遇到以下情况,可以先从一次小问题排查开始合作:

  • ✅ PHP / Laravel / Yii2 老项目无人维护
  • ✅ Go / Gin 后端接口需要排查或优化
  • ✅ WordPress 网站访问慢、报错或插件冲突
  • ✅ Nginx / MySQL / Redis / Linux 服务器异常
  • ✅ CDN / Cloudflare / DNS / HTTPS 配置问题
  • ✅ 需要长期远程技术支持或兼职维护

更多介绍请查看:关于我 & 合作

微信:13980074657
邮箱:shuijingwanwq@gmail.com
Telegram:@shuijingwan
GitHub:https://github.com/shuijingwan