PostHog / PostHog/posthog-android

Screenshot capture fires mid-scroll and janks 120Hz ARR panels

Open
#750 6 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug enhancement Session Replay
Dominant language
Kotlin
Stars
90
Forks
49
Avg merge
2d 5h
Merged PRs (30d)
61

Description

Version

3.61.0 (via @posthog/react-native-plugin 2.5.1 / posthog-react-native 4.66.2)

Steps to Reproduce

  1. React Native 0.84 app (new architecture, release build) with enableSessionReplay: true, default throttleDelayMs (1000), on a physical Android 15+ device with an ARR panel (tested: Pixel 10 Pro XL, 120Hz LTPO, Display.hasArrSupport() == true). Emulators cannot reproduce this — the virtual HWC exposes one fixed 60Hz mode, so the frame-rate-override path never engages.
  2. Scroll a content screen continuously (drags + flings) for ~80s. For a deterministic repro we record a real finger session once with evemu-record and replay it byte-identically with adb shell uinput (the kernel-path mechanism AOSP CTS uses; adb shell input swipe under-triggers the ARR heuristics ~10× and won't reproduce).
  3. Capture with Perfetto (android.surfaceflinger.frametimeline data source) and count jank types for the app's layer:
select jank_type, count(*) from actual_frame_timeline_slice
where layer_name like '%<your.app>%' group by 1 order by 2 desc;

Expected Result

Scrolling stays smooth while replay records. At minimum, replay does not spend capture cost on frames it will discard: during a scroll, maskLegacyScreenshot's redraw guard rejects any capture that overlapped a non-animation redraw (unsafeRedraw), which a scroll redraw always is — so mid-gesture captures are pure cost with zero shipped frames.

Actual Result

Capture is scheduled off onDraw through the Throttler, which re-arms on hasPendingDraw — so during continuous scrolling, captures chain at exactly 1/throttleDelayMs for the entire gesture, by construction. Each capture is a full-window PixelCopy readback plus a ~16MB ARGB Bitmap + WebP encode. The burst stalls the app's frame delivery long enough that ViewRootImpl's intermittent-update detector (Android 15+) drops the frame-rate vote 120→60; when the cadence recovers it flips back. Every flip is a render-rate transition SurfaceFlinger must reschedule around, and the mis-scheduled frame stuffs the buffer queue — 200–950ms of late presentation per episode, felt as intermittent scroll stutter.

Measured (identical replayed input per arm, ~6,200 frames and ~106 render-rate switches per 80s run):

Arm Buffer Stuffing frames SurfaceFlinger Scheduling janks
Replay recording, defaults 651 33
Same app, replay not recording 0–45 (3 runs) 0–2
Replay recording + app-side "pause capture while a pointer is down" (5 runs) median 75 median 1

The third row is the proposed fix implemented app-side (stopSessionRecording() on pointer-down, startSessionRecording(true) on all-pointers-up + 1s fling debounce): jank returns to the no-replay baseline, and capture-thread activity confirms screenshots still fire between gestures — recordings stay intact because between-gesture frames are the only ones the redraw guard was keeping anyway.

Proposed fix (all hooks exist in PostHogReplayIntegration):

  1. Defer capture while a pointer is down (+ a short settle window for flings): onTouchEventListener already sees every DOWN/MOVE/UP; gate the throttled snapshot lambda on a volatile pointer-down flag and fire the deferred capture when quiet. Same instinct as 3.17.0's "do not capture screenshot during screen changes", wider.
  2. Micro-fix in the same listener: filter to DOWN/UP before MotionEvent.obtain + executor.submit — currently every MOVE of a 120Hz scroll is copied and submitted just to be discarded on the executor.
  3. The in-source TODO: PixelCopy.Request.Builder.ofWindow + Executor on API 34+.
  4. Reuse a persistent Bitmap across captures to remove the ~16MB/s allocation churn while scrolling.

Precedent: iOS has screenshotModeBackgroundCapture for exactly this symptom, and the RN docs recommend it verbatim for "janky or drops frames on high-refresh-rate (120Hz ProMotion) iPhones" (PostHog/posthog-js#3552) — Android has no equivalent. Related accepted capture-cost fixes: PostHog/posthog-ios#321, PostHog/posthog-ios#571, and this repo's 3.53.7/3.54.1/3.55.1 changelog entries. PostHog/posthog-js#3796 asks for RN wireframe mode partly for the same perf reason.

Happy to share full Perfetto traces, the replay harness, and our app-side pauser, or to re-run the A/B against a patched build.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in PostHogReplayIntegration, especially onTouchEventListener, the Throttler snapshot path, and the maskLegacyScreenshot redraw guard. Reproduce on a physical Android 15+ 120Hz ARR device with the described Perfetto query, then verify that capture is deferred during pointer activity, MOVE events are not unnecessarily submitted, and scrolling jank returns toward the no-replay baseline.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin, react-native
Domain
mobile-dev, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.