Migrating from rrweb to rrweb Cloud
Keep rrweb's event format and move ingestion, storage, and retrieval to rrweb Cloud. Choose the smallest migration that preserves behavior you actually need.
| Situation | Recommended path |
|---|---|
| Standard recorder configuration | Replace the transport with Browser Client |
| Custom recorder build or transport | Keep the recorder and send authenticated events manually |
Both paths use a public write key for browser ingestion. A public key can append events and metadata but cannot read recordings. Do not put a private API key in browser code.
Path 1: replace a standard transport with the Browser Client
If your integration calls record() with supported options and mainly uploads emit results, replace the transport with the Browser Client, which forwards rrweb options to record() and owns recording IDs, WebSocket delivery, buffering, and HTTP fallback.
import { start } from '@rrweb/browser-client';
start({
publicApiKey: 'public_key_rr_your_key',
blockSelector: '[data-private]',
maskTextSelector: '[data-mask]',
maskAllInputs: true,
sampling: {
mousemove: 100,
scroll: 200,
},
});Move privacy and recording options across unchanged, then compare a replay from each integration. Review Recording and privacy settings because the Browser Client supplies Cloud defaults for options you leave undefined.
If an existing emit callback performs local work, you may retain it. The Browser Client calls the callback and also sends the same event through its Cloud transport:
start({
publicApiKey: 'public_key_rr_your_key',
emit(event) {
updateLocalDiagnostics(event);
},
});Path 2: keep a custom recorder or transport
Keep manual ingestion when you run a patched recorder, transform the stream, or need a custom transport lifecycle; you become responsible for UUIDs, event order, retry semantics, authentication, buffering, and unload behavior.
Create and preserve the recording ID
Cloud requires a UUID in each ingestion route; for a browser tab that should continue across same-origin navigation, generate the UUID once and store it in sessionStorage.
One recording ID must identify one ordered rrweb stream.
const storageKey = 'my-app-rrweb-recording-id';
let recordingId = sessionStorage.getItem(storageKey);
if (!recordingId) {
recordingId = crypto.randomUUID();
sessionStorage.setItem(storageKey, recordingId);
}Separate tabs receive separate sessionStorage contexts. Clear the key at your session boundary to create a new recording. See Recording IDs for grouping multiple recording IDs with metadata.
Authenticate HTTP ingestion
For a strict custom transport, prefer serialized HTTP POST with one event per request. A successful status is not enough: the JSON response reports successful_rows and quarantined_rows, and a 2xx response can still contain a quarantined row. Send the next event only after the current response reports exactly one successful row and zero quarantined rows.
The route uses the public write key as an Authorization: Bearer header. This example keeps the head event queued until that acceptance check passes and halts, rather than automatically retrying, when delivery is rejected or uncertain:
function createHttpTransport({ recordingId, publicApiKey, fetchImpl = fetch }) {
const eventBuffer = [];
const encoder = new TextEncoder();
let flushInFlight = null;
let haltedError = null;
function enqueue(event) {
eventBuffer.push(event);
}
async function sendHead() {
if (haltedError) throw haltedError;
if (eventBuffer.length === 0) return;
const body = JSON.stringify(eventBuffer[0]);
try {
const response = await fetchImpl(
`https://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/events`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${publicApiKey}`,
'Content-Type': 'application/json',
},
body,
keepalive: encoder.encode(body).byteLength < 64_000,
},
);
if (!response.ok) {
throw new Error(`rrweb Cloud returned ${response.status}`);
}
let result;
try {
result = await response.json();
} catch {
throw new Error('rrweb Cloud returned an unreadable ingest result');
}
if (result?.successful_rows !== 1 || result?.quarantined_rows !== 0) {
throw new Error(
`rrweb Cloud did not accept exactly one event ` +
`(successful_rows=${String(result?.successful_rows)}, ` +
`quarantined_rows=${String(result?.quarantined_rows)})`,
);
}
eventBuffer.shift();
} catch (error) {
haltedError =
error instanceof Error ? error : new Error('Unknown ingest failure');
throw haltedError;
}
}
async function drain() {
while (eventBuffer.length > 0) await sendHead();
}
function flush() {
if (flushInFlight) return flushInFlight;
flushInFlight = drain().finally(() => {
flushInFlight = null;
});
return flushInFlight;
}
function state() {
return { halted: haltedError !== null, pending: eventBuffer.length };
}
function resolveHalt({ removeHead = false } = {}) {
if (flushInFlight) throw new Error('Wait for the current flush to settle');
if (!haltedError) return;
if (removeHead) eventBuffer.shift();
haltedError = null;
}
return { enqueue, flush, state, resolveHalt };
}Use the transport as the recorder's emit callback and schedule one flush path:
import { record } from '@rrweb/record';
const transport = createHttpTransport({
recordingId,
publicApiKey: 'public_key_rr_your_key',
});
const stopRecording = record({
emit: transport.enqueue,
blockSelector: '[data-private]',
maskAllInputs: true,
});
const flushTimer = setInterval(() => {
if (transport.state().halted) return;
void transport.flush().catch((error) => {
console.error('Ingestion halted with the head event retained', error);
});
}, 5_000);
document.addEventListener('visibilitychange', () => {
if (document.hidden && !transport.state().halted) {
void transport.flush().catch(console.error);
}
});The in-flight promise prevents overlapping POSTs, and drain() preserves event order by awaiting each acceptance result before sending the next event. Any failure leaves the head event in place and blocks later sends. Alert on that halted state and inspect the response, application logs, and recording before taking an explicit recovery action:
- If you confirm an uncertain request was accepted, or deliberately discard a quarantined event, call
resolveHalt({ removeHead: true })before flushing again. - If you confirm the event was not accepted and a retry is safe, call
resolveHalt()and flush again. A quarantined event usually needs correction rather than an unchanged retry.
The ingestion route has no idempotency key for event writes. A retry after a timeout, connection loss, or unreadable response can duplicate an event if Cloud committed it before the client lost the response. This queue is also memory-only. If a page termination, offline session, or crash must not lose events, persist the queue and its acknowledged position before capture, or use the Browser Client instead of treating keepalive as a delivery guarantee.
The equivalent command-line request is:
curl --request POST \
--url https://api.rrweb.com/recordings/550e8400-e29b-41d4-a716-446655440000/events \
--header 'Authorization: Bearer public_key_rr_your_key' \
--header 'Content-Type: application/json' \
--data '{"type":4,"timestamp":1760000000000,"data":{"href":"https://example.test/","width":1280,"height":720}}'Authenticate WebSocket ingestion
Browsers cannot add an Authorization header to a WebSocket handshake, so put the public write key in the token query parameter.
WebSocket is an advanced custom-transport choice, not a drop-in reliability layer. Measure frame size in bytes rather than JavaScript string length:
const publicApiKey = 'public_key_rr_your_key';
const wsUrl = new URL(
`wss://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/events/ws`,
);
wsUrl.searchParams.set('token', publicApiKey);
const socket = new WebSocket(wsUrl);
const encoder = new TextEncoder();
function sendFrame(event) {
const frame = JSON.stringify(event);
if (encoder.encode(frame).byteLength >= 1_000_000) {
throw new Error('Frame is too large; use the HTTP transport');
}
if (socket.readyState !== WebSocket.OPEN) {
throw new Error('Socket is not open; retain the event before retrying');
}
socket.send(frame);
}WebSocket.send() only hands a frame to the browser; it is not an application-level acknowledgement that Cloud durably accepted that event. After a disconnect, a client cannot infer which in-flight frames were accepted. Resending those uncertain frames through another transport can duplicate or reorder them.
For a custom WebSocket transport, design acknowledgement, sequence checkpointing, and resume behavior for your application before production. Otherwise use the serialized HTTP transport above or the Browser Client, whose lifecycle is maintained with the SDK.
Attach metadata during migration
Use the same recording ID and public write key to POST metadata to the recording's metadata endpoint, preferring stable opaque identifiers over names or email addresses.
async function addRecordingMetadata(metadata) {
const response = await fetch(
`https://api.rrweb.com/recordings/${encodeURIComponent(recordingId)}/metadata`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${publicApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(metadata),
},
);
if (!response.ok) {
throw new Error(`Metadata write failed with ${response.status}`);
}
}
await addRecordingMetadata({
user_id: 'user-123',
environment: 'migration-test',
ingestion_version: 'cloud-v1',
});See Application metadata for naming, updates, and server-side enrichment.
Verification before cutover
- Use a non-sensitive test page and a fresh recording ID.
- Confirm the initial Meta and FullSnapshot events arrive before incremental events.
- Find the recording in the dashboard and replay navigation, clicks, scrolls, and masked inputs.
- Confirm metadata filters return the test recording.
- Exercise WebSocket failure, POST retry, page navigation, and your session boundary.
- Compare event counts and replay duration with the existing pipeline.
Roll out with dual-write
Put Cloud delivery behind a feature flag and begin with internal traffic. During dual-write, pass each rrweb event to the old destination and the Cloud destination without mutating the event between sends. Use a dedicated Cloud recording ID map so retries do not accidentally join unrelated sessions.
Increase traffic only after dashboards, errors, accepted batches, stored metadata, and actual replays agree. Monitor storage volume as well as request success: a successful transport does not prove the recording is useful.
Roll back
Keep the old transport and its configuration deployable until the observation window ends. To roll back, disable the Cloud feature flag, stop creating new Cloud recording IDs, and continue the existing destination. Do not delete dual-written recordings during the incident; preserve them for comparison and follow your normal retention process afterward.
Once stable, remove dual-write code and document which system owns recording IDs, metadata, retry queues, and operational alerts.