Flutter Session Replay: See What the User Did
How TracePath records the ten seconds before a Flutter exception: a RepaintBoundary ring buffer, MP4 encoded only on error, and widget-level masking.
Flutter session replay is a short recording of the app's own rendered frames, captured continuously into a fixed-size buffer and kept only when something goes wrong. TracePath's Flutter SDK holds roughly the last ten seconds in memory, and when an exception reaches one of Dart's error hooks it encodes that window to MP4 and ships it with the stack trace. The dashboard then plays the clip next to the error, so the question stops being "what does this null check mean" and becomes "the user tapped Pay twice."

Key takeaways
- Capture uses Flutter's own
RepaintBoundary, not a platform screen-recording API, so it needs no new OS permission and never sees anything outside your app. - Frames land in a ring buffer of 150 slots at 15 fps. Steady-state cost is a timer, a screenshot and a buffer write.
- Encoding happens only when an exception fires, which is why continuous CPU cost stays near zero.
- The same payload carries a rolling log and action timeline, so the video has a synchronized list of navigations, HTTP calls and custom events beside it.
- Sensitive widgets are masked at capture time with
TracePathPrivacyMask; the pixels never leave the device unmasked.
Why screen recording was the wrong primitive
The obvious implementation is to record the screen and trim the last ten seconds. Every serious route to that turns out to break a requirement.
Native screen-capture APIs, reached through platform channels, capture the display, not the app. That means a permission prompt on both platforms, a system recording indicator, elevated privileges, and no access to the Flutter widget tree — which is exactly where you need to be if you want to redact a credit-card field rather than a rectangle of pixels you guessed at.
FFmpeg on device has the same permission problem plus a much worse power profile. Continuous encode is the single most expensive thing you can ask a phone to do in the background, and it is being asked to run permanently for an event that happens rarely.
Raw RGBA frames are the trap in the middle. RepaintBoundary.toImage() will happily hand you uncompressed pixel buffers, and at ten seconds of full-resolution frames that is hundreds of megabytes of live heap. On a device with 600 MB of usable RAM you are not building observability, you are building an OOM kill.

So the requirements came out as a list, and everything above failed at least one line of it: capture a meaningful window before the exception, attach it to the trace that carries the stack, play it in a browser, do not degrade frame time, stay cheap at scale, add no permissions, and work across iOS, Android and macOS.
The architecture
Four layers: capture, buffer, encode, sync. During normal operation only the first two are running.
Capture. TracePath.run() wraps your app in a RepaintBoundary with a Listener inside it. A timer calls toImage() on the boundary at the configured frame rate — every ~67 ms at the default 15 fps — converts the result to PNG bytes, and pairs it with the current touch position and any active mask regions.
RepaintBoundary(
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: widget.child,
),
);The Listener is there because RepaintBoundary captures what the engine rendered, and the engine never renders your finger. Touch positions are drawn onto the captured frame afterwards as overlays, which is why they show up in the replay but are invisible in the live app.
Buffer. Frames go into a circular buffer of maxBufferFrames slots, 150 by default. When it is full the oldest frame is silently overwritten. Nothing grows, nothing is scanned, nothing is flushed. That bounded overwrite is the whole reason the steady-state cost is predictable: at 15 fps with a 150-slot buffer you always hold ten seconds and never eleven.
Encode. Three hooks funnel errors into the client — FlutterError.onError for framework errors, PlatformDispatcher.onError for platform-channel errors, and Dart's Zone mechanism for uncaught async errors. On any of them the recorder drains the buffer, decodes each PNG back to pixels, applies mask regions and touch indicators, and feeds frames one at a time into an H.264 encoder. An encoding lock means one encode at a time, so a burst of five exceptions does not stack five encoders on top of each other. Only one raw frame is ever live in memory during the pass.
Sync. The encoded MP4 is attached to the exception payload. A debounce window (debounceMs, 1500 by default) batches concurrent exceptions, the payload is compressed and posted, and on success the local files are deleted. On failure it retries after retryDelayMs. With no network the files sit on disk under the maxLocalFiles and localFileMaxAgeHours caps (5 files, 12 hours by default) and go out when connectivity returns.
The two constants that make it cheap
Two defaults carry most of the cost profile, and both are tunable.
capturePixelRatio is 0.75 and fps is 15. Neither is a quality compromise in the way it first sounds. You are not reviewing an animation curve, you are reconstructing a sequence of user intentions, and 15 fps at three-quarter resolution is more than enough to read a tap, a scroll and a sheet dismissal.
The payoff is in the arithmetic. A PNG of a typical app screen is mostly flat colour and compresses hard, so a frame lands in the tens of kilobytes rather than the megabytes that raw RGBA would cost. One hundred and fifty of those is single-digit megabytes of resident buffer — bounded, constant, and reclaimed as it rotates.
The encoded output is small for the same structural reason, from the other direction: consecutive frames of a UI are nearly identical, which is the exact case inter-frame video compression was designed for. A ten-second clip of an app screen lands in the low hundreds of kilobytes.
CPU: encode only on exception
This is the load-bearing design decision. Continuous encoding is what makes every "just record the screen" approach unusable, so the SDK does not do it. During normal operation the app takes a screenshot on a timer and writes PNG bytes into a slot. The H.264 encoder runs exactly when an exception has already happened — a moment at which the user is, by definition, no longer having a smooth experience.
That does mean encode cost is concentrated rather than eliminated. In the benchmark harness the exception-burst scenarios are the only ones where jank counts move at all, rising up to roughly 3x on the lowest device tier while encoding runs. Normal operation does not move them.
Logs and actions on the same timeline
The video is the part people notice, but the payload carries two more rolling buffers, each capped at eventsMaxCount (200) over an eventsWindow (10 seconds):
- Logs — every
printanddebugPrintline, mirrored through a Zone print override. (dart:developer.logdoes not flow through that hook and is not captured.) - Actions — three kinds. Network: every
dart:ioHTTP call with method, URL, status, duration and byte counts, picked up throughHttpOverrides.global, so it catchespackage:http, Dio, Firebase and anything else on the same client. Navigation: push, pop, replace and remove transitions fromTracePath.navigatorObserver. Custom: whatever you record yourself.
TracePath.recordAction(
category: 'cart',
name: 'add_item',
data: {'sku': 'SKU-123', 'qty': 2},
);Each recording carries startedAt and endedAt, taken from the first and last buffered frames, so the backend can place every log line and action at an offset into the video rather than in a separate list you have to correlate by hand.
Privacy and redaction
Masking happens at capture time, on the frame, before anything is persisted or sent. Wrap the widget:
TracePathPrivacyMask(
child: Text('4242 4242 4242 4242'),
)
TracePathPrivacyMask(
mode: TracePathMaskMode.blank(color: Color(0xFF000000)),
child: SensitiveDataWidget(),
)Blur pixelates the region with a tunable ratio; blank replaces it with a solid fill. Either way the live app is visually unchanged — the mask exists only in the recorded frames.

