DEV Community

ibrahim TOPBAŞI
ibrahim TOPBAŞI

Posted on

HTTP 200 does not mean your page rendered

A page can answer 200 OK, ship a full JSON payload, pass node --check, and still render a completely blank screen. I shipped exactly that, and the status code told me everything was fine for a whole day.

What happened

I maintain a small internal dashboard. The server sends an empty container and the data inline; the browser builds the rows:

<main><div id="list"></div></main>
<script>
const DATA = { items: [ /* ... 355 KB ... */ ] };
function draw(){ /* builds a card per item */ }
draw();
</script>
Enter fullscreen mode Exit fullscreen mode

A field was renamed upstream (candidates disappeared from the payload), but two reads of it survived in the template. The first card threw TypeError: Cannot read properties of undefined (reading 'length'), draw() unwound, and #list stayed empty.

Every check I had was green:

check result what it actually proves
curl -o /dev/null -w '%{http_code}' 200 bytes were served
response size 355 KB the data is in the page
node --check page.js rc=0 the file parses
rendered rows 0 — nobody measured this

Syntax validity and runtime success are different questions. So is "the server answered" and "the user saw something".

The gate that would have caught it

Run the page's own script in a context with a minimal DOM and count what it produced. Node's vm module is enough — no browser needed for the fast path:

const vm = require('vm');
const ctx = vm.createContext({ document: fakeDom, window: {}, console });
vm.runInContext(pageScript, ctx);          // throws? that is your blank page
const rows = fakeDom.byId.list.children.length;
Enter fullscreen mode Exit fullscreen mode

Two things bit me while writing that gate, both worth knowing:

1. const at the top level of a vm script is not a property of the context. Lexical declarations live in the global lexical scope, so ctx.DATA is undefined forever. Function declarations do become properties, which is why ctx.draw worked and hid the problem. Read it back in the same context instead:

vm.runInContext('typeof DATA !== "undefined" ? DATA.items.length : null', ctx);
Enter fullscreen mode Exit fullscreen mode

Without this, my gate read the item count as 0, so it would have happily accepted a page that rendered 5 of 50 rows.

2. Calling draw() yourself hides a page that never calls it. Measure the state right after loading the script — that is what the browser shows — and only then drive the filters and the search box yourself.

That last part matters more than it sounds: the second stale field read in my template sat in the search branch. The page looked fine on load and exploded the moment anyone typed. A gate that only renders the default view would have signed off on it.

The rule I kept

A proxy signal is an input, not a verdict.

200, rc=0, "the file was written", "the API accepted it" — all inputs. For anything a human looks at, measure the thing the human receives: the rendered screen, the published text, the delivered message. And when that measurement can't be taken (no runtime available, network down), the verdict is not green — it's unmeasured, which keeps the item open instead of closing it silently.

The fast gate runs on every build. A real headless Chromium run does the same count once a day, because a stub DOM is a stub, not a browser.


I build small Android apps on one shared Kotlin core; the dashboard in this post is the one that tracks them. The apps are listed at bulbildir.it.name.tr.

Top comments (0)