Rejourney Architecture
How we achieved pixel-perfect replay with 3 FPS and zero main-thread impact.
How we achieved pixel-perfect replay with 3 FPS and zero main-thread impact.
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.
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.
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.
// 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.
// 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()
}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.
| Metric | Thread | Avg (ms) |
|---|---|---|
| Main: UIKit + Metal Capture | Main | 12.4 |
| BG: Async Image Processing | Background | 42.5 |
| BG: Tar+Gzip Compression | Background | 14.2 |
| BG: Upload Handshake | Background | 0.8 |
| Total Main Thread Impact | Main | 12.4 |
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.
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
}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.
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.
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.
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.
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.
// 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
}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.
The same pipeline runs on both platforms: orchestration, capture, structure, upload, then health signals. Paths below are relative to packages/react-native/.
Owns session lifecycle, remote configuration, and keeps capture, hierarchy, and upload in sync.
Framebuffer capture, background JPEG work, batching, and on-device redaction before bytes leave the app.
View tree snapshots and gesture / interaction metadata that align with the frame timeline.
Compressed segment packaging, HTTP/2-friendly uploads, and backoff / retry.
Main-thread watchdogs, stability signals, and the telemetry path that rides alongside replay.