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. We'll build a small script that wraps Puppeteer's mouse with ghost-cursor so every move follows a curved path instead of teleporting to the coordinate, clicks a target element with the curve and speed varied per run, spaces the actions with a jittered delay helper so the timing never repeats a fixed signature, and adds small random scrolls so the page sees movement that resembles a person reading. 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.
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
curved path, fast in the middle and slow near the target, 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.mjsHow it works
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. Size these delays to the site's real human pace, a few hundred milliseconds between clicks rather than seconds, since multi-second pauses on every action turn a thousand-page crawl into a multi-hour job and can themselves look like a stuck client; parallelize across pages instead of padding a single slow path.
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. Note that some infinite-scroll pages listen for a scroll event on a specific container rather than the viewport, so the new content never loads from page.mouse.wheel; for those, scroll the container in the page context with el.scrollBy(0, 400) inside page.evaluate, and wait on the count of loaded items rather than a fixed delay.
Move along a curve, then click. cursor.move(selector) resolves the element, scrolls it into view, and walks the pointer there over a curved 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. An element below the fold can land on a stale coordinate if the page reflows mid-move, so call await page.waitForSelector(selector, { visible: true }) first to settle its box before the path is computed. The selector overload also re-queries the DOM on each call, so if the node is replaced between move and click the second lookup can target a different element; resolve the handle once with const el = await page.$(selector) and pass it to both calls so they act on the same node.
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.
Treat motion and fingerprint as separate layers. Curved paths do not change your fingerprint: human-like motion still ships with navigator.webdriver === true, a flag set under automation, and the other headless Chrome tells, so a fingerprint-based blocker flags the session regardless of how the pointer moved. When the target checks fingerprints, add puppeteer-extra-plugin-stealth alongside the motion work.
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.