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.
We run two observability signal types side by side, and they answer different questions:
| Signal | Question it answers | Example |
|---|---|---|
| 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.
Retention limits and access control shrink the window of exposure. Masking removes the exposure class. Five reasons this matters for an email product specifically:
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 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.
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.
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.
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:
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.
A native app's UI has no portable, serializable representation, so mobile recorders choose between two approximations:
| Mode | How it works | Pros | Cons |
|---|---|---|---|
| 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. |
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.
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.
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.
| Content | Treatment | Why |
|---|---|---|
| Sender names & addresses | MASK | Identity of third parties; the correspondence graph itself is sensitive (whose doctor, lawyer, recruiter). |
| Subjects, snippets, summaries | MASK | Derived from mail content — including AI summaries, which are distillations of it. |
| Email bodies & drafts | MASK | Mail content, inbound and outbound. Non-negotiable; the floor we never cross. |
| AI profile ("what we know about you") | MASK | The most concentrated PII in the product — a distillation of the entire mailbox. |
| Search queries | MASK | Typed queries are high-signal ("flight to rehab", people's names). Input masking covers them globally. |
| Attachment filenames | MASK | Third-party-authored; often descriptive ("offer_letter_final.pdf"). |
| Toasts / dialogs quoting a sender | MASK fragment only | Mask the interpolated name, keep the sentence — toasts are debugging signal. |
| Dates, times, counts, file sizes | VISIBLE | Metadata; carries no identity and anchors the replay in time. |
| Category / relationship chips | VISIBLE | Fixed vocabulary ("Customers", "Act") — not user data, and exactly what UX review needs to see. |
| Buttons, labels, empty states, layout | VISIBLE | The chrome. Masking it makes recordings undebuggable for zero privacy gain. |
| Avatars & sender icons | VISIBLE | Deliberate 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 email | VISIBLE | Documented boundary: first-party identity of the person being recorded. Changing it is a product/privacy decision, not a config tweak. |
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.
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.
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.
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.
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.
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.
Worked every time so far. In order:
| Symptom | Actual 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 appears | Session mismatch, tail not flushed, or visit shorter than the frame interval — in that order of likelihood. |
| Sensitive text readable in a viewer state | Read-only editor state or WebView escaped the automatic input masking. Needs an explicit mask. |
| Empty list / "0 items" mid-recording | A frame captured mid-load. Not masking. |
Why things are the way they are — the decisions with their reasoning, so they get revisited deliberately rather than re-litigated accidentally.
| Decision | Rationale | Revisit when… |
|---|---|---|
| Hosted sampling only | Replay 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 primitive | Container 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 sender | Success/failure toasts are debugging signal; static text stays readable, only the interpolated name masks. | — |
| Fixed-vocabulary chips visible | Category/relationship labels are enums, not user data — and central to reviewing the product experience. | — |
| User's own account email visible | First-party identity of the recorded user; long-standing documented boundary. | Explicit product/privacy call. |
| Dev-only unmask switch, production-proof | Replay 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 vendor | The 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. |