Skip to content

fix(replay): stop reserving an uncompressed frame per screenshot - #755

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/perfreplay-cut-per-screenshot-memory-in-1fb4e5
Draft

fix(replay): stop reserving an uncompressed frame per screenshot#755
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/perfreplay-cut-per-screenshot-memory-in-1fb4e5

Conversation

@posthog

@posthog posthog Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Problem

  • Session replay screenshot capture runs a device out of memory on media-heavy screens. The crash is an app crash, not a degraded feature, and it repeats at the default 1 second capture cadence.
  • Bitmap.base64 sized its output stream from the bitmap's allocationByteCount — the uncompressed frame. On a 1080x2400 screen that reserved a ~9.9 MB byte[] on the Java heap for a WEBP payload that lands in the tens of kilobytes. On a 512 MB heap, a contiguous 10 MB request every second is the allocation that fails.
  • The blast radius is wider than one app: PostHogReplayIntegration forces screenshot mode whenever the SDK is not native, so every React Native Android app on session replay runs this path with no way to opt out.

Peak Java heap per capture — 1080x2400 frame, lossy WEBP quality 30, ~40 KB payload:

Allocation Before After
Output stream buffer 9.9 MB (4 × w × h) 216 KB (w × h / 12)
Compressed byte[] copy ~40 KB none
Base64 String ~53 KB ~53 KB

Changes

  • Size the buffer from a compressed estimate instead of the uncompressed frame: a lossy frame of a UI needs well under a sixteenth of a byte per pixel, and Base64 adds a third on top.
  • Compress straight into Base64OutputStream, so the compressed payload is never held in a separate array.

The wire format is unchanged apart from a trailing newline that the streaming encoder does not always emit. That newline sits outside the Base64 payload and every decoder ignores it.

- ByteArrayOutputStream(allocationByteCount)   // 4 bytes per pixel
+ ByteArrayOutputStream(base64BufferSize())    // 1/12 byte per pixel, floor 4 KB

Note

This is the cheap half of the fix. Two larger follow-ups stay open: an optional capture downscale, and reusing the capture bitmap while the view size holds. Both change replay output or lifetime and need a device to validate. Neither is needed for this allocation, because bitmap pixels live in native memory on API 26+ while the buffer this PR shrinks is on the Java heap that throws OutOfMemoryError.

💚 How did you test it?

  • New BitmapBase64Test (Robolectric, 5 cases): the data URI matches what Base64.encodeToString produced before, for a single-line and a wrapped payload; the payload decodes back to the exact compressed bytes; the WEBP media type is right; a recycled bitmap still returns null.
  • Full :posthog-android:testDebugUnitTest suite and spotlessCheck pass.
  • Not validated on a physical device or against a live recording. The memory numbers above are computed from the buffer sizes, not measured with a profiler. A reviewer with a device should confirm a recorded session still plays back before this ships.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

  • Written by Claude Code (Opus) in a PostHog Desktop task, from an inbox report about repeated out-of-memory crashes traced to the replay screenshot path.
  • Considered and rejected for this PR: capture downscale and bitmap reuse. Bitmap pixels are allocated natively on API 26+, so they do not count against the Java heap that throws OutOfMemoryError; the oversized byte[] does. Fixing the buffer targets the reported failure, and the other two are performance work that needs device validation.
  • Also rejected: Base64.NO_WRAP, which would shrink the payload ~1.4% and produce a spec-valid data URI. It changes what is sent, which this PR should not do.
  • The estimate divisor moved from 3 to 12 during review: w × h / 3 was still 8-60x larger than real payloads. /12 keeps enough headroom that the array never grows in practice, and an under-estimate only costs one array copy.

Created with PostHog Desktop from this inbox report.

The Base64 encoder buffer in Bitmap.base64 was sized from the bitmap's
allocationByteCount, so every capture reserved a second full uncompressed
frame - about 10 MB on a 1080x2400 screen - for a payload that lands in the
tens of kilobytes. At the default 1 second capture cadence that repeated
allocation is what ran small heaps out of memory.

The buffer is now sized from a compressed estimate, and the compressed bytes
stream straight into the encoder instead of being held in an intermediate
array.

Generated-By: PostHog Desktop
Task-Id: 26fba2a7-1ba9-42fe-927c-f9daa9f1d107
@posthog

posthog Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

🦔 PostHog Review reviewed this pull request

Nothing worth raising this time, so here's a calming picture instead:

A panda relaxing and waving

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.

0 participants