Skip to content

fix(android): encode replay screenshots off the main thread#494

Open
turnipdabeets wants to merge 3 commits into
mainfrom
fix/android-replay-encode-off-main
Open

fix(android): encode replay screenshots off the main thread#494
turnipdabeets wants to merge 3 commits into
mainfrom
fix/android-replay-encode-off-main

Conversation

@turnipdabeets

Copy link
Copy Markdown
Contributor

Problem

While session replay records, every snapshot frame runs BitmapFactory.decodeByteArray (full screen-size PNG decode), a JPEG re-encode (quality 30), and Base64 synchronously on the platform main thread inside the method-channel handler — roughly 25–75ms per frame on midrange hardware, up to once per second for the duration of every recorded session. That is a rhythmic 2–4 dropped-frame hitch, most visible mid-scroll.

iOS already routes the identical work through a background serial queue (PosthogFlutterPlugin.swift even carries the comment: "otherwise we are doing slow operations in the main thread") — this brings Android to parity.

Fix

sendMetaEvent and sendFullSnapshot are submitted to a dedicated single-threaded executor:

  • Serial by design so a meta event can never be overtaken by the frame it describes (the ordering contract the Dart side relies on).
  • Submissions after engine detach hit the shut-down executor and are dropped with a warning rather than thrown.
  • No API or behavior change otherwise; timestamps still come from the SDK clock via SnapshotSender.

Evidence

Choreographer main-thread gap probe (>17ms) during an identical 20-swipe scripted scroll with replay recording, emulator (Pixel 9, Apple-silicon host — best-case CPU, so deltas understate real devices):

stalls >17ms total blocked snapshot sends
before 4 (max 33ms) 132ms 49
after 2 (max 33ms) 66ms 47

On midrange ARM devices the per-frame cost is several times larger, so the removed stall is proportionally bigger there.

Verified: ktlint clean, Android unit tests pass, replay frames flow end-to-end after the change (sends unchanged).

🤖 Generated with Claude Code

Each replay frame ran BitmapFactory.decodeByteArray plus a JPEG re-encode
and base64 synchronously in the method-channel handler on the platform
main thread - 25-75ms per frame on midrange hardware, up to once per
second while recording. iOS routes the identical work through a background
serial queue. Meta and frame sends share one single-threaded executor so
a meta event can never be overtaken by the frame it describes; submissions
after engine detach are dropped.
@turnipdabeets
turnipdabeets requested a review from a team as a code owner July 17, 2026 14:38

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was skipped because it would exceed your organization's monthly flex usage limit. Raise the limit in billing settings or wait until the next billing period resets limits.

@turnipdabeets
turnipdabeets marked this pull request as draft July 17, 2026 14:39
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-07-17 22:36:24 UTC
Duration: 96879ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 141ms
Format Validation.Event Has Uuid 120ms
Format Validation.Event Has Lib Properties 117ms
Format Validation.Distinct Id Is String 116ms
Format Validation.Token Is Present 115ms
Format Validation.Custom Properties Preserved 118ms
Format Validation.Event Has Timestamp 117ms
Retry Behavior.Retries On 503 5332ms
Retry Behavior.Does Not Retry On 400 2119ms
Retry Behavior.Does Not Retry On 401 2118ms
Retry Behavior.Respects Retry After Header 8122ms
Retry Behavior.Implements Backoff 15445ms
Retry Behavior.Retries On 500 5230ms
Retry Behavior.Retries On 502 5227ms
Retry Behavior.Retries On 504 5226ms
Retry Behavior.Max Retries Respected 15451ms
Deduplication.Generates Unique Uuids 130ms
Deduplication.Preserves Uuid On Retry 5225ms
Deduplication.Preserves Uuid And Timestamp On Retry 10336ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5232ms
Deduplication.No Duplicate Events In Batch 125ms
Deduplication.Different Events Have Different Uuids 117ms
Compression.Sends Gzip When Enabled 117ms
Batch Format.Uses Proper Batch Structure 113ms
Batch Format.Flush With No Events Sends Nothing 109ms
Batch Format.Multiple Events Batched Together 124ms
Error Handling.Does Not Retry On 403 2117ms
Error Handling.Does Not Retry On 413 2118ms
Error Handling.Retries On 408 5230ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 13ms
Request Payload.Flags Request Uses V2 Query Param 10ms
Request Payload.Flags Request Hits Flags Path Not Decide 10ms
Request Payload.Flags Request Omits Authorization Header 10ms
Request Payload.Token In Flags Body Matches Init 10ms
Request Payload.Groups Round Trip 10ms
Request Payload.Groups Default To Empty Object 10ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 11ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 10ms
Request Payload.Disable Geoip Omitted Defaults To False 10ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 12ms
Request Lifecycle.No Flags Request On Init Alone 5ms
Request Lifecycle.No Flags Request On Normal Capture 114ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 17ms
Request Lifecycle.Mock Response Value Is Returned To Caller 10ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 116ms

