Behavioral fingerprinting: mouse patterns, timing, typing

Behavioral fingerprinting: mouse patterns, timing, typing

Behavioral fingerprinting is what catches scrapers after they have fixed everything else. TLS, HTTP/2, canvas, WebGL, audio, fonts, all clean. The browser looks like Chrome, sounds like Chrome, hashes like Chrome. Then the script clicks a login button without ever moving the mouse to it, fills a form with characters typed in 4 milliseconds each, and the bot detector logs a session that no human could possibly produce. The hashes were perfect, the behavior was the giveaway.

This guide covers what behavioral fingerprinting actually measures, why simple page.click and page.fill calls in Playwright are detectable, and the patterns that produce realistic interactions. Code targets Playwright with Chromium because that is the dominant scraping browser, but the principles apply across automation stacks.

What behavioral fingerprinting measures

Modern bot-detection vendors instrument the page with JavaScript that records:

  • Mouse path: every mousemove event, with coordinates, timestamp, and pressure (where supported)
  • Mouse velocity: speed and acceleration patterns between mousemoves
  • Mouse click timing: time between mousedown and mouseup, click frequency, double-click cadence
  • Scroll patterns: scroll start/end coordinates, velocity, smoothness, deltaY values
  • Touch events: similar to mouse but for touch devices
  • Keystroke timing: dwell time per key, flight time between keys, typing rhythm
  • Focus and blur events: window focus changes, tab switches, time spent on each input
  • Page lifecycle: time-to-first-interaction, scroll-to-bottom timing, total session duration
  • Pointer events: pointertype (mouse, touch, pen), pressure, tilt
  • Sensor events on mobile: device orientation, motion, when permission is granted

Each of these is captured at high frequency (often hundreds of events per second), aggregated, and fed into a model that scores the session for likelihood-of-being-human. Real humans produce noisy, variable patterns. Default Playwright actions produce sterile, deterministic patterns that the model recognizes within seconds.

Vendors that heavily use behavioral fingerprinting in 2026:

  • DataDome (proprietary behavioral model)
  • PerimeterX / Human Security (very behavior-heavy)
  • Akamai Bot Manager (behavior is one of many signals)
  • Kasada (aggressive behavioral and challenge-based)
  • reCAPTCHA v3 (behavior-only, no challenge)
  • Cloudflare Turnstile (passive behavioral checks)

For a deeper academic background, see Anti-bot bypass: a look at modern browser fingerprinting, which surveys behavioral signals among other techniques.

What default Playwright leaks

Default page.click("button.submit") in Playwright produces:

  • One mousemove event from current position to target center
  • One mousedown at exact center of target
  • One mouseup at the same coordinates 50ms later
  • One click event

Real human clicks produce:

  • 5-30 mousemove events along a curved path
  • mousedown at a slightly off-center coordinate
  • mouseup 80-300ms later, sometimes at a slightly different coordinate (hand jitter)
  • A click at the final position

The default automation pattern is so different from human behavior that vendors can flag it from a single click. Same for page.fill("input", "username"):

  • All characters appear in input value within milliseconds
  • No keydown/keyup/keypress events fire (Playwright bypasses keyboard events for fill)
  • No focus event before, no blur event after
  • No selectionchange events

A human typing “username” produces:

  • focus event on the input
  • 8 keydown events (dwell time 50-150ms each)
  • 8 keypress events (one per character)
  • 8 keyup events
  • 7 flight times between keys (60-200ms each, with variable patterns)
  • Several selectionchange events as the cursor moves
  • blur event when leaving the field

Default page.fill produces zero of these. The fix is to use page.type (which does fire events) plus realistic timing, plus mouse movement to the field before typing.

Bypass approach 1: realistic mouse paths with Bezier curves

Replace direct page.mouse.click(x, y) calls with a path that curves toward the target, varies speed, and overshoots slightly before settling. Bezier curves are the standard approach.

import asyncio
import random
from playwright.async_api import async_playwright, Page

