All posts
Engineering

Session Replay in Flutter Without a DOM

Web replay serializes DOM mutations. Flutter has no DOM. Here is the design space that leaves, what TracePath picked, and what the approach cannot do.

Browser session replay works because the browser hands you a document. rrweb takes a full DOM snapshot, records every subsequent mutation as a compact event, and the player replays those events into an iframe to rebuild the page. Flutter gives you none of that: it paints through Skia or Impeller onto a canvas, and by the time anything is on screen there is no element tree to diff and no stylesheet to serialize. That single fact decides the entire architecture of a Flutter replay, so it is worth walking the options properly.

Key takeaways

  • Flutter's rendered output is pixels on a canvas. There is no serializable document, which rules out the web approach outright.
  • Flutter's semantics tree is not a substitute: it exists for accessibility, is only built when assistive technology asks for it, and describes meaning rather than layout.
  • That leaves four candidate designs. TracePath's Flutter SDK captures rasterized frames from a RepaintBoundary and encodes them to MP4 only when an exception fires.
  • Replays arrive as a single flutter_video event and play in a device frame next to the stack trace, with logs and actions aligned onto the same timeline.
  • The trade-off is honest and worth stating: pixels are not structure, and what the host platform composites outside Flutter is not in the frame.

Why the web technique does not port

The rrweb model rests on three properties of the browser that Flutter does not have.

First, a stable serializable tree. The DOM is addressable — every node has an id you can reference in a mutation record, so "node 412 gained the class error" is a complete, tiny description of a visual change. Flutter's widget tree is rebuilt constantly by design; element identity is an implementation detail of the framework's reconciliation, not a public address space.

Second, a declarative style layer. A web replay can ship one stylesheet and then describe changes as class flips. In Flutter, style lives inside widget build methods as Dart expressions. There is nothing to serialize that would let a player independently compute what the screen looked like.

Third, a renderer on the other end. rrweb replays into a real browser, which does the layout. A Flutter replay would have to ship a Flutter engine to the viewer, or reimplement Flutter's layout in the player and keep it version-matched forever. Every framework upgrade becomes a player bug.

What about the semantics tree?

It is the obvious counter-proposal, and it does not hold up. Flutter maintains a semantics tree for screen readers, but it is built lazily — the framework only populates it when assistive technology is active or something else holds a semantics handle — so recording from it would mean forcing that machinery on for every user. Worse, it is the wrong shape: it describes a button's label and role, not its position, colour, or the sheet sliding over it. It exists so a blind user can navigate, not so a developer can reconstruct a screen. A replay built on it would be a list of names, not a picture of what went wrong.

The four candidate designs

With a document off the table, there are four ways to build this, and they trade off along the same two axes: payload size and reconstruction fidelity.

1. Native screen recording via platform channels. Highest fidelity, worst everything else. It requires a screen-capture permission on both platforms, shows the OS recording indicator, and records the display rather than your app — which means it can capture a notification banner from another app. It also gives you no handle on the widget tree, so redaction becomes "blur these screen coordinates and hope the layout did not move."

2. Continuous video encoding on device. Removes the permission problem if you feed it your own frames, but sustained H.264 encoding is one of the most expensive background workloads a phone can run, and you pay it permanently for an event that happens rarely.

3. Custom semantic event stream. The rrweb analogue: record navigation pushes, scroll offsets, masked text edits and gestures, then approximate the screen in the player. The payload is tiny. The fidelity is the problem — everything the player cannot reconstruct is a guess, custom-painted canvases become placeholder boxes, and the player now owns a permanent approximation of Flutter's layout engine. You spend that maintenance budget to end up with a screen the user never actually saw.

4. Rasterize the app's own frames, encode on demand. Sample what Flutter already rendered into a fixed-size buffer, and turn it into video only when something goes wrong. Fidelity is exactly what the user saw, because it is what the user saw. Payload is bounded by the buffer, not by session length. Redaction can be done in the widget tree, where it belongs.

TracePath's Flutter SDK is option four.

What that looks like in practice

RepaintBoundary.toImage() rasterizes the subtree the boundary wraps. The SDK wraps your whole app in one, samples it on a timer at 15 fps by default, compresses each frame to PNG, and pushes it into a ring buffer of 150 slots — ten seconds, constantly overwritten. Nothing is uploaded and nothing is encoded while the app is behaving.

When an error reaches FlutterError.onError, PlatformDispatcher.onError or the guarded Zone, the buffer is drained, masks and touch overlays are burned into the frames, and the window is encoded to MP4 and attached to the exception payload. The mechanics — the ring buffer, the encode lock, the disk fallback, the masking API — are covered in detail in the architecture post.

The design consequence worth noticing is that the expensive work is the rare work. Web replay inverts this: because the DOM is cheap to serialize, you record continuously and the cost is in bandwidth. In Flutter the cheap operation is sampling and the expensive one is encoding, so the architecture moves encoding behind the event you actually care about.

The player side

A Flutter recording does not arrive as an event stream, so the dashboard does not replay it as one. It comes back as a single event:

{
  "type": "flutter_video",
  "format": "mp4",
  "fps": 15,
  "durationSeconds": 10,
  "data": "<base64 mp4>"
}

The dashboard detects that shape and switches the replay panel from the rrweb player to a video player inside a device frame, with its own scrubber, playback rate and fullscreen. Web recordings in the same panel are still rrweb event arrays and still replay into an iframe — one component, two sources, chosen by the first event's type.

What makes the video useful rather than just watchable is the timeline alignment. Each recording carries startedAt and endedAt taken from the first and last buffered frames, and the same payload carries the rolling log and action buffers. Every log line, navigation, HTTP call and custom action has a timestamp, so each one resolves to an offset into the video. Clicking an entry seeks the clip; scrubbing the clip moves through the entries. You are not reading a video and a log side by side and correlating by hand.

What this approach cannot do

Every design buys something with something. These are the costs, stated plainly.

Pixels are not structure. You cannot search the replay for a string, diff two states, or ask which widget a tap landed on. You get the frame, a touch overlay at the tap coordinates, and the action timeline beside it. For debugging an exception that is usually enough; for building product analytics on top of replays, it is not.

Anything Flutter does not rasterize is not in the frame. Platform views — embedded webviews, native map SDKs, camera previews — are composited by the host platform outside the Flutter layer that toImage() captures, so they show up as empty regions. If your crash is inside an embedded webview, the replay will show you the app around it, not the page inside it.

Sampling has a floor. At 15 fps a state that exists for 40 ms may not appear in any frame. Raise fps if transient states matter to you; the buffer scales with it.

Masking is irreversible and must be right at capture time. Because the artifact is video rather than a re-renderable event stream, a region you failed to mask cannot be masked later, and a region you did mask cannot be unmasked for an investigation. That asymmetry is deliberate — it is the same reason the unmasked pixels are never written to disk — but it means privacy decisions belong in the widget tree, reviewed like any other code.

Flutter web is out of scope. HttpOverrides.global does not run there and the capture path differs enough that it is a separate implementation; browser SDKs cover that case with rrweb.

FAQ

Why not just port rrweb to Flutter? rrweb records DOM mutations and replays them into a browser that performs layout. Flutter has neither the serializable tree nor a layout engine on the viewer's side, so a port would mean inventing both and keeping them matched to every Flutter release.

Is Flutter's accessibility tree a viable recording source? No. It is built only when assistive technology requests it, and it encodes labels and roles rather than layout, so it cannot reconstruct what a screen looked like.

How large is a Flutter replay compared with a web one? Different shape, comparable order of magnitude. A web recording is an event stream that grows with session length; a Flutter recording is a fixed ten-second MP4 in the low hundreds of kilobytes, because consecutive UI frames are nearly identical and compress well.

Does the replay cost frame time during normal use? Capture is a timer, a rasterize and a buffer write. In the SDK's benchmark harness there is no p50 frame-time regression with capture on across the Android device tiers; the measurable cost lands at encode time, after the exception.

Can I use this on Flutter web? No — use the browser SDK there, which records rrweb events. The Flutter SDK covers iOS, Android and macOS.

Further reading

The Flutter SDK is in private beta and is not on pub.dev yet. Email [email protected] for access.

Subscribe

Get new engineering posts in your inbox

This opens your mail client with a short message to [email protected]. Prefer no email at all? Use the RSS feed.