Cheap Session Replay: 17× Cheaper Than PostHog<br>GitHub146Log inGet started
Back to Engineering Log<br>PostHog bills $85/month for 25,000 sessions. We bill $5. The gap widens to 20× at 350,000 sessions/month. This article is not about how our pricing works rather it is about why those numbers are sustainable for us when they are not for a usage-based model. The answer is a stack of deliberate engineering decisions that each remove a unit of cost that would otherwise compound with volume. If you are looking for cheap session replay that is still built for production mobile apps, this is the engineering behind it.<br>Price Comparison — Rejourney vs PostHog Session Replay<br>Sessions/moRejourneyPostHogCheaper by25,000$5/mo$85/mo17×100,000$15/mo$272.50/mo18×350,000$35/mo$712.50/mo20×<br>01 // SDK BANDWIDTHEvery Byte the SDK Produces Has to Be Stored and Served
Most session replay tools default to capturing at the device's native resolution — 1× or 2× Retina — because it is the simplest implementation. We do not. The SDK captures at 1.25× scale, which means we read the framebuffer at roughly 80% of its linear dimension before any compression runs. The difference in raw pixel count between 3× Retina and 1.25× is approximately 5.8×. That is 5.8× less data going into the JPEG encoder before compression is even considered.<br>The frame rate is also not what people assume. Our default is 1 frame per second, with the capture timer scheduled in UIKit's default run loop mode — intentionally not .common. Scheduling in the default mode means the timer automatically pauses while UIKit is handling active scroll events. It is not that we slow down capture during scrolls; the run loop simply does not fire the timer. Combined with our 1 FPS default, a tool capturing at 10 FPS will produce roughly 10× more screenshot data per session before any other factor is considered.<br>The main thread is involved only for the pixel read itself. JPEG encoding, frame batching, gzip compression, and HTTP upload all run on a serial background OperationQueue at .utility QoS. The encode queue has a hard backpressure limit: 50 pending batches and 500 buffered frames. When either limit is reached, new frames are dropped rather than queued. This prevents a network outage from turning into unbounded memory growth, and it keeps the SDK's memory footprint predictable under any network condition.<br>On-device redaction is part of this story too. Text inputs, password fields, and camera previews are blacked out in the pixel buffer before the JPEG encoder runs. The redacted pixels are never in the artifact. This eliminates the server-side blurring pipeline that most tools run as a post-processing step which is one less compute stage, one less storage operation per session. Small but still helpful.
01b // COMPRESSIONHow the Frame Bundle Format and Gzip Work
After JPEG encoding, frames are not uploaded individually. They are packed into a binary archive and gzip-compressed as a single unit before the network call happens. The binary format is deliberately simple: for each frame, 8 bytes of big-endian timestamp offset from the session epoch, 4 bytes of big-endian JPEG size, then the raw JPEG bytes. Each frame record is just a header and a payload, no alignment padding, no per-frame metadata.<br>Binary frame bundle layout (per frame, repeated)<br>[ 8 bytes — uint64 BE ] timestamp offset from session epoch (ms)<br>[ 4 bytes — uint32 BE ] JPEG byte length<br>[ N bytes ] raw JPEG data
...repeated for each frame in the batch...
→ entire archive passed to gzipCompress()<br>The gzip pass uses zlib's deflateInit2_ with compression level 9 — maximum ratio, not the default level 6. The flag MAX_WBITS + 16 selects the gzip container format specifically (as opposed to raw deflate or the zlib envelope). Memory level is 8, strategy is Z_DEFAULT_STRATEGY. The output buffer is pre-allocated at input.count / 2 — assuming 50% compression upfront to avoid reallocation in the common case — and streams in 16 KB chunks until avail_out is non-zero.<br>zlib parameters<br>deflateInit2_(<br>&stream,<br>9, // level — Z_BEST_COMPRESSION<br>Z_DEFLATED,<br>MAX_WBITS + 16, // +16 = gzip container (not raw deflate)<br>8, // memLevel<br>Z_DEFAULT_STRATEGY,<br>ZLIB_VERSION,<br>MemoryLayout.size<br>Level 9 costs slightly more CPU than level 6 but produces meaningfully smaller output for JPEG data that has already been compressed once. The trade-off is intentional: the encode queue runs at .utility QoS, so the extra compression work happens on spare CPU that the OS would not otherwise schedule for foreground work. The smaller payload wins at the storage and egress layer every time the artifact is read back.<br>Batch flush triggers on whichever comes first: the buffer reaching the upload batch size (default 3 frames), or the oldest buffered frame having waited longer than batchSize × snapshotInterval milliseconds. The time-based flush exists specifically for short sessions that end before accumulating a full batch — without it, a 2-second session would sit in...