Back to Engineering Log
Feb 06, 20268 min read

Rejourney Architecture

How we achieved pixel-perfect replay with 3 FPS and zero main-thread impact.

M
01 //

Why Pixel-Perfect Replay?

Most mobile session replay tools attempt to reconstruct the session rather than record it. Tools like PostHog, LogRocket, and other replay systems use native APIs to serialize the view hierarchy or capture low-level drawing commands.

  • PostHog & LogRocket: Primarily serialize the View Tree. They inspect the UI structure and reconstruct it as wireframes or static UI state snapshots synced with event streams. While efficient, they often miss the "visual truth" of high-motion GPU content.
  • Drawing-command recorders: Capture low-level Drawing Commands to provide a "walkthrough-style" video. It buffers visual commands on-device, but it isn't capturing the final rendered outcome seen by the user.

Rejourney is different: We capture the actual GPU Framebuffer (via drawHierarchy). This ensures Pixel-Perfect Accuracy. If your app uses Metal, Maps, or custom shaders that native view serialization can't understand, Rejourney records them exactly as they appeared on the user's screen.

While competitors rely on reconstructing a simulation from data points, Rejourney delivers a true visual record of the session. We handle the heavy lifting of GPU-ready capture while ensuring zero impact on the main thread.

02 //

To achieve high-fidelity replay (3 FPS) without impacting frame rates, our Swift SDK uses a sophisticated Async Capture Pipeline. Capturing the screen is cheap; processing it is expensive.

We perform the mandatory UIKit interaction on the main thread but immediately hand off the pixel buffer to a serialized background queue (QoS: Utility) for JPEG encoding, batching, and Gzip compression.

CORE: ASYNC ENCODING (Swift)
// Capture hierarchy on main, compress on background
_encodeQueue.addOperation { [weak self] in
    // jpegData(compressionQuality:) accounts for 60% of per-frame cost
    guard let data = image.jpegData(compressionQuality: jpegQuality) else { return }
    
    self?._stateLock.lock()
    self?._screenshots.append((data, captureTs))
    // Auto-ship when batch size (20 frames) is reached
    let shouldSend = self?._screenshots.count >= self?._batchSize
    self?._stateLock.unlock()
    
    if (shouldSend) { self?._sendScreenshots() }
}

To further protect the user experience, we utilize Run Loop Gating. By running our capture timer in the default run loop mode, the system automatically pauses capture during active touches or scrolls, eliminating any risk of micro-stutter during critical interactions.

Logic: Run Loop Gating
// Industry standard: Use default run loop mode (NOT .common)
// This lets the timer pause during scrolling which prevents stutter
_captureTimer = Timer.scheduledTimer(
  withTimeInterval: snapshotInterval, 
  repeats: true
) { [weak self] _ in
    self?._captureFrame()
}
03 //

Real-World Performance Benchmarks

Benchmarks captured on an iPhone 15 Pro (iOS 18) running the production Merch App. By isolating main-thread UIKit calls from background processing, we maintain a virtually invisible performance footprint.

MetricThreadAvg (ms)
Main: UIKit + Metal CaptureMain12.4
BG: Async Image ProcessingBackground42.5
BG: Tar+Gzip CompressionBackground14.2
BG: Upload HandshakeBackground0.8
Total Main Thread ImpactMain12.4
04 //

Privacy: On-Device Redaction

Rejourney follows a Zero-Trust Privacy model. Sensitive UI elements are never recorded. Our on-device RedactionMasks scan identifies text inputs (UITextField, UITextView), password fields, and camera previews before the pixel buffer is encoded.

These areas are blacked out directly in the memory buffer. The private data never hits the disk, never enters the JPEG encoder, and never leaves the device.

