EmailZap · Knowledge Base

Session Recordings: how replay works on web and mobile, and how we mask it

Everything we learned shipping PostHog session replay across the Chrome extension and the iOS app — the two recording technologies, why masking behaves so differently on each, what we mask and deliberately don't, and the pitfalls that cost us debugging hours.

What replay is for — and what it is not

We run two observability signal types side by side, and they answer different questions:

SignalQuestion it answersExample
Product events"How often does this happen, across users?" Counting, funnels, cohorts.Did users who saw the drafts section send more replies?
Session replay"What did this one user actually experience?" Watching, UX debugging.Why did this user open Settings four times in three seconds?

Replay is a UX microscope, not a funnel tool. When the two overlap — you want to know when a draft was sent inside a recording — the replay player shows the event timeline alongside the video, so events give you sequencing even where pixels are masked.

Why mask at all, if recordings expire and sit behind auth

Retention limits and access control shrink the window of exposure. Masking removes the exposure class. Five reasons this matters for an email product specifically:

  1. Third-party data. Email bodies and sender identities belong to people who never agreed to EmailZap, let alone to an analytics vendor. Privacy obligations trigger at collection, not at deletion.
  2. Auth means our team sees it. Anyone with analytics access could otherwise read customers' mail. Replay is for UX debugging — nobody needs actual mail text for that.
  3. TTL protects the future, not the past. A breach, a leaked viewer session, or an exported clip during the retention window leaks real mail. A masked recording leaks nothing, whenever it leaks.
  4. Copies in transit. Data crosses networks and sits in the vendor's ingestion pipeline and backups. Deletion promises cover primary storage, not every intermediate.
  5. Trust asymmetry. One screenshot of a customer's inbox visible in our analytics tool is a product-killing story for an email-privacy product. Masking costs nothing in return.

Operating principle: mask the content, show the chrome. Anything derived from mail (names, subjects, bodies, summaries, drafts, the AI profile, search queries) is masked. Everything structural (dates, counts, category chips, buttons, layout) stays visible — that's exactly what UX debugging needs.

The two recording technologies

The single most important mental model: web replay records data; mobile replay records pixels. Every difference in masking quality, payload size, and fidelity follows from that split. The next two tabs cover each in depth.

Web · Chrome extension

rrweb — DOM recording

  • Serializes the DOM tree + subsequent mutations
  • Replay reconstructs a real DOM — text stays text
  • Masking substitutes characters with asterisks, layout intact
  • Tiny payloads (diffs, not frames)
Mobile · iOS app

Screenshot mode — frame capture

  • Periodic screenshots of the screen, throttled
  • Masking paints opaque rectangles over view frames
  • No asterisks possible — a screenshot is pixels
  • Temporal gaps between frames; larger payloads

How rrweb records the extension