async def human_mouse_move(page: Page, x_target: int, y_target: int, steps: int = 25):
    """Move the mouse along a bezier curve from current position to target."""
    # Get current mouse position via injected JS
    pos = await page.evaluate(
        "() => ({ x: window.__mx || 100, y: window.__my || 100 })"
    )
    x_start, y_start = pos["x"], pos["y"]

    # Generate two random control points
    cx1 = x_start + random.randint(-100, 100)
    cy1 = y_start + random.randint(-100, 100)
    cx2 = x_target + random.randint(-100, 100)
    cy2 = y_target + random.randint(-100, 100)

    def bezier_point(t):
        x = ((1 - t) ** 3) * x_start + 3 * ((1 - t) ** 2) * t * cx1 \
            + 3 * (1 - t) * (t ** 2) * cx2 + (t ** 3) * x_target
        y = ((1 - t) ** 3) * y_start + 3 * ((1 - t) ** 2) * t * cy1 \
            + 3 * (1 - t) * (t ** 2) * cy2 + (t ** 3) * y_target
        return int(x), int(y)

    for i in range(steps + 1):
        t = i / steps
        # Add slight non-linearity to t for variable speed
        t_eased = 1 - (1 - t) ** 2
        x, y = bezier_point(t_eased)
        await page.mouse.move(x, y)
        # Track current position
        await page.evaluate(f"() => {{ window.__mx = {x}; window.__my = {y}; }}")
        # Variable delay per step
        await asyncio.sleep(random.uniform(0.005, 0.015))


async def human_click(page: Page, selector: str):
    """Click an element with realistic mouse movement, jitter, and timing."""
    box = await page.locator(selector).bounding_box()
    if not box:
        return
    # Pick a slightly random coordinate within the element
    x = int(box["x"] + box["width"] * random.uniform(0.3, 0.7))
    y = int(box["y"] + box["height"] * random.uniform(0.3, 0.7))

    await human_mouse_move(page, x, y)
    # Brief pause before click (humans pause to "aim")
    await asyncio.sleep(random.uniform(0.05, 0.2))
    await page.mouse.down()
    # Variable mousedown duration
    await asyncio.sleep(random.uniform(0.08, 0.18))
    # Slight position drift during press
    x_up = x + random.randint(-2, 2)
    y_up = y + random.randint(-2, 2)
    await page.mouse.move(x_up, y_up)
    await page.mouse.up()

This produces a mouse trace that looks like a human pointing at and clicking on the button. The Bezier path curves naturally, the speed varies, the click is slightly off-center, and the mousedown holds for 80-180ms with a tiny drift before mouseup.

Bypass approach 2: realistic keyboard timing

Replace page.fill with page.type (which does fire keyboard events) plus realistic per-character delays:

import asyncio
import random
from playwright.async_api import Page

# Average dwell and flight times by character type, in milliseconds
DWELL_BASE_MS = 80
FLIGHT_BASE_MS = 120

async def human_type(page: Page, selector: str, text: str):
    """Type text into an input with realistic per-character timing."""
    await page.locator(selector).click()  # focus the field with a real click
    await asyncio.sleep(random.uniform(0.2, 0.4))  # pause to "look at the field"

    for i, char in enumerate(text):
        # Dwell time (time key is pressed)
        dwell = DWELL_BASE_MS + random.randint(-30, 50)
        await page.keyboard.down(char)
        await asyncio.sleep(dwell / 1000)
        await page.keyboard.up(char)

        # Flight time (between keys)
        if i < len(text) - 1:
            flight = FLIGHT_BASE_MS + random.randint(-50, 100)
            # Common bigrams are faster
            if text[i:i+2] in ["th", "he", "in", "er", "an", "re"]:
                flight = int(flight * 0.7)
            # Number-letter transitions are slower
            elif text[i].isdigit() != text[i+1].isdigit():
                flight = int(flight * 1.3)
            await asyncio.sleep(flight / 1000)

    # Brief pause after typing complete
    await asyncio.sleep(random.uniform(0.3, 0.6))

This produces a keystroke trace with variable dwell and flight times that pattern-match common typing rhythms. Bigram-aware flight times (th, he, in faster than rare combinations) push the realism further.

Bypass approach 3: scroll behavior

Page scrolling is another high-resolution behavioral signal. Instant page.mouse.wheel(0, 1000) is detectable. Scroll in small increments with variable timing:

import asyncio
import random
from playwright.async_api import Page

async def human_scroll(page: Page, total_pixels: int, direction: str = "down"):
    """Scroll the page in small increments with variable timing."""
    sign = 1 if direction == "down" else -1
    remaining = total_pixels
    while remaining > 0:
        # Each "scroll wheel notch" is 100-300 pixels
        chunk = random.randint(80, 250)
        chunk = min(chunk, remaining)
        await page.mouse.wheel(0, sign * chunk)
        remaining -= chunk
        # Pause between scroll chunks
        await asyncio.sleep(random.uniform(0.1, 0.4))

    # Sometimes pause after scrolling complete to "read"
    if random.random() < 0.6:
        await asyncio.sleep(random.uniform(1.0, 3.0))

For pages with infinite scroll, alternate scroll-and-pause patterns mimic the read-then-scroll cadence of real users. For pages with discrete content, occasionally scroll back up a bit (humans often do) to add more variety.

Bypass approach 4: full session lifecycle

Beyond individual actions, behavioral fingerprinting also looks at the macro shape of a session:

  • Time from page load to first interaction (humans take 1-5 seconds, bots often interact immediately)
  • Whether the user moves the mouse before clicking
  • Whether the user reads (scrolls slowly) before submitting forms
  • Time spent on each page before navigating away
  • Tab switches and window blur events

A complete realistic session:

async def realistic_visit(page, url: str):
    await page.goto(url, wait_until="domcontentloaded")

    # Initial settle: humans don't act on the page in the first second
    await asyncio.sleep(random.uniform(1.5, 4.0))

    # Move mouse around aimlessly while "reading"
    for _ in range(random.randint(2, 5)):
        x = random.randint(200, 1200)
        y = random.randint(200, 800)
        await human_mouse_move(page, x, y, steps=15)
        await asyncio.sleep(random.uniform(0.5, 1.5))

    # Scroll partway down the page
    await human_scroll(page, random.randint(300, 800))

    # Read a bit more
    await asyncio.sleep(random.uniform(2.0, 5.0))

    # Now perform the actual scrape action (e.g., click a product)
    await human_click(page, ".product-card:first-child a")

This pattern adds 5-10 seconds per page, which slows scraping. The tradeoff is real: slower but unblocked, or faster but blocked. For high-value targets, the slowdown is worth it.

Comparison: detection difficulty by signal

signaldifficulty to spoofimpact if wrong
mouse path linearitylow (use Bezier curves)high (immediate flag)
mouse jitterlow (add per-step random)medium
click timinglow (random mousedown duration)medium
keystroke dwell timemedium (per-key timing)high
keystroke flight timemedium (bigram awareness)high
scroll smoothnesslow (chunked wheel events)medium
time-to-first-interactiontrivial (sleep)high
focus and blur eventsmedium (manage event firing)medium
pointer pressurehard (most automation lacks pressure)low for desktop, medium for mobile
sensor events on mobilehard (no real device motion)high for mobile

The high-impact, low-difficulty signals (mouse path, time-to-first-interaction, scroll patterns) should be your first targets. Pointer pressure and sensor events matter less unless you are scraping a mobile-only site.

Bypass approach 5: third-party humanization libraries

Several libraries package realistic interaction patterns into single-call helpers:

  • botright: Python library that wraps Playwright with realistic Bezier mouse paths, typing patterns, and other humanization
  • puppeteer-extra-plugin-humanize: Node.js equivalent for Puppeteer
  • playwright-extra with stealth: stealth plus humanization
  • Stagehand: AI-driven, includes realistic interaction by default
  • Browserbase: managed service with humanization built in

Using botright in Python:

from botright import Botright

async def stealth_with_human_actions(url: str):
    botright_client = await Botright(headless=True)
    browser = await botright_client.new_browser()
    page = await browser.new_page()
    await page.goto(url)

    # botright's enhanced page object includes realistic actions
    await page.mouse.click(500, 300)  # uses bezier mouse internally
    await page.keyboard.type("hello", delay=120)  # uses realistic per-key delay

    await botright_client.close()

For most teams, a stealth library plus careful selector-level humanization on the actions you care about is the right balance.

Verifying behavioral fingerprinting

Unlike TLS or canvas, behavioral fingerprinting cannot be checked against a single public site that returns a hash. The signal is captured by site-side JavaScript and only visible in the bot vendor’s backend. Practical verification:

  1. Run against a known-protected site: pick a site you know uses DataDome or PerimeterX (fingerprint.com/demo exposes some signals, ticketing sites like SeatGeek run heavy bot defenses)
  2. Compare success rate: vary your behavioral patterns and measure the resulting block rate
  3. Use shadow accounts: run the same scraping flow with realistic human behavior (recorded from a real user) versus default Playwright, compare outcomes
  4. Inspect the captured signal: use browser DevTools to inspect what the bot vendor’s JavaScript is sending in network requests; compare your scraper’s payload structure to a real user’s

For statistical sanity-checking your typing patterns, real users have a coefficient of variation in flight times around 0.3-0.5 (standard deviation divided by mean). If your scraper produces flight times with CV near 0, you are flagged.

Operational checklist

For production scrapers facing behavioral fingerprinting in 2026:

  • Replace page.click with humanized click that includes mouse movement
  • Replace page.fill with page.type plus realistic per-character delays
  • Add 1-5 second pause between page load and first interaction
  • Scroll in chunks, not all-at-once
  • Add brief pauses after each major action (read, navigate, decide)
  • Use bigram-aware typing speeds for forms
  • Pair with TLS, canvas, WebGL, audio defenses
  • Use clean residential or mobile proxies (behavioral cleanliness does not save you on a flagged IP)
  • Vary the session shape across pages (different scroll depths, different read times)
  • Avoid running multiple browser contexts from the same IP simultaneously (shared timing patterns are a flag)

Red flags that bot vendors specifically watch for

Common patterns that get sessions flagged in 2026:

  • Mouse never moves before a click
  • Mouse moves in perfectly straight lines
  • Click coordinates are dead-center on every target
  • Form fields filled with no keydown/keyup events
  • Submit button clicked within 100ms of last field fill
  • Page never scrolls below the fold but a full data extraction was performed
  • Time-to-first-interaction less than 500ms
  • Identical session shape (same actions, same timing) across multiple page loads
  • No idle time anywhere in the session
  • Tab focus never blurs (real users switch tabs)
  • viewport size is exactly default Chrome (1280×720) on every session

Avoiding all of these requires deliberate effort. Default Playwright produces several of them automatically.

Mobile-specific behavioral signals

If you are scraping mobile-targeted content, mobile-specific signals add to the surface:

  • Touch events: pointertype “touch” rather than “mouse”
  • Tap timing: time between touchstart and touchend (real taps are 50-200ms)
  • Swipe gestures: required for some mobile flows
  • DeviceMotion and DeviceOrientation events: real phones have constant low-magnitude motion noise

Spoofing mobile motion requires injecting fake DeviceMotion events at realistic frequencies (30-60Hz with small accelerometer noise). patchright includes this for mobile profiles.

For broader anti-bot patterns, see DataDome vs PerimeterX vs Akamai bot management compared and Cloudflare Turnstile bypass tactics.

When behavioral fingerprinting is the dominant signal

For some sites, behavioral signals dominate everything else:

  • Ticketing sites during high-demand drops
  • Sneaker drop sites (Snkrs, ConfirmedApp)
  • Account creation flows on social media
  • Banking and fintech logins
  • Government services (visa applications, tax filings)

