# Why You Can't Record Only the Sessions That Break — and the Config That Gets You Closest

> Session Replay streams to the backend as it records, so starting the recorder from an error handler never shows what caused the error. Here's the mechanism, the config that recovers most of the value, and the session-expiry catch that quietly breaks it.

Source: https://openobserve.ai/blog/record-session-replay-only-for-sessions-with-errors/
Published: 2026-08-11
Authors: Bhargav Patel
Category: Engineering
Tags: RUM, Session Replay, Frontend Monitoring, Error Tracking, Observability

---

## TL;DR

A question we get often, in one form or another:

> I don't need to record every session, only the sessions that hit an error. I call `startSessionReplayRecording()` from my error handler, but the replay only starts *after* the error, so I never see what caused it. Can the SDK record continuously and only send the recording when something breaks?

The honest answer is no, and the reason is worth understanding rather than working around: **in this SDK, "recording" and "sending" are the same activity.** Replay data leaves the browser in compressed segments every few seconds. There is no rolling buffer sitting in memory waiting for a decision — by the time your error fires, the context you wanted is either already on the server or was never captured at all.

What you *can* do is make the first error mark the session, so everything after it is recorded in full — including across page loads. That takes one non-obvious init flag, and there's a session-expiry catch that silently undoes it. Both are below.

## The instinct, and why it fails

The reasoning is sound: replays are only interesting when something goes wrong, so record everything and keep the interesting ones. Every engineer arrives at some version of this. The naive implementation looks like this, and it does not do what it appears to do:

```javascript
openobserveRum.init({
  sessionSampleRate: 100,
  sessionReplaySampleRate: 0,   // don't record by default
});

window.addEventListener('error', () => {
  openobserveRum.startSessionReplayRecording({ force: true });
});
```

You get a replay. It starts at the error. The first frame is the error toast.

To see why, you have to look at what the recorder actually is.

## How the recorder actually works

Session Replay is not a screen recorder holding a video in memory. It's an event recorder with a short, aggressively flushed buffer:

1. **On start**, the SDK serializes a **full DOM snapshot** of the page, then attaches observers for mutations, input, scroll, mouse movement and viewport changes.
2. Records accumulate into a compressed **segment** in memory.
3. That segment is finalized and POSTed to the replay intake — `/rum/{apiVersion}/{organization}/replay` — as soon as **any** of four things happens:

| Flush trigger | When it fires |
|---|---|
| Segment duration limit | ~5 seconds after the first record in the segment |
| Segment size limit | The encoded segment passes roughly 60 KB |
| View change | A route change or navigation creates a new view |
| Page exit | The tab is hidden or unloading — sent with `sendBeacon` so it survives |

Four independent triggers, the slowest of which is five seconds. That's the whole story.

## The timeline that makes it concrete

Take a checkout session that fails at 02:14.

**Case A — `sessionReplaySampleRate: 0`, recording started from the error handler**

| Time | User | SDK | In browser memory |
|---|---|---|---|
| 00:00 | Loads `/cart` | Recorder not running. No snapshot, no observers. | nothing |
| 00:00–02:13 | Browses, edits quantity, goes to `/checkout`, fills the form | Nothing. The DOM changes; nobody is watching. | nothing |
| 02:14 | Clicks **Pay** → error | Error handler fires → recorder loads → **full DOM snapshot of the page as it is right now** | segment opens |
| 02:19 | — | First segment flushed | emptied |

**Case B — recording from page load**

| Time | SDK | In browser memory |
|---|---|---|
| 00:00 | Snapshot + observers. Segment 1 opens. | 0–5s of data |
| 00:05 | Flush (duration limit) | emptied |
| 00:10, 00:15, … | Flush every ~5s | never more than ~5s |
| 01:30 | `/cart` → `/checkout` → flush (view change) | emptied |
| 02:10 | Segment opens | |
| **02:14** | **Error — lands inside this segment** | ~4s of data |
| 02:15 | Flush | emptied |

Now look at the memory column at 02:14 in Case B. When the error fires, the browser is holding **about four seconds** of replay. The two minutes of context you actually want have been sitting on your OpenObserve server for a while already.

So even a hypothetical `flushBufferNow()` API would hand you four seconds. **The pre-error context was never withheld pending a decision — it was already sent (Case B) or never existed (Case A).** There is no third state where it sits in the browser awaiting your verdict. That's why "record always, send only on error" isn't a missing feature; it's a contradiction in terms. Recording *is* sending.