rrweb (the recorder inside PostHog's web SDK) takes a full serialized snapshot of the DOM, then streams mutations: nodes added/removed, attributes changed, text edited, plus input, scroll, and pointer events. The replay player rebuilds an actual DOM from that stream — which is why web recordings are crisp at any zoom, capture every interaction, and cost a fraction of video bandwidth.

Because text nodes travel as data, masking happens before serialization: each character of a masked text node is replaced with *. The layout, fonts, and element sizes survive — a viewer can verify the UI rendered correctly while reading nothing.

Mask vs block — two different tools

MASKText replaced by asterisks in place. Layout intact. Use for our own UI's PII text.
BLOCKElement replaced by empty space. Looks like missing or broken UI. Reserve for host-page content we never debug from replay (Gmail's own rows and message bodies).

The lesson we learned the hard way: our first configuration listed EmailZap's own PII marker in both the mask and the block lists. Block wins in rrweb — so every sender name, subject, and contact row simply vanished, and the recordings looked like a rendering catastrophe. If a replay looks "broken," check whether it's actually blocked.

Why the recorder is lazy-loaded

rrweb's DOM mirror pre-arms at SDK init, even when recording is configured off — it installs observers so recording can start instantly. Inside Gmail, that means paying memory and mutation-observer cost on every Gmail tab of every user, recorded or not. We measured this once, removed the recorder from the eager bundle, and now:

Identity, sessions, and sampling

Rule of thumb for web masking: prefer masking over blocking for anything we render; block only third-party host content; keep the recorder out of every bundle that doesn't need it.

Mobile has no DOM — the two possible modes

A native app's UI has no portable, serializable representation, so mobile recorders choose between two approximations:

ModeHow it worksProsCons
Screenshot Periodically screenshots the window; masking paints opaque rectangles over the frames of masked views. Pixel-faithful — replay looks exactly like the app; works for any UI framework; WebViews render. Masking = black boxes, never asterisks; frames sampled on an interval, so fast actions fall between them; heavier payloads; capture work costs CPU.
Wireframe Serializes the native view hierarchy; replay reconstructs recognizable widgets as a skeleton. Small payloads; masked text renders as tidy grey placeholders; no screenshot cost. Reconstruction is approximate, not faithful; needs a semantic native view tree to draw from.

Why we use screenshot mode — and had no real choice

The EmailZap app is React Native. RN renders through generic container views — the native hierarchy is a soup of anonymous boxes and paragraphs with no widget semantics. Wireframe reconstruction has nothing meaningful to draw, so its output for RN apps is unusable. PostHog's React Native integration therefore hardcodes screenshot mode; there is no supported toggle, and forcing it would mean patching the vendor's native code for a known-bad result. Screenshot mode is the correct and only mode for this app — worth revisiting only if the app ever goes fully native.

The capture interval — temporal fidelity

Screenshot capture is throttled: at most one frame per interval, triggered by view changes. The default is 1 second — which sampled our sub-second navigation out of existence entirely (a quick Settings visit produced zero frames; the events proved the user was there, the video never showed it). We lowered it to 250 ms, the vendor's recommended floor. The cost scales linearly: 4× frames = more CPU, battery, and ingestion volume. Capture also runs on a background thread so it doesn't stutter the UI on high-refresh iPhones.

Session lifecycle — the debugging facts

Known limitation: masking ignores what's on top

Masking rectangles are painted at each masked view's window coordinates, with no awareness of z-order. When a bottom sheet or draft card opens above masked content, the background content's rectangles are stamped on top of the sheet — black bars floating over unmasked UI. This is a vendor SDK gap (verified unfixed through the current release), not a masking-placement error on our side. Read those recordings with the event timeline; bars on a sheet = background bleed-through, not broken sheet UI.

See the difference — one briefing row, four renderings

Rendering
G
Google Thu
A new sign-in on Mac was detected
Security alert
actunknown
The row as the user sees it. Toggle the modes above.

The masking taxonomy

ContentTreatmentWhy
Sender names & addressesMASKIdentity of third parties; the correspondence graph itself is sensitive (whose doctor, lawyer, recruiter).
Subjects, snippets, summariesMASKDerived from mail content — including AI summaries, which are distillations of it.
Email bodies & draftsMASKMail content, inbound and outbound. Non-negotiable; the floor we never cross.
AI profile ("what we know about you")MASKThe most concentrated PII in the product — a distillation of the entire mailbox.
Search queriesMASKTyped queries are high-signal ("flight to rehab", people's names). Input masking covers them globally.
Attachment filenamesMASKThird-party-authored; often descriptive ("offer_letter_final.pdf").
Toasts / dialogs quoting a senderMASK fragment onlyMask the interpolated name, keep the sentence — toasts are debugging signal.
Dates, times, counts, file sizesVISIBLEMetadata; carries no identity and anchors the replay in time.
Category / relationship chipsVISIBLEFixed vocabulary ("Customers", "Act") — not user data, and exactly what UX review needs to see.
Buttons, labels, empty states, layoutVISIBLEThe chrome. Masking it makes recordings undebuggable for zero privacy gain.
Avatars & sender iconsVISIBLEDeliberate decision: ours are company favicons or colored initials, never contact photos. Blacked-out squares hurt legibility more than the weak signal they hide. Revisit if real contact photos ever ship.
The user's own account emailVISIBLEDocumented boundary: first-party identity of the person being recorded. Changing it is a product/privacy decision, not a config tweak.

Granularity: the single most important practice

Mask the finest PII-bearing element, never its container. A wholesale mask around a row's text column renders (on mobile) as one black slab — hiding the date, the counts, the chips, and making the replay read as broken UI. Per-leaf masks produce black bars over exactly the private words, with everything structural readable around them. The "anti-pattern" toggle in the demo above shows the difference.

Traps that silently unmask content

The read-only editor trap bit us on web AND mobile, independently

Automatic input masking covers editable fields. Rich-text editors flip to a non-editable state for display — and the generic input mask no longer matches. Result: the AI profile was masked while being edited and fully readable while being viewed, which is when users actually look at it.

Rule: any surface that renders sensitive text through an editor's read-only state needs its own explicit mask.

The WebView trap text masks can't see inside a browser-in-a-box

Email bodies and the rich profile editor render inside embedded web views. No text-leaf mask can reach inside one — the content isn't native text. The whole surface must be masked as a region, and that's correct anyway: everything inside is mail-derived.

The portal/overlay trap masks don't inherit across render boundaries

Toasts, dialogs, and sheets often render outside the main component tree (portals, separate windows). A mask on the page doesn't cover them — any overlay that interpolates sender data must carry its own mask on the overlay content itself.

The shadow-boundary trap web-only

rrweb's selectors don't cross shadow DOM boundaries. Content rendered inside a shadow root needs a masking marker inside that root — a marker on the host element is not enough.

The inspection switch

Judging replay quality requires occasionally seeing it unmasked. We keep one hardcoded developer switch that bypasses both masking layers at once — guarded so that a production (App Store) build always masks regardless of the switch. One knob, production-proof, documented; never per-screen hacks.

"There's no recording" — the debugging playbook

Worked every time so far. In order:

  1. 1 Right project? Dev builds report to the Local analytics project, not Production. Check where the events landed first.
  2. 2 Do events flow at all? If product events arrive, SDK init/network/keys are fine — the problem is replay-specific. If nothing arrives, it isn't a replay problem.
  3. 3 Match session ids. Every event carries its session id; a recording's id is the session id. "I visited that screen" must be checked against which session the visit landed in — the screen may be in a session that has no recording.
  4. 4 First launch after fresh install? The recording go-ahead comes from a cached server config; a cold cache means the first session doesn't record. Relaunch.
  5. 5 Native module actually in the build? A new native dependency needs pods + a real rebuild. The JS layer degrades silently when the native half is missing.
  6. 6 Waiting on the tail? Ongoing sessions flush in batches; background the app and give it a minute before declaring frames missing.
  7. 7 Faster than the frame interval? Sub-second visits can fall between screenshots entirely. Check the throttle before hunting exclusions.

"The recording looks broken" — interpretation guide

SymptomActual cause
Content vanished, layout collapsed (web)Element was blocked, not masked. Blocking renders empty space. Move it to the mask list.
Whole row is one black slab (mobile)Container-level mask. Move masks to the PII text leaves.
Black bars floating over a bottom sheet (mobile)Occlusion bleed-through — masked background content painted over the overlay. Vendor limitation; read via the event timeline.
A screen the user visited never appearsSession mismatch, tail not flushed, or visit shorter than the frame interval — in that order of likelihood.
Sensitive text readable in a viewer stateRead-only editor state or WebView escaped the automatic input masking. Needs an explicit mask.
Empty list / "0 items" mid-recordingA frame captured mid-load. Not masking.

Practices that kept us honest

Decision log

Why things are the way they are — the decisions with their reasoning, so they get revisited deliberately rather than re-litigated accidentally.

DecisionRationaleRevisit when…
Hosted sampling onlyReplay volume is controlled in the analytics project settings; no client-side sample rate anywhere. One authority, adjustable without a release.Cost pressure requires per-surface rates.
Recorder lazy-loaded (web)rrweb pre-arms at init; keeping it out of the eager bundle spares every plain-Gmail tab the cost. Auth + surface eligibility gate the fetch.Recording is ever wanted on non-EmailZap surfaces.
Mask > block for our UI (web)Blocked elements render as empty space and read as broken UI. Blocking reserved for Gmail-native content.
Screenshot mode (mobile)Forced by React Native — wireframe needs a semantic native view tree RN doesn't produce. Not a choice to revisit while the app is RN.App goes fully native.
Per-leaf masking via one shared primitiveContainer masks black out whole rows; per-view fixes don't scale. One primitive + import restriction makes the policy structural.
Images unmasked (mobile)Avatars are domain favicons / colored initials — not contact photos. Blacked squares hurt legibility more than the weak correspondence-graph signal they hide. Mail-content images stay inside masked surfaces regardless.Real contact photos ship anywhere.
250 ms capture interval (mobile)Default 1 s sampled out sub-second navigation entirely. 250 ms is the vendor floor; cost is linear in frames.Field frame/hang or energy metrics regress.
Toasts mask only the quoted senderSuccess/failure toasts are debugging signal; static text stays readable, only the interpolated name masks.
Fixed-vocabulary chips visibleCategory/relationship labels are enums, not user data — and central to reviewing the product experience.
User's own account email visibleFirst-party identity of the recorded user; long-standing documented boundary.Explicit product/privacy call.
Dev-only unmask switch, production-proofReplay quality must be inspectable; the guard makes it impossible for a store build to record unmasked even if the switch ships flipped.
Stay on one replay vendorThe pain points (black-box masking, occlusion bleed) are mobile-platform physics shared by every vendor; switching buys a different box color at the cost of a second SDK, PII contract, and a split session timeline.A vendor ships occlusion-aware or higher-fidelity mobile masking worth the migration.

Open items