Version
3.61.0 (via @posthog/react-native-plugin 2.5.1 / posthog-react-native 4.66.2)
Steps to Reproduce
- 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.
- 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).
- 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):
- 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.
- 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.
- The in-source TODO:
PixelCopy.Request.Builder.ofWindow + Executor on API 34+.
- 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.
Version
3.61.0 (via @posthog/react-native-plugin 2.5.1 / posthog-react-native 4.66.2)
Steps to Reproduce
enableSessionReplay: true, defaultthrottleDelayMs(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.evemu-recordand replay it byte-identically withadb shell uinput(the kernel-path mechanism AOSP CTS uses;adb shell input swipeunder-triggers the ARR heuristics ~10× and won't reproduce).android.surfaceflinger.frametimelinedata source) and count jank types for the app's layer: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
onDrawthrough theThrottler, which re-arms onhasPendingDraw— so during continuous scrolling, captures chain at exactly 1/throttleDelayMsfor the entire gesture, by construction. Each capture is a full-windowPixelCopyreadback plus a ~16MB ARGBBitmap+ WebP encode. The burst stalls the app's frame delivery long enough thatViewRootImpl'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):
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):onTouchEventListeneralready 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.MotionEvent.obtain+executor.submit— currently every MOVE of a 120Hz scroll is copied and submitted just to be discarded on the executor.PixelCopy.Request.Builder.ofWindow+Executoron API 34+.Bitmapacross captures to remove the ~16MB/s allocation churn while scrolling.Precedent: iOS has
screenshotModeBackgroundCapturefor 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.