BugMojoBugMojoBugMojo
FeaturesPricingBlogHelpAbout
Add to ChromeLog inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

A product of Softech Infra.

Product

  • Features
  • Pricing
  • Browser extension
  • Get started
  • Log in

Resources

  • Help & guides
  • Blog
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Security
  • Privacy
  • Terms
  • Sitemap
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Engineering
  4. Compressing Session Replays at Scale: rrweb Events to Cheap S3 Storage
Engineering

Compressing Session Replays at Scale: rrweb Events to Cheap S3 Storage

rrweb session replays balloon fast: one heavy DOM snapshot plus thousands of tiny deltas. Here is how to compress them with gzip or Brotli, store console and network as S3 objects instead of JSONB, chunk sensibly, and rebuild on the read path.

Hrishikesh BaidyaHrishikesh Baidya·Jun 26, 2026·8 min read
Engineering
Isometric line-art pipeline of scattered rrweb event dots collapsing through a lime compression stage into stacked S3 storage cubes on a dark canvas
TL;DR
  • rrweb replays are asymmetric: PostHog measured one DOM full snapshot at ~263 KB, then incremental deltas averaging just 2,799 bytes each. One compression policy for both wastes money.
  • Compress the whole session server-side, not per-event. The rrweb docs say per-event packFn "may not get the best result."
  • Store console and network as compressed S3 objects, not inline JSONB — keep only a pointer (key, size, event count) in Postgres.
  • Chunk to the low-megabyte range with a manifest; tiny chunks lose compression ratio and rack up per-object fees.

A session replay is not a video file. rrweb records a serialized DOM snapshot and then a stream of incremental events, which is exactly why it is small enough to capture everywhere and large enough to bankrupt a naive storage design. The trap is treating the whole stream as one homogeneous blob. It is not. The first snapshot is heavy and one-time; everything after it is a long tail of tiny deltas. If you compress and store both the same way, you pay for the mismatch every single session.

This post is the storage-and-compression half of the rrweb story. For how capture actually works internally, see our rrweb deep dive. Here we stay strictly downstream: delta plus gzip or Brotli, whole-session versus per-event packing, S3 objects versus JSONB, the chunking trade-off, and the read path that rebuilds a replay on demand.

How do you compress rrweb replays for cheap storage at scale?

Compress the entire session server-side with gzip or Brotli rather than packing each event, because deflate-style algorithms exploit repetition across the whole stream. Store console and network logs as compressed S3 objects keyed by session, keeping only a database pointer. Chunk into low-megabyte segments described by a manifest so playback can decompress and seek without downloading everything.

Why rrweb event streams balloon

The single most useful number for sizing your pipeline is the shape of the data, not its average. PostHog's instrumentation benchmark measured rrweb's initial DOM full snapshot at 269,358 bytes (about 263 KB uncompressed) for one page, while subsequent incremental mutation payloads averaged just 2,799 bytes and ranged from 457 to 12,772 bytes across ten requests. That asymmetry is the whole game: one large object, then a long tail of small ones.

Active UIs make the tail long. Virtualized lists re-render rows on every scroll, animation frames emit attribute mutations, and a chatty React tree can fire mutation events on each keystroke. A few minutes of a busy session can cross several megabytes of raw JSON. Repetition is everywhere — class names, attribute keys, inlined CSS — which is good news, because repetitive text is exactly what general-purpose compressors eat for breakfast.

rrweb payload shape: one heavy snapshot, a tail of tiny deltas
Initial DOM full snapshot
269358bytes
Largest incremental delta
12772bytes
Average incremental delta
2799bytes
Smallest incremental delta
457bytes
Source: PostHog session recording benchmark (2024)

Delta plus gzip or Brotli: pick per layer

rrweb already gives you the delta layer for free — incremental events are deltas against the prior DOM state, so you are not diffing blobs yourself. Your job is the second layer: a general-purpose compressor over the serialized stream. DebugBear's comparison shows a 173 KB text asset shrinking to 61 KB with gzip (65% reduction) versus 52 KB with Brotli (70% reduction). Brotli lands roughly 15% smaller on the same payload, at the cost of more CPU at high quality levels.

