| title | Human-like mouse movement: Bezier curves are the easy part |
|---|---|
| description | Human-like mouse movement needs more than a Bezier curve: every pointer event carries fields saying where it came from, and a perfect curve can still fail. |
| parent | The Automation Layer |
| grand_parent | Guides |
| nav_order | 5 |
Human-like mouse movement takes two things, not one: a natural path, and pointer events whose fields match a real device. The curve is the easy half. A convincing arc delivered as events that report, in plain fields any page can read, that no hand produced them still fails on the cheapest possible check.
Search for this and you get a dozen libraries that draw a nice curve between two points, with jitter, overshoot and a bell-shaped velocity profile. They are not wrong. They are solving the visible half of the problem.
The half nobody mentions is that a path is made of events, and an event carries fields that say where it came from. You can draw a perfect human arc and still deliver it as a hundred events that no hand ever produced.
Move a real mouse and each event arrives with more than coordinates:
| Field | On a real mouse | On a naive synthetic event |
|---|---|---|
isTrusted |
true |
false if dispatched from page JavaScript |
pointerType |
"mouse" |
often absent, or "pen" / "touch" by accident |
pressure |
0 when hovering, 0.5 while a button is down |
frequently 0 always, or 1 always |
buttons |
a bitmask consistent with the sequence | often inconsistent across down, move and up |
movementX / movementY |
the delta the OS reported | zero, or recomputed and not matching |
| timestamps | irregular, tied to the input device's rate | perfectly spaced |
A page can read every one of these. Checking isTrusted alone is one line, and it
separates "the driver moved the pointer" from "a script pretended to".
Mouse movement can be produced at three layers, and each reaches less of the event than the one below it: page JavaScript can shape the path but never the trusted flag, the automation driver produces trusted events but not all of their content, and only the browser's own input path decides every field the way a real device would.
Page JavaScript. element.dispatchEvent(new MouseEvent('mousemove', ...)). The
path can be as human as you like and the events are isTrusted: false, permanently,
because the flag is set by the browser and is not writable by page script.
This is the layer most "humanize" snippets on the web operate at, and it fails the
cheapest possible check.
The automation driver. page.mouse.move()
in Playwright, and the equivalents elsewhere. These produce genuinely trusted events,
because the browser generates them internally rather than the page. This is a real
improvement and it is where the Bezier libraries plug in: they call mouse.move()
many times along a curve instead of once at the destination.
What the driver does not fix by itself is the content of those events. Whether
pressure changes when a button goes down, whether pointerType
is set, whether the timing between events looks like a device rather than a loop, all
depend on what the driver fills in.
The browser's own input path. Values decided where the browser builds the event.
At this level pressure, pointerType and the trusted flag are simply what a real
device would have produced, because they are produced in the same place.
Be fair to the curve libraries: straight-line teleporting is a real tell and they fix
it. A pointer that jumps from (0,0) to a button and clicks, with no intermediate
events at all, is trivially distinguishable from a hand.
But consider what a behavioural check can compare, once you have a curve:
- Event rate. A real mouse reports at a device-determined rate, so intervals
cluster and jitter around it. A loop with
await sleep(10)produces a distribution no hardware makes. - Sub-pixel behaviour. Real movement produces fractional coordinates on scaled displays. Integer-only paths are a signature.
- What happens between actions. Humans move while reading, drift, and overshoot targets they are not going to click. Automation usually moves only when it needs to arrive somewhere.
- The idle case. A session with no pointer events at all before a click is louder than any curve shape.
So the curve is necessary and not sufficient, which is the same shape as every other part of this subject.
One page served from 127.0.0.1, one target, three ways of reaching it. The page draws
a dot per pointermove and prints the fields it actually received, so the picture and
the numbers come from the same run.
The first thing the panels settle is a negative: isTrusted is true in all three,
and so isTrusted does not separate the driver from the engine. It only separates
both of them from page script. A check that stops at the trusted flag has not
distinguished anything here.
What does separate them is the deltas. The evenly stepped driver path produces three
distinct movementX values across 41 events, because a straight line walked in equal
steps reports the same delta almost every time. The engine's path produces 26 distinct
values across 34 events. Same trusted flag, same pointerType, same pressure, and a
completely different distribution underneath.
And notice what the third panel is not: it is not a graceful arc. The dots stay roughly on the line to the target, clustered at the start and irregular the rest of the way. That is the point of this page in one image. The shape was never the hard part.
Two pieces, and the split is deliberate.
The path is drawn in the driver from the session seed, so the same seed produces the same motion and a failure is reproducible. Every click, hover and drag arcs rather than teleporting, with the timing spread rather than uniform.
The event fields are set in the browser's own input handling, so pointerType,
pressure and the trusted flag are what a real device produces rather than what a
synthesiser guessed. That part is a source patch and it is the reason the feature is
not simply a library you could add on top.
Getting the path itself right was not the end of it, either - the path also has to survive the driver's own hit-target check, which turned out to consume most of it on the exact calls real scripts make.
It is switched on by default and tunable:
with InvisiblePlaywright(seed=42, humanize=True) as browser:
page = browser.new_page()
page.goto("https://example.com")
page.click("#submit") # arcs to the button, with device-shaped eventsHonest limit, since this page is about what things do not fix: humanised motion is a behavioural signal and it does nothing for the machine underneath. A convincing pointer on a browser announcing a software GPU is still a browser announcing a software GPU.
document.addEventListener('mousemove', e => {
console.log(e.isTrusted, e.pointerType, e.pressure, e.movementX, e.timeStamp);
}, { once: false });Drive your automation across the page with that attached and read the stream:
isTrustedmust betrueon every event. Onefalseis decisive.pointerTypeshould saymouseconsistently.pressureshould be0while hovering and non-zero between a down and an up.- The gaps between timestamps should vary. Identical gaps are a loop, not a hand.
- Coordinates should not advance by a constant step.
Then do the same in a browser you drive by hand and compare the two streams. That comparison tells you more than any single field.
Does moving the mouse along a Bezier curve avoid bot detection? It removes the teleporting-cursor tell, which is real. It does not change what the events say about where they came from.
Is page.mouse.move() detectable? The events it produces are trusted, so the
cheapest check passes. What can still stand out is their timing and their fields.
Why is isTrusted false in my script? Because you dispatched the event from page
JavaScript. That flag is set by the browser and cannot be assigned, which is
why a JS-dispatched click can never be trusted while a driver-level one is.
Do I need a humanisation library? If your tool teleports the pointer, yes, or something equivalent. If it already draws paths, the next thing worth checking is the event fields rather than a better curve.
Does any of this matter if the site does not watch behaviour? No. Check whether the block arrives at the first request or only after an interaction. Late blocks point here; immediate ones point at the fingerprint or the address.
Should the movement be random every run? No. Random per run means a failing run cannot be reproduced. Derive it from a seed so the motion is varied between identities and identical when you replay one.
- MDN:
Event.isTrusted, for how the trusted flag is set by the browser and cannot be assigned from page script. - MDN:
PointerEvent.pointerType, for the field a naive synthetic event usually gets wrong. - Playwright's documented
Mouse.move(), the driver-level call every Bezier-curve humanizer plugs into. - This project's own source patch to the browser's input handling, and its own detection gates comparing the resulting event fields against a real device.
See also: the checklist for being detected on one site, where behaviour is step five, and three ways to make Playwright undetected, which is the same layering argument applied to the fingerprint instead of the pointer.
From the notes of invisible_playwright, a Firefox patched at the C++ level. The event fields are set in the engine, which is why they are not a library.