The same reasoning kills the mirror-image idea. `stopSessionReplayRecording()` **flushes** the pending segment rather than dropping it — but even if it discarded, it would be discarding a few seconds. Everything else is long gone.

One more detail about Case A that people miss: the DOM snapshot taken at 02:14 captures the page in its **post-error** state. The replay doesn't just start late — it opens on the symptom.

## The config that gets you closest

You can't recover the first error's cause. You *can* make sure it's the only one you miss.

`force: true` doesn't just start the recorder — it writes `forcedReplay=1` into the **persisted session state**. That state lives in a cookie, so the session stays forced for its remaining lifetime, across full page loads and navigations.

```javascript
openobserveRum.init({
  applicationId: 'web-application-id',
  clientToken: '<your_rum_client_token>',
  site: 'your-openobserve-host',
  organizationIdentifier: 'default',
  service: 'my-web-application',
  env: 'production',
  version: '1.0.0',

  sessionSampleRate: 100,
  sessionReplaySampleRate: 0,             // no baseline recording
  startSessionReplayRecordingManually: false,   // ← the line that makes it work

  defaultPrivacyLevel: 'mask-user-input',

  beforeSend: (event) => {
    if (event.type === 'error') {
      openobserveRum.startSessionReplayRecording({ force: true });
    }
    return true;
  },
});
```

Two choices in there are doing real work.

**`beforeSend` instead of `window.onerror`.** This hook sees every error RUM itself collects — unhandled exceptions, unhandled promise rejections, console errors, reported network failures, and your own `addError()` calls — so you don't wire up each source separately. Only an explicit `return false` drops an event, so returning `true` is safe. And `startSessionReplayRecording()` is idempotent: if recording is already starting or started, the call returns immediately.

**`startSessionReplayRecordingManually: false`, stated explicitly.** This is the one that isn't obvious, and it's the difference between the approach working and silently doing nothing.

## The default that quietly cancels it

Here's the rule that catches people:

```
startSessionReplayRecordingManually  defaults to  (sessionReplaySampleRate === 0)
```

Setting `sessionReplaySampleRate: 0` — which you must, to avoid baseline recording — silently gives you `startSessionReplayRecordingManually: true`. And that means the SDK will **never start recording on its own after a page load**. It waits for your code.

Follow the two configs through a real session:

**Without the flag** (defaults in play):

- **Page 1, `/checkout`** — error at 02:14 → `force: true` → session marked forced → records from 02:14 to the end of the page. ✅
- **User clicks through to `/order/123`** (full page load) — the SDK initializes. The cookie still says `forcedReplay=1`. But manual-start is on, so the SDK doesn't auto-start, and your `beforeSend` hook only fires on the *next* error. **Recording is silently dead.** ❌

The forced flag is sitting right there in the cookie, correct and useless, because nothing calls `start()`.

**With `startSessionReplayRecordingManually: false`:**

- **Page 2** — the SDK auto-attempts a start on load → checks session state → not sampled (rate is 0), but `forcedReplay=1` → replay state resolves to **FORCED** → recording begins **at first paint**, full DOM snapshot included. ✅
- **A normal user who never errored** — same auto-attempt → not sampled, not forced → state resolves to **OFF** → the SDK declines and sends nothing. The recorder chunk and compression worker are never even fetched. ✅

That last point matters for the cost argument: setting the flag to `false` costs you nothing on the sessions that are fine. It only changes behavior for sessions already marked forced.

## What this actually buys you — and the catch

Be clear-eyed about the shape of what you capture:

| Phase of the session | Recorded? |
|---|---|
| Everything before the first error | No |
| First error → end of that page | Yes |
| Every later page load in the same session | Yes, from first paint |
| **After the session expires** | **No** |

That last row is the catch, and it's the one most write-ups skip.

**When a session expires, the forced flag is dropped.** The SDK builds a fresh session state containing only the expiry marker (plus the anonymous id, if you track it). `forcedReplay` doesn't survive. Sessions expire after **15 minutes of inactivity** or **4 hours total**, whichever comes first.

So the most common real-world pattern — user hits the error, gets annoyed, wanders off, comes back 20 minutes later and hits it again — is **not** captured. New session, not forced, recorder off.

This approach is genuinely good at errors that repeat *quickly*: validation loops, broken retries, a component that keeps failing to render, cascading API failures. It's weak at exactly the frustrated-user pattern you most want to see.

## Closing the gap

Three complements, in rough order of how much context they recover:

**Record everything and filter at query time.** `sessionReplaySampleRate: 100`, then find the failures via **RUM → Error Tracking → Affected Sessions**, or by filtering the Sessions list on error count. This is the only approach that reliably captures a *first* occurrence, because the selection happens at query time — when you already know which sessions mattered — instead of requiring you to predict failure in advance.

The instinct to avoid this comes from per-session SaaS replay pricing. OpenObserve stores replay segments as ordinary stream data in your own infrastructure, so the lever is a **shorter retention period on the replay stream** than on the rest of your RUM data. Replays get watched within days; aggregate RUM metrics stay useful for months. Set retention accordingly and the cost argument mostly evaporates.

**Scope recording to the flows that break.** Start the recorder when the user enters checkout, signup, onboarding, bulk import — whatever generates your support tickets — and stop when they leave. Full cause-and-effect where it matters, at a fraction of the volume.

**Record their next session.** This is the direct fix for the session-expiry gap. Persist your own flag when an error occurs, and use it to record that browser's next visit from the first byte:

```javascript
const REPLAY_FLAG = 'oo_record_next_session';
const REPLAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

function shouldRecordThisSession() {
  const until = Number(localStorage.getItem(REPLAY_FLAG) || 0);
  if (!until) return false;
  if (Date.now() > until) {
    localStorage.removeItem(REPLAY_FLAG);
    return false;
  }
  return true;
}

const recordFromStart = shouldRecordThisSession();

openobserveRum.init({
  // ...other options
  sessionSampleRate: 100,
  sessionReplaySampleRate: recordFromStart ? 100 : 0,
  startSessionReplayRecordingManually: !recordFromStart,

  beforeSend: (event) => {
    if (event.type === 'error') {
      localStorage.setItem(REPLAY_FLAG, String(Date.now() + REPLAY_WINDOW_MS));
      openobserveRum.startSessionReplayRecording({ force: true });
    }
    return true;
  },
});
```

Unlike the session cookie, `localStorage` outlives session expiry. Users who never hit an error cost nothing; users who did are recorded end to end on their next visit — which is precisely when they're most likely to reproduce the problem.

## Verify it yourself in five minutes

Don't take the flush cadence on faith. With recording active, open DevTools → **Network**, filter by `replay`, and:

1. Move the mouse and scroll → a `POST` to `.../rum/v1/{org}/replay` roughly every 5 seconds.
2. Navigate to another route → an extra request fires immediately.
3. Switch browser tabs → another fires immediately.
4. Sit still for 30 seconds → requests stop. No DOM changes means no records, so nothing to flush.

That cadence *is* the argument. Data leaves the browser continuously. Nothing accumulates.

Now check the forced flag. In the console, before any error:

```javascript
document.cookie.split('; ').find(c => c.startsWith('_oo_s_v2='));
// → "_oo_s_v2=id=<uuid>&created=...&expire=..."   (no forcedReplay)
```

Trigger one:

```javascript
openobserveRum.addError(new Error('replay test'));
```

Check again — `&forcedReplay=1` is now in the cookie, and replay requests are flowing. Then **hard-reload** and watch the Network tab:

- Without `startSessionReplayRecordingManually: false` → no replay requests. The flag is set; recording never resumed.
- With it → requests start within ~5 seconds of load.

One line of config, and that's the whole difference.

## Which should you run?

If your infrastructure can absorb it, **record everything and shorten retention**. It's the only option that never misses a first occurrence, and it removes an entire category of "we didn't capture it" from your debugging. Every conditional scheme is a bet that you can predict which sessions matter, placed before you have the information needed to make that call.

If you can't, layer the rest: flow-scoped recording for the paths you know are risky, force-on-error so repeats come with context, and the localStorage flag so a user who hits a bug is fully recorded next time. Start with a modest baseline sample rate, watch your replay stream volume for a week, and adjust.

What you shouldn't do is ship the naive error handler and assume you're covered. It produces replays that open on the error — which looks like it's working right up until the moment you need one.

## Further reading

- [Recording Only Sessions With Errors](https://openobserve.ai/docs/user-guide/data-exploration/rum/error-triggered-session-replay/) — the full guide, with working code for each strategy
- [Session Replay](https://openobserve.ai/docs/user-guide/data-exploration/rum/session-replay/) — the player, privacy controls, and day-to-day usage
- [RUM Error Tracking](https://openobserve.ai/docs/user-guide/data-exploration/rum/error-tracking/) — finding and grouping the errors you want replays for