The practical rule: Brotli at a moderate quality (4 to 6) for cold storage objects you write once and read rarely, gzip where you need lower CPU or broad streaming-decompression support. rrweb event JSON is highly repetitive, so both land near the top of their reduction ranges. Do not over-tune; the snapshot dominates, and Brotli quality 11 buys you a few percent for a large CPU bill.

Featuregzip (deflate)BrotliPer-event packFn
Best roleStreaming reads, broad supportCold S3 objects written onceShrinking data in transit from browser
Compression ratio on rrweb JSON~65% reduction~70% reduction (best)Lower; per-event context is small
CPU costLow to moderateHigher at quality 9-11Spread across the client
Whole-session ratioStrong (shared context)StrongestWeak — rrweb advises against for storage
Agent-readable over MCPYes, if chunk-addressableYes, if chunk-addressableNo standard exposure
Where each compression choice fits in a replay pipeline
Compress the session, not the event

The rrweb docs are blunt: "Use packFn to compress every event may not get the best result. It's recommended to compress the whole session in the backend, which will have a more efficient compression ratio for some algorithms like deflate." They also recommend deduplicating inlined CSS so only one copy of repeated styles is stored. Per-event packing still earns its keep on the network hop — just decode and recompress the whole chunk before it hits S3.

The pipeline: pack in transit, recompress at rest

The pattern that works at scale has two compression stages with a decode in between. The browser does light packing — rrweb's @rrweb/packer uses fflate, the same engine Sentry switched to when it cut its Replay SDK bundle by 23% (about 19 KB min+gzip) and up to 35% with tree shaking; the compression worker alone is roughly 10 KB gzipped. That keeps the network payload small. Server-side, you decode, batch events into a chunk, and recompress the whole chunk with Brotli before writing to S3.

store-chunk.tsts
import { gunzipSync } from 'fflate';
import { brotliCompressSync, constants } from 'node:zlib';
import { PutObjectCommand } from '@aws-sdk/client-s3';

// events arrive packed from the browser (fflate). Decode, batch, recompress.
export async function storeChunk(sessionId: string, packed: Uint8Array, idx: number) {
  const json = gunzipSync(packed);                 // 1. decode the in-transit packing
  const compressed = brotliCompressSync(json, {     // 2. recompress the WHOLE chunk
    params: { [constants.BROTLI_PARAM_QUALITY]: 5 },
  });
  const key = `replays/${sessionId}/chunk-${idx}.br`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.REPLAY_BUCKET,
    Key: key,
    Body: compressed,
    ContentEncoding: 'br',
    ContentType: 'application/json',
  }));
  return { key, bytes: compressed.length };          // 3. return a pointer for the manifest
}

Console and network: S3 objects, not JSONB

It is tempting to drop console output and network logs into a JSONB column next to the bug row. Resist it. These payloads are append-only, almost never queried field-by-field, and they inflate row size and TOAST overhead in Postgres. That bloats backups, slows the rows you do query, and turns a vacuum into an event. Write them instead as compressed objects keyed by bug or session ID, and keep only a pointer in the database — the S3 key, the byte size, and the event count — which is all you need for indexing and access control.

The cost math favors this too. Amazon S3 Standard is $0.023 per GB-month for the first 50 TB in US East (N. Virginia), and S3 Intelligent-Tiering adds a monitoring charge of $0.0025 per 1,000 objects larger than 128 KB. Because that per-object fee is fixed, thousands of tiny per-event objects cost more in overhead than a handful of well-sized compressed objects holding the same bytes.

Chunking: the size sweet spot

Chunking splits a session into time-bounded segments so playback can stream and seek, and so a long session never exceeds object-size limits. The tension is two-sided. Make chunks too small and compression ratio drops because each chunk has less shared context for the algorithm to exploit, and per-object monitoring fees pile up. Make them too large and you lose the seek benefit and risk re-downloading megabytes to jump to one moment.

The workable balance is chunks in the low-megabyte range, each written with a manifest entry recording its byte range, timestamp span, and event count. The manifest is the index the read path walks; the first chunk always carries the full snapshot so any later chunk is replayable from snapshot-plus-range.

manifest.jsonjson
{
  "sessionId": "s_9f2a",
  "snapshotChunk": "replays/s_9f2a/chunk-0.br",
  "chunks": [
    { "key": "replays/s_9f2a/chunk-0.br", "from": 0,     "to": 11840, "events": 412, "bytes": 96231 },
    { "key": "replays/s_9f2a/chunk-1.br", "from": 11840, "to": 24010, "events": 905, "bytes": 88714 }
  ],
  "logs": { "console": "logs/s_9f2a/console.br", "network": "logs/s_9f2a/network.br" }
}

The read path: rebuilding a replay

Playback is the manifest in reverse. The server reads the manifest from the database, resolves the chunk keys needed for the requested timestamp range, and fetches the compressed objects from S3 — typically via presigned URLs so the bytes flow straight to the client. The client decompresses, concatenates events in timestamp order starting from the full snapshot, and hands the stream to the rrweb Replayer, which deterministically rebuilds the DOM and applies each incremental event.

Seeking is where chunking pays off. To jump to the moment a bug occurred, you need only the snapshot chunk plus the one chunk covering that timestamp — not the whole session. This is also the hook for the AI read path: because each chunk is independently addressable and described by the manifest, BugMojo's MCP server can stream just the event range around a failure to an agent like Claude Code or Cursor for triage, instead of forcing a full-session download or a human-only player. No competing replay vendor exposes a chunk-addressable, agent-readable replay artifact over MCP.

Implementation checklist

  • Two-stage compression: pack with fflate in the browser for the network hop, then decode and recompress the whole chunk server-side.
  • Brotli for cold objects, gzip for streaming: quality 4-6 is plenty; the snapshot dominates, so quality 11 rarely pays.
  • Dedupe inlined CSS so repeated stylesheets are stored once, per the rrweb storage recipe.
  • Logs to S3, pointer to Postgres: store the key, byte size, and event count — never the raw JSONB blob.
  • Chunk to low megabytes and write a manifest with byte range and timestamp span per chunk.
  • Snapshot in chunk 0 so every later chunk is replayable from snapshot-plus-range.
  • Presigned URLs on read so decompressed bytes flow client-side and your API server never proxies the payload.
The one rule to remember

Match the storage layer to the data shape. The snapshot is heavy and one-time; the deltas are a tiny long tail. Compress the whole session, keep logs as S3 objects not JSONB, and chunk to the low-megabyte range with a manifest. Do that and a multi-megabyte raw session becomes a few cheap, seekable, agent-readable objects.

Capture, compress, and triage replays without building the pipeline

BugMojo's extension records rrweb sessions, console, and network in one click, stores them as chunk-addressable compressed S3 objects, and exposes them to AI agents over MCP.

Try BugMojo

Frequently asked questions

Frequently asked questions

Sources

  1. Benchmarking the impact of session recording on performance — PostHog (2024)
  2. Optimize the storage size (rrweb recipes) — rrweb (GitHub) (2024)
  3. How we reduced Replay SDK bundle size — Sentry (2024)
  4. Brotli vs GZIP: Improve page speed with HTTP compression — DebugBear (2024)
  5. Amazon S3 pricing — Amazon Web Services (2025)
  6. @rrweb/packer (fflate-based event compression) — npm (2024)
Share:
Hrishikesh Baidya
Hrishikesh Baidya· Chief Technology Officer

Hrishikesh Baidya is the CTO at Softech Infra. He is drawn to architecture that is invisible — systems that simply work — and leads the engineering behind BugMojo.

On this page

  • How do you compress rrweb replays for cheap storage at scale?
  • Why rrweb event streams balloon
  • Delta plus gzip or Brotli: pick per layer
  • The pipeline: pack in transit, recompress at rest
  • Console and network: S3 objects, not JSONB
  • Chunking: the size sweet spot
  • The read path: rebuilding a replay
  • Implementation checklist

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.