The stronger privacy property is structural, and it is the reason the RepaintBoundary route was worth the work: the SDK can only ever see what your own app rendered. There is no screen-capture permission to grant, no system recording indicator, and no possible path to another app's content or a notification shade.
What the measurements actually say
The SDK ships an integration-test harness that runs each scenario — idle rendering, list scrolling, page navigation, video playback, plus a burst variant of each that fires five exceptions in quick succession — under four configurations: no SDK, error capture only, capture at 15 fps, and the full stack with disk persistence. Frame timing comes from Flutter's FrameTiming callback, memory from ProcessInfo.currentRss and peak RSS.
The results quoted here are the Android tiers, run on Firebase Test Lab across a Pixel 5 (API 30), a Pixel 6 (API 33) and a Pixel 8 (API 34):
- No frame-time regression at p50 on any tier with capture enabled. The capture timer does not compete with the render pipeline.
- Steady-state idle memory overhead from the capture loop of roughly 14–29 MB. Other scenarios vary with GC timing.
- Jank counts flat during normal operation, rising during exception bursts as described above.
- Worst-case peak RSS in the harness, an exception burst on the Pixel 8, stayed under 70 MB above baseline.
The gaps are worth naming too. The harness does not yet automate encoding wall-clock time, upload latency, or per-session cost. Beta customers can ask for the raw harness output for a given build.
Getting it
The Flutter SDK is in private beta and is not published to pub.dev — flutter pub add tracepath will not resolve today. Email [email protected] for access and setup instructions.
Once you have the package, integration is one wrapper:
TracePath.run(
connectionString: 'your-token@https://ingest.tracepath.dev/api/report',
options: TracePathOptions(
screenCapture: true,
version: '1.0.0',
),
child: MyApp(),
);iOS, Android and macOS are supported. Flutter web is not — use the browser SDK there.
If you want data flowing before the beta reaches you, a Flutter app that already emits OpenTelemetry can export traces and metrics over OTLP/HTTP to https://ingest.tracepath.dev/api/otel with the project token as a bearer token. That path needs nothing from us but a project.
FAQ
Does session replay need a screen-recording permission?
No. Capture goes through Flutter's RepaintBoundary, which rasterizes the widget tree your app already rendered. No platform screen-capture API is involved, so there is no prompt and no recording indicator.
How much of the session is kept?
Roughly the last ten seconds before the exception, set by maxBufferFrames (150) and fps (15). Raise either one and the window or the smoothness grows along with the buffer.
Is anything recorded when no exception happens? Nothing leaves the device. The ring buffer overwrites itself continuously and is discarded at process exit. Encoding and upload are triggered by an exception, never by a timer.
Can I keep specific widgets out of the recording?
Yes — wrap them in TracePathPrivacyMask with either the blur or blank mode. Masks are applied to the frame during capture, so unmasked pixels are never written to disk or sent.
What happens if the device is offline when the crash occurs?
The encoded recording and its exception are written to disk and retried. They are bounded by maxLocalFiles and localFileMaxAgeHours so an offline device cannot accumulate recordings indefinitely.
Further reading
- How session replay works in Flutter when there is no DOM — the design space, the player, and the honest limitations.
- Reading an obfuscated Flutter crash by hand — what happens to those stack traces in an obfuscated release build.
- Flutter session replay and session replay on the product side, and pricing for ingest allowances and retention per plan.
- Flutter SDK documentation for every option, default and wire shape.