--- 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: Bezier curves are the easy part 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. ## What a real pointer event carries 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". ## The three layers, and what each can reach 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](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted). 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()`](https://playwright.dev/python/docs/api/class-mouse) 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`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/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. ## Why the curve alone is not enough, and why it still matters 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. ## The same three layers, measured on one page ![Three panels of one target page, each drawing a dot per pointer event received. A single driver move leaves two events and no path; a driver move with forty steps leaves an evenly spaced line with three distinct movementX values; the patched engine's path is unevenly spaced with twenty-six. All three report isTrusted true.](https://raw.githubusercontent.com/feder-cr/invisible_playwright/main/docs/img/mouse-path-driver-vs-engine.png) 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](hover-mouse-movement-bug.md), which turned out to consume most of it on the exact calls real scripts make. It is switched on by default and tunable: ```python 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 events ``` Honest 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](webgl-renderer-strings.md) is still a browser announcing a software GPU. ## Checking your own ```js 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: - `isTrusted` must be `true` on every event. One `false` is decisive. - `pointerType` should say `mouse` consistently. - `pressure` should be `0` while 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. ## Short answers to the questions that lead here **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](playwright-clicks-istrusted.md). **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. ## Sources - [MDN: `Event.isTrusted`](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted), for how the trusted flag is set by the browser and cannot be assigned from page script. - [MDN: `PointerEvent.pointerType`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType), for the field a naive synthetic event usually gets wrong. - Playwright's documented [`Mouse.move()`](https://playwright.dev/python/docs/api/class-mouse), 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](playwright-detected-as-bot.md), where behaviour is step five, and [three ways to make Playwright undetected](playwright-stealth-levels.md), which is the same layering argument applied to the fingerprint instead of the pointer. --- *From the notes of [invisible_playwright](https://github.com/feder-cr/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.*