For these targets, perfect TLS and clean proxies do not help if your behavior screams bot. Invest in humanization.

For other sites, behavioral signals matter less:

  • Public news scraping (no behavioral check on read)
  • Search engine results pages (some checks but mostly proxy/TLS)
  • API endpoints without browser flow
  • Static content scraping

Match your humanization investment to the target value.

Sample full session: realistic product scrape

Putting it all together for an ecommerce product scrape:

async def scrape_product(page, product_url: str):
    await page.goto(product_url, wait_until="domcontentloaded")
    await asyncio.sleep(random.uniform(2, 4))  # initial read

    # Move mouse to scroll area
    await human_mouse_move(page, 600, 400)
    await asyncio.sleep(0.5)

    # Scroll to see product details
    await human_scroll(page, 500)
    await asyncio.sleep(random.uniform(2, 5))

    # Hover over price element (realistic mouseover)
    await human_mouse_move(page, 800, 350, steps=20)
    await asyncio.sleep(0.8)

    # Read description by scrolling more
    await human_scroll(page, 400)
    await asyncio.sleep(random.uniform(3, 6))

    # Now extract data without further interaction
    title = await page.text_content("h1.product-title")
    price = await page.text_content(".price-current")
    description = await page.text_content(".product-description")

    return {"title": title, "price": price, "description": description}

This takes 8-15 seconds per product, versus 1-2 seconds for a default Playwright fetch. The slowdown is the price of unblocking. Plan throughput accordingly.

FAQ

Q: do I need to humanize behavior on every page or just on form submissions?
For PerimeterX, DataDome, Kasada targets, every page. They collect signals throughout the session. For lighter targets, only on critical actions like form submits and high-value clicks.

Q: can I record real human behavior and replay it?
You can but it is risky. Recorded behavior gets reused identically across sessions, which itself becomes a fingerprint. Better to parametrize realistic patterns (Bezier with random control points, variable typing speeds) so each session is unique.

Q: how do I know if behavioral fingerprinting is what is blocking me?
Look at when the block happens. Immediate 403 on first request: likely TLS or proxy. Block after a few minutes of activity: likely behavioral. Block after submitting a form: definitely behavioral. The timing of the block tells you which layer caught you.

Q: does adding random sleeps work?
Random sleeps help but are not enough. The shape of the behavior matters too: paths, pressure, event sequences. Random sleeps without humanized actions just slow down a still-detectable bot.

Q: are mobile sessions easier or harder to humanize than desktop?
Harder. Mobile adds touch events, sensor noise, and orientation changes that are difficult to fake convincingly. patchright handles the basics, but truly convincing mobile sessions need device emulation that few stealth libraries provide.

Q: how many mousemove events per second should a humanized session emit?
Real desktop browsing emits roughly 60-120 mousemove events per second when the cursor is in motion, dropping to zero when idle. PerimeterX flags any session that emits a constant rate above 200 events per second (suggests scripted high-resolution path) or below 20 events per second during active interaction (suggests skipped intermediates). Target a Poisson-distributed event rate around 80 per second during motion with realistic idle gaps.

Wrapping up

Behavioral fingerprinting is the layer that sorts careful scrapers from sloppy ones. Once your stack handles TLS, HTTP/2, canvas, WebGL, and audio, behavior is the last big thing to get right. Bezier mouse paths, realistic typing rhythms, chunked scrolls, and full session pacing add 5-10 seconds per page but unlock targets that defeat lighter approaches. Pair this with our TLS fingerprinting guide, canvas fingerprinting bypass, and WebGL fingerprinting bypass for the full picture, and browse the anti-detect-browsers category on DRT for related deep-dives.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
message me on telegram

Resources

Proxy Signals Podcast
Operator-level insights on mobile proxies and access infrastructure.

Multi-Account Proxies: Setup, Types, Tools & Mistakes (2026)