I was rebuilding a WordPress theme and running it on localhost. The iteration loop was fast, so I set up a visual verification pipeline: finish a section, take a screenshot, look at it, move on. The primary path was a real browser driven by Playwright. When that path got fussy, I had a fallback: invoke Chrome directly in headless mode from the command line.
One afternoon that fallback started handing me images that stopped my heart for a second. The homepage was black. Not the empty black of a page that failed to render, but a layered black, clearly a dark overlay sitting on top of the entire viewport. My first reaction was exactly the one you are not supposed to have: I opened the theme files and started hunting for what I had broken.
The symptom only existed in one place
Before changing anything, I loaded the same page in a normal browser. It looked fine. A dark intro loader appeared for about a second, faded out, and the hero showed up the way it was designed to. I reloaded a few times, hard refreshed, tried a clean profile. Fine every time.
So the situation was: one page, two ways of looking at it, two completely different results.
- Real browser through Playwright: clean, loader gone, hero visible.
- Headless Chrome from the command line: dark overlay covering everything, permanently.
That gap is as much of a clue as the failure itself. A symptom that refuses to travel to a real browser is a property of the tool that captured it, not a property of the site.
The root cause: load waits on the video, and the video never finishes
The theme's intro loader was doing something extremely ordinary:
window.addEventListener("load", () => {
document.body.classList.add("is-loaded"); // loader fades out
});The overlay starts at opacity: 1 and only drops to 0 once that class lands. Everything hangs off a single signal: the load event on window.
And that is the trap. load does not mean "the DOM is ready". That is DOMContentLoaded. load only fires once every subresource on the page is considered done, including images, iframes, and media. The hero section on that page had an autoplaying background <video>.
In a real browser the video behaves normally, the page is considered complete, load fires, the loader fades. In headless Chrome the media pipeline does not behave the same way. The video never reaches the point that lets the page be considered done. load never fires. The is-loaded class never lands. The overlay stays at opacity: 1 until the headless time budget runs out, and then Chrome photographs exactly that state: a dark screen.
The site was fine. What I had captured was a state that essentially no visitor ever experiences. That is a headless artifact, not a bug.
Which means the fix was not in the code either. For pages with a hero video I went back to the real browser path, and it captured cleanly: loader gone, hero visible, without a single line changed in the theme. The headless fallback stayed in the toolbox, but for pages whose appearance does not depend on heavy media.
The broader lesson underneath it: window load is a far more fragile signal than it looks, because it ties itself to the slowest resource on the page. One large media file that stalls is enough to hold the whole event hostage.
The headless command I settled on
The fallback was still worth keeping, so I tidied the invocation up to make the output consistent:
& "C:\Program Files\Google\Chrome\Application\chrome.exe" `
--headless=new `
--hide-scrollbars `
--no-sandbox `
--user-data-dir="C:\tmp\cshotN" `
--window-size=1440,1600 `
--virtual-time-budget=9000 `
--screenshot="C:\tmp\out.png" `
URLA few of those flags are load bearing rather than decorative:
--hide-scrollbarsremoves the scrollbar that otherwise gets baked into the image and shifts your layout width by a few pixels.--user-data-dirpoints at a fresh directory each run, so no leftover profile state leaks into the result.--virtual-time-budget=9000gives the page room to finish animations and fetches before the shot is taken. It is also the deadline that eventually fires whenloadnever arrives.
For GPU handling the rule splits in two:
- Pages that use WebGL: add
--use-angle=swiftshader --enable-unsafe-swiftshaderso the canvas still renders through a software renderer. - Ordinary pages with no WebGL: just add
--disable-gpu.
A second trap: scroll-reveal pages capture as empty
The same failure mode came back wearing a different costume. I had a handful of static HTML mockups for a design presentation, and their full page screenshots came back nearly blank. The sections were there, the heights were right, the content was not visible.
The cause is in the same family. Those mockups use scroll-reveal: elements start at opacity: 0 and only animate in once they get scrolled into view. A full page capture expands the captured area, but it never actually scrolls the page. So what gets recorded is the initial state, with every element still transparent.
The fix is not to change the design, it is to inject a temporary override before capturing: kill every transition and animation, then force the reveal elements into their final state.
/* temporary, only while capturing */
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
}
[data-reveal],
.reveal,
.fade-up {
opacity: 1 !important;
transform: none !important;
}Killing transition is essential, not garnish. If the transitions are still live, forcing opacity to 1 just starts a fresh animation, and the screenshot can land mid fade with everything half faded. Kill first, force second, and the result is deterministic.
What I took away
- If an anomaly only appears in your automation tool and disappears in a real browser, do not patch the site yet. Prove whether it is a bug or an artifact first.
- A loader that waits for
windowloaddies alongside the slowest resource on the page. Background video is the most frequent candidate. - A headless screenshot is not a photo of the user experience. It is a photo of the page at one artificial point in time, with a different media and GPU pipeline behind it.
- Before capturing a page whose animations are scroll triggered, disable transitions and force the end state. The capture never scrolls on your behalf.
The expensive part here was not the time. It was how close I came to shipping a "fix" for a problem no visitor ever had. If I had trusted that first screenshot, I would most likely have torn apart a perfectly healthy loader just to satisfy a browser that could never play the video in the first place.