HomeLearn → Headless browser detection

Headless browser detection

Almost everything written about headless browsers is about being caught: navigator.webdriver, the missing plugin array, HeadlessChrome sitting in the user agent. All of it is real, and all of it is patchable. It also misses the more useful problem. A headless browser is not a normal browser wearing a disguise — it is a browser with the screen removed, and a surprising amount of the web quietly stops working when you take the screen away. You do not get flagged. You get a different page.

What headless actually removes

Headless mode is not "Chrome with the window hidden". There is no window, and several things hang off the existence of one:

None of that is a detection feature. It is the consequence of the mode. But the outcome is indistinguishable from detection, because the page you end up holding is not the page a person would have been given.

The measurement

We build RPA processes for Reddit, so this got measured rather than argued about. Same machine, same profile, same signed-in account, same network connection, same fingerprint. The only variable was whether a window existed.

HeadlessWith a window
Posts in the DOM per feed 3 25–27
"Load more" on reaching the bottom Never fired Fired
Scrollable page height ~1800px, then oscillates Grows as you read
scrollBy({behavior:'smooth'}) 0px, on 10 attempts out of 10 Works

Three posts. Checked across r/popular, r/programming and r/AskReddit/top, and confirmed by sitting at the bottom of a feed through six rounds of waiting with no growth at all.

We cannot tell you whether Reddit is deliberately serving less to a headless client or whether pagination simply never triggers because the observers it hangs on never fire. It does not change what to do about it. Either way, an automation built to browse a feed spends two minutes on three posts and believes it read the front page.

It is not really about headless

Further measurement sharpened this, and the sharper version is more useful. The signal the page reacts to is document.visibilityState. Headless is always hidden — but so is a perfectly ordinary window whose tab is not the one in front.

On one signed-in profile, within the same minute: with the tab in the background the feed sat at 2,794px and three posts however far it was scrolled; with the same tab brought to the front it reached 20,611px and twenty-eight posts the moment scrolling began.

So "run it with a window" is not the whole instruction. The window has to be showing the page you are automating. An automation that opens a real browser and then works in a background tab, or minimises it to stay out of the way, gets exactly the degraded page it was trying to avoid — and reports success while doing it.

The scrolling that does not scroll

The last row deserves its own section, because it is the kind of bug that never announces itself. window.scrollBy({top: 400, behavior: 'smooth'}) moves zero pixels in headless Chromium — measured at 0px on ten consecutive attempts. No error, no warning, no rejected promise. The call returns and the page has not moved.

Smooth scrolling is an animation, and an animation needs frames that are being presented. Without a window it never advances. So every "human-like scrolling" helper built on behavior:'smooth' — and there are a great many of them, in every language — does nothing at all the moment it runs on a schedule instead of on your desk.

Our own first version had a worse variant of the same problem: it counted the distances it asked for and cheerfully logged 3993px of scrolling on a page that had not moved a pixel. The log said the session went well. That is the failure mode to watch for — not a crash, but a clean report about something that did not happen. The fix was to build motion out of short runs of small instant scrolls, roughly 22px a frame, and to read the distance back from window.scrollY rather than trusting the request.

Why patching the flags does not cover this

The standard hardening list is worth doing: strip HeadlessChrome from the user agent, remove navigator.webdriver, populate plugins and languages, make window.chrome present. It clears the cheap checks.

It does not put the posts back. You can make a headless browser claim anything you like about itself and the feed still holds three items, the deferred images still never load, and the scroll animation still does not run. Those are not properties you assert. They are what the page did.

And they leave a trail of their own on the server side, which is the part that gets missed. A session that loads a feed, fires no further pagination requests, loads none of the deferred images and leaves after two minutes is a strange session — not because of any single value in a fingerprint, but because of its shape. You hid the flag and produced an anomaly further down the stack, in the same way that deleting RTCPeerConnection trades a leak for a signature.

When headless is the right answer anyway

This is not an argument against headless. It is fast, it is cheap, it does not steal your keyboard focus, and for a great deal of work the missing screen costs you nothing:

The line is roughly this: if the job is fetch a fact, headless is fine and often better. If the job is look like somebody using the site — browsing, dwelling, scrolling, voting, anything whose whole point is the behaviour rather than the data — then the window matters more than anything inside your script. It is the difference between reading three posts and reading twenty-five.

It is also why our Reddit browsing process visits six subreddits instead of scrolling one feed forever. A real feed runs out of page long before it runs out of time.

How Parallel handles it

Scheduled runs are headless by default, because most scheduled work is the fetch-a-fact kind and nobody wants windows opening on their machine all day. Any schedule can opt out: RPA → Schedules → Run visibly launches a real window for that schedule's runs, with the profile's proxy and fingerprint unchanged.

Because of the visibility finding above, a process that depends on the page behaving normally should also bring its tab to the front as its first step — our Reddit warmup template does, and it is the difference between three posts and twenty-eight.

Runs you start from the editor, and runs an assistant starts over MCP, are visible already — you are watching them happen.

The toggle exists because the right answer genuinely depends on the process, and choosing on your behalf gets it wrong half the time. A process that reads an inbox should be headless. A process meant to pass for a person reading Reddit should not be.

Related reading

Automation that runs in a real browser

Parallel runs every profile as actual Chromium with its own fingerprint and proxy — headless when that is the efficient choice, visible when the point is to look like a person. Three profiles free, forever.

Download for Windows