Covers success-before-work and the post-detach drop (a rejected
submission must never escape to the channel).
@turnipdabeets
turnipdabeets marked this pull request as ready for review July 17, 2026 17:53

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core performance diagnosis is corroborated: Android currently performs bitmap decode, JPEG re-encoding, and Base64 work on the platform thread, and this change moves it to a serial worker while preserving meta → frame ordering. The remaining comments are non-blocking follow-ups.

Human-driven, agent-assisted review.

val y = call.argument<Int>("y") ?: 0
if (imageBytes != null) {
snapshotSender.sendFullSnapshot(imageBytes, id, x, y)
submitSnapshotWork { snapshotSender.sendFullSnapshot(imageBytes, id, x, y) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Consider capturing the replay timestamp before submitting the encoding work

SnapshotSender currently reads the SDK clock after bitmap decoding and JPEG/Base64 encoding. Flutter iOS instead captures Date before dispatching to its serial queue.

At the default capture interval this should only introduce tens of milliseconds of skew, so I don’t think it needs to block this improvement. Capturing the SDK-backed timestamp here and passing it into SnapshotSender would nevertheless provide closer iOS parity and prevent larger skew if the executor ever becomes backlogged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — our pre-PR pass flagged the same thing. Fixed in a11ae47: the handler captures the SDK-clock timestamp at enqueue and passes it into SnapshotSender, matching the iOS shape.

stopOcclusionDetector()
channel.setMethodCallHandler(null)
bitmapExportExecutor.shutdown()
snapshotSendExecutor.shutdown()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reattaching the same plugin instance leaves snapshot delivery disabled

snapshotSendExecutor is created once, shut down here, and not recreated in onAttachedToEngine. If Flutter removes and later re-adds the same plugin instance, both snapshot handlers report success while their work is rejected by the terminated executor.

Generated registration normally creates a fresh instance, so this is an uncommon lifecycle edge. Consider recreating attachment-scoped executors on attach, with coverage for attach → send → detach → reattach → send.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — generated registration makes this unreachable today, but it's cheap insurance. Fixed in a11ae47: both attachment-scoped executors are recreated on attach, with the attach → send → detach → reattach → send coverage you suggested.

// Frame sends decode and re-encode the image — too slow for the main
// thread. Single-threaded so a meta event can never be overtaken by the
// frame it describes.
private val snapshotSendExecutor = Executors.newSingleThreadExecutor()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2/design] Consider whether the snapshot queue needs backpressure

This executor has an unbounded queue, while the method-channel call now succeeds immediately after submission. With the default one-second capture interval the worker has ample headroom, but a very low custom throttleDelay could enqueue full-screen image buffers faster than they are encoded.

Consider completing the channel result after worker completion, or bounding/coalescing queued frames, if very low capture intervals are intended to be supported safely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — with the result completing at enqueue, a throttleDelay below the encode time does grow the queue without bound. Fixed in a11ae47 with your first option: the reply completes after the worker finishes (posted via the main handler), restoring the Dart-side one-in-flight backpressure while keeping the encode off the main thread. E2E-verified frames still flow at the throttle cadence.

…revive executors on reattach

- Complete the channel reply after the worker finishes (posted via the
  main handler): Dart's await then spans the encode, so a throttleDelay
  below the encode time self-regulates instead of growing the queue of
  full-screen buffers without bound.
- Capture the SDK-clock timestamp in the handler and pass it into
  SnapshotSender, matching iOS and capping skew under any backlog.
- Recreate shut-down executors on engine reattach so a reused plugin
  instance does not silently drop snapshots.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants