How to add human-like delays and mouse movement in Puppeteer
If a site you are authorized to scrape keeps throttling your Puppeteer script, you are probably tripping a timing or motion check rather than a content rule. Your script clicks the instant the DOM is ready and jumps the pointer straight to a link with no movement in between, which is input no person could produce. This is a common reason a scrape that works in a hand-driven desktop browser stalls under automation, and the throttling lifts once you pace the actions.
The solution is to move the pointer along curved paths and space the actions with randomized delays, so the session reads like someone working through the page rather than a machine acting at zero milliseconds. That removes the two most obvious tells, the instant zero-duration click and the identical pause, in about 40 lines of Node.js with Puppeteer and one open-source library, ghost-cursor.
Key terms
- ghost-cursor. An open-source library that wraps Puppeteer's mouse so every move and click follows a generated path instead of teleporting to the coordinate.
- Bezier curve. A smooth curved path between two points, which ghost-cursor samples into a stream of
mousemoveevents to mimic a real pointer's approach. - Fitts's Law. The model of human pointing speed ghost-cursor uses to make the move fast in the middle and slow near the target.
- Jitter. Replacing a fixed delay with a random span so the gaps between actions vary from run to run.
navigator.webdriver. A browser flag set totrueunder automation, one of the fingerprint tells that curved motion alone does not hide.
Here is what the script does:
- Launch Puppeteer and attach a ghost-cursor instance, which generates Bezier-curve mouse paths between any two points instead of teleporting the pointer.
- Move the cursor to a target element over a curved path and click it, with the curve and speed varied per run.
- Insert randomized pauses between actions with a jittered delay helper, so the timing varies from one run to the next rather than repeating a fixed signature.
- Add small random scrolls so the page sees movement that resembles a person reading rather than a bot reading the DOM directly.
The complete script
// human-like-puppeteer.mjs
import puppeteer from 'puppeteer'
import { createCursor } from 'ghost-cursor'
/* Wait a random number of milliseconds in [min, max]. A fixed delay is itself
a fingerprint: real people never pause for exactly 1000ms twice in a row. */
const jitter = (min, max) =>
new Promise(resolve => setTimeout(resolve, min + Math.random() * (max - min)))
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
/* ghost-cursor wraps the page's mouse. Every move() and click() now travels a
Bezier curve with Fitts's Law velocity instead of jumping to the coordinate. */
const cursor = createCursor(page)
await page.goto('https://news.ycombinator.com/news', { waitUntil: 'domcontentloaded' })
/* Pause as if reading the page before touching anything. */
await jitter(800, 1800)
/* A couple of short scrolls, each followed by a reading pause. */
for (let i = 0; i < 3; i++) {
await page.mouse.wheel({ deltaY: 300 + Math.random() * 250 })
await jitter(500, 1200)
}
/* Move the cursor to the first story link over a curved path, then click. The
selector is resolved by ghost-cursor; it scrolls the element into view first. */
const firstStory = '.titleline > a'
await cursor.move(firstStory)
await jitter(200, 600)
await cursor.click(firstStory)
/* Wait for the destination to settle, then read its title. */
await page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {})
await jitter(600, 1400)
const title = await page.title()
console.log('Landed on:', title)
await browser.close()npm install puppeteer ghost-cursor
node human-like-puppeteer.mjsWhat each step does
Launch headless and wrap the page. puppeteer.launch({ headless: true }) starts Chrome with no window. createCursor(page) returns a cursor bound to that page's Mouse. From here you drive the pointer through the cursor, not through page.mouse directly, so every move carries a path.
Pause before the first interaction. await jitter(800, 1800) waits between 0.8 and 1.8 seconds before anything happens. A scraper that acts the instant the DOM is ready is acting faster than a person can read. The randomized span is the point; a fixed setTimeout(1000) would be just as detectable as no delay.
Scroll in short bursts. page.mouse.wheel({ deltaY: ... }) with a randomized deltaY, each followed by a reading pause, produces the stop-start scroll pattern of someone skimming. Three bursts is enough to look like reading without wasting time.
Move along a curve, then click. cursor.move(selector) resolves the element, scrolls it into view, and walks the pointer there over a Bezier path. The short jitter(200, 600) between move and click mimics the beat between arriving at a link and pressing it. cursor.click(selector) re-targets and presses.
Tolerate the navigation race. page.waitForNavigation(...).catch(() => {}) swallows the case where navigation already fired during the click. Without the .catch, a click that triggers an immediate same-tick navigation can reject the wait and crash the script.
Gotchas
A fixed delay is as detectable as no delay.
- Issue: Replacing instant actions with
await new Promise(r => setTimeout(r, 1000))makes every gap exactly 1000ms, which is a cleaner machine signature than random human timing. - Fix: randomize the span with a helper like
jitter(min, max)so no two waits match, and vary the bounds per action type rather than reusing one constant everywhere.
- Issue: Replacing instant actions with
ghost-cursor needs the element in the viewport.
- Issue: Calling
cursor.move(selector)on an element below the fold can land on a stale coordinate if the page reflows mid-move, so the click misses. - Fix: let ghost-cursor scroll it in (its default) and add a short
jitterafter the move, or callawait page.waitForSelector(selector, { visible: true })first so the element's box is settled before the path is computed.
- Issue: Calling
Curved mouse paths do not change your fingerprint.
- Issue: Human-like motion still ships with
navigator.webdriver === trueand the other headless Chrome tells, so a fingerprint-based blocker flags the session regardless of how the pointer moved. - Fix: treat motion and fingerprint as separate layers and add
puppeteer-extra-plugin-stealthwhen the target checks fingerprints. See How to patch headless Chrome to avoid detection.
- Issue: Human-like motion still ships with
The selector overload re-queries the DOM on every call.
- Issue:
cursor.move('.btn')followed bycursor.click('.btn')runs the selector twice, and if the node was replaced between calls the second lookup can target a different element. - Fix: resolve the handle once with
const el = await page.$('.btn')and pass it to both calls, so move and click act on the same node.
- Issue:
Wheel scrolling does not trigger lazy-load on every site.
- Issue:
page.mouse.wheelmoves the viewport but some infinite-scroll pages listen for a scroll event on a specific container, so the new content never loads. - Fix: scroll the container in the page context with
el.scrollBy(0, 400)insidepage.evaluate, or wait on the count of loaded items rather than a fixed delay.
- Issue:
Too much jitter wrecks throughput.
- Issue: Multi-second pauses on every action feel safe but turn a thousand-page crawl into a multi-hour job, and the slowness itself can look like a stuck client.
- Fix: size the delays to the site's real human pace, a few hundred milliseconds between clicks rather than seconds, and parallelize across pages instead of padding a single slow path.
Use this when
You are authorized to scrape a site that applies naive timing or motion checks, and you want your automation to pace and move the way a person would so it is not throttled for acting faster than any human could.
Skip this when
The site does not gate on input timing, in which case the plain page.click is faster and the extra motion is wasted effort; the block is fingerprint-based rather than behavioral, in which case reach for a stealth plugin instead of mouse paths; the data is served by a public API, in which case call the API rather than driving a browser; or the target's terms prohibit automated access, in which case do not scrape it at all.