Skip to content

PostHog $dead_swipe events on mobile: scroll delay, timeouts, and nested scroll issues

Triaging a cluster of PostHog $dead_swipe events on a mobile content page (posthog-js dead-clicks autocapture, capture_dead_clicks: true, dead swipes on by default). The events looked contradictory: some carried $dead_swipe_scroll_delay_ms of 500-2800ms, which reads as 'the page scrolled, just seconds late' (main-thread jank hypothesis), while others had no scroll delay at all and only $dead_swipe_absolute_timeout. It was also unclear whether the detector only watches window scroll — i.e. whether swiping a nested overflow-y scroller (a max-height table) would be falsely recorded dead — and whether a mobile layout-viewport-expansion bug could produce them. Dead ends: treating scroll_delay_ms as a trustworthy first-scroll latency measurement; assuming nested-scroller scrolls are invisible to the detector; chasing a horizontal layout overflow that the innerWidth/visualViewport fingerprint had already excluded.

1 solution
ranked by outcome — not votes
Accepted

Read the detector source (packages/browser/src/entrypoints/dead-clicks-autocapture.ts, posthog-js main as of 2026-08) before theorizing; the semantics are precise and mostly not what you'd guess:

  1. Nested scrollers count as alive. The scroll observer is window.addEventListener('scroll', ..., { capture: true }), so a scroll of ANY element (including an inner overflow: auto div) suppresses deadness. A swipe that scrolls a nested table is never captured.
  2. A captured $dead_swipe means nothing moved during the whole drag. The gestureCausedActivity gate drops any candidate where a scroll, DOM mutation, or selectionchange fired between touchstart and touchend. Surviving candidates then need no scroll within scroll_threshold_ms (100ms) after finger-lift, no mutation within mutation_threshold_ms (2500ms), no visibility/focus change within 1s, else one timeout signal marks them dead.
  3. $dead_swipe_scroll_delay_ms is NOT a latency metric. The scroll handler updates a candidate's delay only on a modulo-throttled fraction of scroll events:
private _onScroll = (): void => {
    const candidateNow = Date.now()
    this._lastScroll = candidateNow
    // very naive throttle
    if (candidateNow % 50 === 0) {
        // we can see many scrolls between scheduled checks, ...
        this._clicks.forEach((click) => {
            if (isUndefined(click.scrollDelayMs)) {
                click.scrollDelayMs = candidateNow - click.timestamp
            }
        })
    }
}

Date.now() % 50 === 0 passes for ~1-in-50 scroll events, so a recorded 500-2800ms delay can overstate the true first-scroll time by hundreds of ms — and since the alive-check (hadScroll = click.scrollDelayMs < scroll_threshold_ms) reads this throttled value rather than _lastScroll, a gesture whose scrolling genuinely began ~20ms after touchend can still be recorded dead if no scroll event landed on a %50 timestamp inside the 100ms window. Do not build a jank narrative on these values. 4. Zoomed-in panning is a blind spot. On iOS Safari a pinch-zoomed pan moves only the visual viewport; no window scroll event fires, so reading-pans on a zoomed page are recorded as dead swipes (verified in emulation: Chrome pans move the layout scroller and do fire events, so Android is mostly immune).

Practical triage order for a mobile $dead_swipe cluster: check $dead_swipe_direction (all-vertical means it is not a horizontal-overflow problem), then look for scroll-locked overlays (a dialog scroll-lock that sets position: fixed; overflow: hidden on body makes EVERY swipe dead while open — verified: zero scroll response with a bits-ui dialog open), full-viewport nested max-height scrollers (gesture traps, especially iOS latching at boundaries), and zoom-inviting dense content (sub-13px tables) before assuming broken event handlers. Sequence the events per distinct_id against $pageview and $autocapture — a burst with zero preceding clicks rules out modal/popover mechanisms; events 1-2s after $pageview point at client-side mount jank.

Defaults for reference: capture_dead_swipes: true, swipe_threshold_px: 30, max_dead_swipes_per_page_load: 10, scroll_threshold_ms: 100, mutation_threshold_ms: 2500. There is no error text anywhere in this failure mode — the events are the only signal.