Security: Redaction Detection
private func _shouldMask(_ view: UIView) -> Bool {
  // 1. Mask ALL text input fields by default
  if view is UITextField || view is UITextView { return true }
  
  // 2. Check class name (React Native internal types)
  let className = String(describing: type(of: view))
  if _sensitiveClassNames.contains(className) { return true }
  
  // 3. Mask camera previews
  if view.layer is AVCaptureVideoPreviewLayer { return true }
  
  return false
}
05 //

Lightweight by Design: The "Smart" Internals

Being lightweight isn't just about moving work to background threads. It's about strategic omission and defensive engineering. Our SDK includes several "invisible" optimizations to ensure we never impact your app's performance.

16ms hierarchy budget

Hierarchy scanning has a hard 16ms bailout. If your view tree is massive, we stop scanning before we block the next frame. We prioritize your app's FPS over our own data completeness.

NaN-Safe Serialization

Animated iOS views often produce "degenerate" frames (NaN/Infinity sizes). Our SDK sanitizes every coordinate before serialization to prevent JSON crashes, a common failure point in mobile replay tools.

Smart Key-Window Only

We only capture the Key Window. This automatically skips high-frequency system windows (like the Keyboard or Alert layers) that would otherwise cause redundant processing and rendering artifacts.

1.25x Capture Scale

Instead of capturing at 3x Retina scale, we use a fixed 1.25x scale. This results in a ~6x reduction in JPEG size while maintaining perfect legibility for debugging sessions.

Optimization: Scan Caching
// We only scan the heavy hierarchy every 1.0s for auto-redaction.
// Focused inputs are always unmasked instantly via explicit registration,
// but the heavy recursive scan is 'debounced' to save CPU.
let now = CFAbsoluteTimeGetCurrent()
if (now - _lastScanTime >= 1.0) {
    _scanForSensitiveViews(in: window)
    _lastScanTime = now
}
06 //

Rewrite from Obj-C to Swift

Our journey to high-fidelity replay wasn't symmetrical. On Android, Kotlin proved exceptionally performant from day one. Its modern concurrency primitives and efficient bytecode generation allowed us to hit our performance targets with minimal architectural thrashing.

iOS was a different story. Our initial prototype was built in legacy Objective-C. While functional, the overhead of the dynamic runtime and the complexity of managing thread-safe manual memory buffers created persistent micro-stutter in high-traffic apps.

We made the decision to rewrite the core capture engine in Swift. The result was an immediate and dramatic improvement in main-thread responsiveness. By leveraging Swift's stricter type system and more efficient handling of OperationQueues and GCD, we managed to cut per-frame overhead by over 40% compared to the original Obj-C implementation.

07 //

SDK pipeline

The same pipeline runs on both platforms: orchestration, capture, structure, upload, then health signals. Paths below are relative to packages/react-native/.

iOS · Swift
Android · Kotlin
01Orchestration

Owns session lifecycle, remote configuration, and keeps capture, hierarchy, and upload in sync.

ios/Recording/ReplayOrchestrator.swift
android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt
02Visual capture

Framebuffer capture, background JPEG work, batching, and on-device redaction before bytes leave the app.

ios/Recording/VisualCapture.swift
android/src/main/java/com/rejourney/recording/VisualCapture.kt
03Structure & interactions

View tree snapshots and gesture / interaction metadata that align with the frame timeline.

ios/Recording/ViewHierarchyScanner.swift
ios/Recording/InteractionRecorder.swift
android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt
android/src/main/java/com/rejourney/recording/InteractionRecorder.kt
04Upload

Compressed segment packaging, HTTP/2-friendly uploads, and backoff / retry.

ios/Recording/SegmentDispatcher.swift
android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt
05Health & telemetry

Main-thread watchdogs, stability signals, and the telemetry path that rides alongside replay.

ios/Recording/AnrSentinel.swift
ios/Recording/StabilityMonitor.swift
ios/Recording/TelemetryPipeline.swift
android/src/main/java/com/rejourney/recording/AnrSentinel.kt
android/src/main/java/com/rejourney/recording/StabilityMonitor.kt
android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt