The compositor-free browser reports frozen animation as no animation
reads as
A screenshot shows the element static. Conclusion drawn: the animation is badly designed, or the values are wrong.
actually
requestAnimationFrame never fires because the headless browser has no compositor. Every rAF-driven counter, canvas loop and scroll handler is frozen at frame zero, whatever its quality.
blind because
A still image cannot distinguish 'renders one frame then stops' from 'renders one frame correctly'. Both produce the same pixels.
the check
let n=0; requestAnimationFrame(()=>n++); setTimeout(()=>console.log('rAF fired:', n), 1000)
cost of missing
Every visual parameter gets tuned against a frame the animation never advances past. The tuning is not merely useless, it is fitted to an artifact.
generalises to
Any observation instrument that shares a failure mode with the thing observed.
A var assignment silently overwrites a hoisted function of the same name
reads as
A canvas is blank and unanimated. Conclusion drawn: a rendering or design problem.
actually
The file already used `var start` as a timestamp. A later `function start()` was hoisted, then overwritten by the number at execution. The call that scheduled initialisation threw a TypeError and init never ran. The element sat at its untouched default size with zero painted pixels.
blind because
An element that renders nothing and an element that renders badly both look like a design problem in a screenshot. Nothing distinguishes them visually.
the check
Read the element's backing store, not its appearance: canvas.width/height still at the 300x150 default means resize() never ran.
cost of missing
Four rounds of visual tuning applied to a layer that was never drawing.
generalises to
Any name reused across a value and a declaration in the same scope.
A relative font URL resolves one directory too deep and fails into a plausible fallback
reads as
Text renders in a serif. Conclusion drawn: the font loaded.
actually
A stylesheet at /assets/fonts.css requesting url('assets/fonts/x.woff2') resolves to /assets/assets/fonts/x.woff2 and 404s. The browser substitutes a system serif without complaint.
blind because
The fallback is a working font. Only someone who knows the intended typeface can see the substitution, and only by comparison.
the check
document.fonts.check('1em "Family Name"') or a 404 on the font path in the network log.
cost of missing
Design review proceeds against the wrong typeface. Every judgement about weight, rhythm and scale is made on a substitute.
generalises to
Every fallback that is good enough to pass inspection: default configs, cached credentials, stub implementations.
Assets are not slow, they are queued behind synchronous work
reads as
Images take seconds to appear. Conclusion drawn: the images are too large.
actually
Their request had not been issued. Heavy synchronous work earlier in the document held the main thread, and the fetch that would load them sat unsent behind it.
blind because
Slow arrival and late departure are the same experience from the viewport.
the check
performance.getEntriesByType('resource') — startTime separates 'requested late' from 'transferred slowly'.
cost of missing
Assets get compressed, resized and lazy-loaded, which lowers quality without touching the delay.
generalises to
Any queue where wait time is read as service time.
Reveal-on-scroll renders a blank page when the observer never fires
reads as
A page loads blank in an embedded or scripted context. Conclusion drawn: a rendering failure.
actually
Elements start at opacity 0 and are revealed by an IntersectionObserver callback. Where the observer does not fire, the page is fully present and fully invisible.
blind because
The DOM is complete and correct. Only computed opacity distinguishes it from a page that failed to build.
the check
Compare element count against visible count: document.querySelectorAll('.reveal').length versus those with computed opacity above zero.
cost of missing
A working page is diagnosed as broken. Worse, the reverse: a genuinely blank page is dismissed as this.
mitigation
Any progressive-enhancement pattern that hides content by default needs a timeout that shows it regardless.
generalises to
Every design where the default state is invisible and visibility depends on a callback.
A transparent overlay takes the click the screenshot shows landing on the button
reads as
The screenshot shows the button unobscured and correctly placed, and the click was dispatched without error. Conclusion drawn: the button was clicked.
actually
A transparent element — a full-viewport modal backdrop, a zero-opacity loading layer, an oversized decorative pseudo-element — covers the button's centre point, and hit testing delivers the event to the topmost element at that coordinate. WebDriver has a named error for exactly this: the Element Click command could not be completed because the element receiving the events is obscuring the element that was requested clicked.
blind because
A transparent overlay contributes no pixels. The image of a covered button and the image of an uncovered one are the same image.
the check
Ask the document what occupies the point: `const r = el.getBoundingClientRect(); document.elementFromPoint(r.left + r.width/2, r.top + r.height/2) === el` — true when the element would receive the click, false when something is over it.
cost of missing
An automated flow reports submitting forms it never submitted. A synthetic `el.click()` compounds it, because dispatching on the element directly bypasses hit testing and succeeds where a real user's click would not.
generalises to
Any interaction verified by appearance rather than by the effect the interaction was supposed to have.
A screenshot's pixel grid is not the page's coordinate grid
reads as
The capture shows the button with its centre at image pixel (400, 140), and a click is dispatched at (400, 140). Conclusion drawn: the click landed on the button.
actually
The capture was taken at a device scale factor above one, so image pixels and CSS pixels differ by that factor. devicePixelRatio is the ratio between the size of a device pixel and the size of a CSS pixel, and a screenshot is measured in the former while every scripting and automation coordinate is expressed in the latter. At scale 2 the button's centre is at CSS (200, 70); (400, 140) is a different part of the page.
blind because
The image carries no units. A 1600-pixel-wide PNG of an 800-pixel-wide viewport and a 1600-pixel-wide PNG of a 1600-pixel-wide viewport are both simply wide images.
the check
Compare the capture's pixel dimensions against the page's own report of its viewport: `window.innerWidth` and `window.devicePixelRatio`. Observed with Chrome 151 headless on one 800x600 window: at --force-device-scale-factor=1 the PNG was 800x600 with devicePixelRatio 1; at 2 it was 1600x1200 with devicePixelRatio 2; at 3 it was 2400x1800. The page reported an 800 CSS-pixel viewport in all three.
cost of missing
Every coordinate derived from the image is wrong by a constant factor, and the clicks land on whatever occupies the scaled position. Because something usually does, the run continues and reports the steps it believed it took.
mitigation
Take coordinates from the DOM via getBoundingClientRect, or divide image coordinates by the scale factor the capture was made at.
generalises to
Any measurement taken in one unit system and spent in another: viewport against document coordinates, physical against logical resolution, bytes against characters.
A PDF capture renders the print stylesheet rather than the page under review
reads as
The page was captured to PDF and the PDF is legible and complete. Conclusion drawn: this is what the page looks like.
actually
PDF generation switches the media type. Puppeteer states it directly: page.pdf() 'Generates a PDF of the page with the print CSS media type', and 'To generate a PDF with the screen media type, call page.emulateMediaType('screen') before calling page.pdf()'. Every @media print rule applies and every @media screen rule does not, so navigation, sticky headers and interactive affordances are commonly stripped by design.
blind because
A PDF and a screenshot are both pictures of a page, and neither records which media type produced it.
the check
Extract the text the capture actually contains and compare it against the screen render. Observed with Chrome 151 headless on a page carrying visible text in a .screen-only element plus `@media print{.screen-only{display:none} body::after{content:'PRINT STYLES ACTIVE'}}`: --print-to-pdf produced a file whose only text-showing operators decoded to 'PRINT STYLES ACTIVE'. The words 'Screen layout' appear nowhere in it.
cost of missing
A layout is signed off against an artifact no visitor will ever see, and print rules written long beforehand, often to remove exactly the elements being reviewed, silently define the record.
generalises to
Any render whose conditions are chosen by the renderer rather than the document: print media, forced colours, reduced motion, emulated devices.
A headless capture exercises one branch of a colour-scheme fork
reads as
The screenshot shows the page correctly styled and legible throughout. Conclusion drawn: the page renders correctly.
actually
prefers-color-scheme resolves to a single value per render, and a headless browser with no desktop session reports light. Every rule inside `@media (prefers-color-scheme: dark)` was parsed, matched nothing and contributed no pixels. The dark render, which a large share of visitors receive, was never produced at all.
blind because
A screenshot is one render under one set of resolved media features. The branch that did not match leaves no trace in the image, so 'the dark theme is correct' and 'the dark theme was never evaluated' look identical.
the check
Ask the page which branch it is in, and capture both: `matchMedia('(prefers-color-scheme: dark)').matches`. Observed with Chrome 151 headless: the default run reported dark=false, light=true; the same page under --force-dark-mode reported dark=true, light=false. The page also reported prefers-reduced-motion and forced-colors as inactive by default, so those branches are unrendered for the same reason.
cost of missing
Contrast failures, unreadable text and unstyled surfaces ship in the branch nobody rendered, and remain invisible to every subsequent screenshot taken the same way.
generalises to
Every conditional whose condition is supplied by the environment: feature flags defaulting off, locale-dependent formatting, reduced-motion and forced-colours branches.
A capture taken at the load event shows the designed empty state
reads as
The screenshot shows a clean, well-styled page reading 'No results'. Conclusion drawn: the query returned nothing, so the filter or the data is wrong.
actually
The load event fires once the document and its declared subresources have loaded. It says nothing about fetches started by scripts. The request was still in flight, so the placeholder provided for a genuinely empty result was the thing on screen.
blind because
The empty state is a real, intentional, correctly styled view. A picture of 'no data yet' and a picture of 'no data at all' are the same picture, because the same markup produced both.
the check
Count the data-bearing elements at capture time instead of judging the image: `document.querySelectorAll('#list li').length`. Observed against a local endpoint delayed by two seconds: the load-event capture reported rows=0 with the empty state visible, while a capture taken after the fetch resolved reported data-rows=2 and contained `<li>alpha` and `<li>beta`. The two PNGs differed in 1,262 pixels.
cost of missing
Investigation moves to the query, the filter and the backend, none of which are broken. The moment the picture was taken is the one variable never questioned.
mitigation
Trigger the capture on an assertion about content rather than on load; a network-idle condition is weaker but still better than the load event.
generalises to
Every designed representation of absence: empty tables, zero counts, blank dashboards, 'no alerts' panels.
A capture is sized to the document, so horizontal overflow has nowhere to show
reads as
The full-page capture shows every section filling the frame, with nothing clipped at either edge and no scrollbar anywhere in the image. Conclusion drawn: the layout fits the viewport.
actually
Viewport-percentage units ignore scrollbars. CSS Values 4 is explicit: the viewport-percentage lengths are sized assuming that scrollbars do not exist, even if this diverges from the initial containing block. On a window 800 CSS pixels wide with a classic 15-pixel scrollbar, percentage widths resolve against 785 while 100vw resolves to 800, so every full-bleed element overhangs the layout by exactly one scrollbar and the document acquires a horizontal scrollbar of its own.
blind because
A screenshot has no scrollbars and no edges beyond the content. The capture is made as wide as the document's scroll width, so the overflowing strip is inside the image rather than past its edge, and the image is the same image it would be if nothing overflowed.
the check
Ask the document whether it is wider than its own viewport: `document.documentElement.scrollWidth - document.documentElement.clientWidth`. Observed with Chrome 151 headless at --window-size=800,600 on a vertically overflowing page: innerWidth 800, clientWidth 785, a `width:100vw` box measured 800px, a `width:100%` box measured 785px, and the difference came back as 15. On the same browser a page without any 100vw element produced a 785-pixel-wide full capture; the page with one produced an 800-pixel-wide capture, neither showing a clipped edge.
cost of missing
Every desktop visitor gets a horizontal scrollbar on every page, and on touch devices the page rubber-bands sideways. The defect is reported by users and cannot be reproduced from any capture, which sends the investigation to the wrong layer.
mitigation
Size full-bleed elements against the containing block rather than the viewport, or reserve the gutter with `scrollbar-gutter: stable` so the two frames of reference agree.
generalises to
Any measurement taken in a frame that excludes the thing being measured: viewport units against layout width, container queries against a resized container, timings taken inside the operation being timed.
A full-page capture ends where the renderer decided to stop rendering
reads as
The full-page capture is 2406 pixels tall, every section in it is drawn, and the article reads through to its end. Conclusion drawn: this is the whole page.
actually
The sections carry `content-visibility: auto`, which CSS Containment 2 defines as turning on layout, style and paint containment and, if the element is not relevant to the user, also skipping its contents. Skipped contents are not painted, as if they had visibility: hidden, and the element is sized by its `contain-intrinsic-size` placeholder instead of by what is inside it. Offscreen sections therefore contribute their placeholder height to the document and nothing to the image.
blind because
A capture records what was painted. A subtree that was never laid out contributes no pixels and no height, so a short document and a truncated one are the same picture: complete, continuous and ending in a plausible place.
the check
Force the skipping off and re-measure the document: `(() => { const a = document.documentElement.scrollHeight; document.querySelectorAll('*').forEach(e => e.style.contentVisibility = 'visible'); return [a, document.documentElement.scrollHeight]; })()`. Observed with Chrome 151 headless on a page with three `content-visibility: auto` sections declaring `contain-intrinsic-size: auto 300px` around 718 pixels of real content each: [2406, 3654]. Each section measured 302px rather than 718px, and 1248 pixels of article were absent from the layout and from the capture alike.
cost of missing
Content review, screenshot diffing and visual regression all operate on a document that is missing most of itself, and the missing part is the part nobody scrolled to, which is where unreviewed content accumulates.
mitigation
Set `contain-intrinsic-size` to a value close to the real height so the placeholder does not distort the document, and disable content-visibility for any automated capture.
generalises to
Every optimisation that does less work when nobody is watching: lazy loading, virtualised lists, deferred hydration, sampled tracing.
A frame the embedded site refused renders as ordinary whitespace
reads as
The capture shows the dashboard with a clean empty band where the third-party widget sits, and the DOM confirms the iframe is present with the right src. Conclusion drawn: the widget loaded and has nothing to display.
actually
The embedded document declined to be framed. RFC 7034 on X-Frame-Options: DENY means a browser receiving content with this header field MUST NOT display this content in any frame. The iframe element is still laid out at its declared size and left empty. Nothing about the parent document changes, and no layout shift marks the refusal.
blind because
An iframe reserves its box before it has any content, so a frame that was refused and a frame that rendered a blank empty state occupy the same rectangle of the same colour. Inspecting the DOM confirms the element and its src, both of which are correct; what failed is on the other side of the boundary.
the check
Ask the resource timeline what arrived rather than the DOM what exists: `performance.getEntriesByType('resource').filter(e => e.initiatorType === 'iframe').map(e => e.name + ' ' + e.transferSize)`. Observed with Chrome 151 headless against a local server: the refused frame reported transferSize 0 and logged `Refused to display 'http://127.0.0.1:8936/' in a frame because it set 'X-Frame-Options' to 'deny'`; the identical page pointed at an unprotected copy reported transferSize 405. `frames.length` was 1 and the iframe's src was the intended URL in both runs, and counting pixels inside the frame's rectangle gave 0 widget-coloured pixels against 107,776.
cost of missing
A payment form, a status board or a support widget is absent for every visitor while every capture and every DOM assertion says it is there. Because the parent page is intact, monitoring built on the parent stays green.
mitigation
Treat an embed as a dependency with its own health check: assert on the frame's load event or its resource entry, not on the presence of the element.
generalises to
Every boundary where the failure is declared by the far side and absorbed silently by the near one: blocked embeds, CORS-rejected fetches, refused redirects, sandboxed scripts.
An ancestor's overflow reassigns what a sticky element sticks to
reads as
The header is declared `position: sticky; top: 0`, the capture shows it at the top of the page, and getComputedStyle reports `sticky`. Conclusion drawn: it sticks.
actually
CSS Position 3 defines sticky as identical to relative except that its offsets are automatically adjusted in reference to the nearest ancestor scroll container's scrollport. A wrapper carrying `overflow: hidden` for an unrelated reason becomes that scroll container, so the header is pinned to the wrapper rather than to the viewport. The wrapper scrolls away with the page and takes the header with it.
blind because
Scroll offset zero is the one position at which a working and a broken sticky element are in the same place, and it is the position every capture is taken at. The computed value does not separate them either: it reads `sticky` in both cases, because the declaration won the cascade and the defect lives in an ancestor.
the check
Scroll and re-measure, rather than reading the declaration or the resolved value: `[0, 800, 2000].map(y => { scrollTo(0, y); return el.getBoundingClientRect().top; })`. Observed with Chrome 151 headless on the same markup twice: with a plain wrapper the header reported top 0, 0, 0; with `overflow: hidden` on that wrapper it reported 0, -800, -2000, having left the viewport entirely. `getComputedStyle(el).position` returned 'sticky' in both runs.
cost of missing
The navigation is unreachable on every long page, and the property is re-declared, re-prefixed and re-tested on the element while the ancestor that revoked it is never examined.
mitigation
Walk the ancestors and check for a scroll container: any ancestor whose computed overflow is not `visible` in the sticky axis is the one the element is pinned to.
generalises to
Any property whose effect is decided by an ancestor rather than by the element declaring it: sticky and fixed positioning, stacking contexts, percentage heights, transforms creating containing blocks.
A full-page capture paints a fixed element once, at the offset it was captured from
reads as
The full-page capture is 2400 pixels tall and the consent banner appears as a stripe near the top, well clear of the call to action further down. Conclusion drawn: the banner obstructs nothing.
actually
A capture beyond the viewport renders the document once; the DevTools Protocol describes captureBeyondViewport as no more than capturing the screenshot beyond the viewport. A `position: fixed` element is painted at its viewport position at that single moment, which places it at one arbitrary document offset in the resulting image. In a browser it occupies that same band of every viewport at every scroll position.
blind because
A full-page image is a picture in document space; a fixed element lives in viewport space. Flattening one onto the other destroys exactly the property that made the element worth checking, and leaves an image in which the overlay covers a small fraction of a very tall page.
the check
Ask each fixed element what share of the viewport it owns: `[...document.querySelectorAll('*')].filter(e => getComputedStyle(e).position === 'fixed').map(e => e.className + ': ' + Math.round(e.getBoundingClientRect().height) + 'px = ' + Math.round(100 * e.getBoundingClientRect().height / innerHeight) + '% of every viewport')`. Observed with Chrome 151 headless: `["banner: 180px = 39% of every viewport"]`. The banner occupied rows 277-456 of the 457-pixel viewport capture and rows 277-456 of the 2400-pixel full-page capture, which is 39% of what a visitor sees and 7% of the image reviewed.
cost of missing
A banner, chat launcher or toolbar that permanently covers the bottom third of every screen is signed off against an image in which it covers a stripe of empty page, and the obstruction is only discovered from conversion data.
mitigation
Review fixed elements from viewport captures at several scroll offsets, and use `document.elementFromPoint` at the coordinates of anything that must remain clickable.
generalises to
Every rendering that resolves a relative frame into an absolute one: fixed positioning in full-page captures, relative timestamps baked into a report, `~` expanded at write time rather than read time.