This is a related resarch for Vites trace view discussion vitest-dev/vitest#10156.
Note
This note may be shared publicly as extra background for the trace-view discussion. Sibling-repo references in this note assume these source repositories:
This note compares DOM snapshot/replay approaches at the technical architecture level.
It is intentionally not framed as "which API is nicer" or "what Vitest should adopt right now". The point is to separate the underlying implementation patterns:
- structural serialization + generic rebuild
- structural snapshot + resource virtualization
- runtime-assisted DOM transplant replay
- with full timeline record/replay treated as a layered concrete system rather than a peer bucket
This also clarifies an important ambiguity around rrweb: rrweb-snapshot and full rrweb are related but materially different layers.
The persisted representation produced at capture time.
Examples:
- a serialized DOM tree
- a DOM tree plus side metadata
- a frame snapshot plus archived resources
- a mutation/event timeline
The code and environment that consumes the capture artifact and turns it back into something viewable.
Capture as pure tree data that can be rebuilt into another document.
Capture CSS/images/fonts/etc as separate resource records or files.
Replay archived resources through a fetch-like layer so the rebuilt page still "loads" them by URL.
Replay depends on a cooperating restore runtime, not just the artifact itself. The runtime knows how to transplant DOM, reattach styles, replace iframes, add highlights, and so on.
A different layer from point-in-time snapshots. Instead of replaying one captured state, it replays a sequence of changes over time.
The useful comparison axes are:
- capture representation
- replay contract
- resource handling model
- artifact portability
- replay runtime dependency
- fidelity envelope
- implementation complexity
- extensibility
This is the rrweb-snapshot style.
Core idea:
- capture a DOM tree as pure serialized data
- rebuild that tree into another document with a generic rebuilder
- optionally store side metadata such as viewport, scroll, node ids, pseudo-class ids, or selected inlined assets
The artifact is fundamentally a serialized DOM tree, typically produced by snapshot(document, ...).
Snapshot API and defaults: packages/rrweb-snapshot/src/snapshot.ts#L1267
The important architectural trait is that this style tends to bake more replay-relevant state directly into the serialized DOM artifact itself. In rrweb terms, the tree is not just structure: it can carry replay-facing data such as stylesheet text, form state, shadow markers, optional image/canvas payloads, and stable node ids.
This is also where one of the main trade-offs appears.
For CSS, rrweb-snapshot primarily pulls in browser-visible stylesheet state through CSSOM APIs:
- for
<link rel="stylesheet">, it looks up the matchingCSSStyleSheetfromdocument.styleSheets - it stringifies the sheet through
rules/cssRules - if successful, it removes
rel/hrefand stores the stylesheet text in_cssText
CSS capture from document.styleSheets: packages/rrweb-snapshot/src/snapshot.ts#L588
CSS stringification via cssRules: packages/rrweb-snapshot/src/utils.ts#L114
For <style>, rrweb-snapshot similarly serializes style.sheet into _cssText.
Inline <style> capture: packages/rrweb-snapshot/src/snapshot.ts#L604
For images, rrweb-snapshot can optionally inline the currently loaded rendered image by:
- drawing the image into a canvas
- storing a data URL in
rr_dataURL
Optional image inlining: packages/rrweb-snapshot/src/snapshot.ts#L689
For canvas, rrweb-snapshot can optionally store a canvas data URL in the same general way.
Optional canvas capture: packages/rrweb-snapshot/src/snapshot.ts#L657
Defaults matter here:
inlineStylesheet = trueinlineImages = falserecordCanvas = false
Defaults: packages/rrweb-snapshot/src/snapshot.ts#L1268
Replay is generic rebuild into a target document, using rebuild(serialized, ...) plus whatever local post-processing the embedding viewer wants to add.
- rebuild the serialized DOM tree
- let the embedding layer optionally restore viewport/scroll/highlights/pseudo-classes
- consume baked-in resource fields such as
_cssTextorrr_dataURLduring rebuild
Rebuild API: packages/rrweb-snapshot/src/rebuild.ts#L192
For the resource side, replay consumes those baked-in artifact fields directly:
- a serialized
<link>with_cssTextis rebuilt as a<style> rr_dataURLon<img>can replace the image sourcerr_dataURLon<canvas>can be drawn back into the replay canvas
_cssText replay as <style>: packages/rrweb-snapshot/src/rebuild.ts#L58 and packages/rrweb-snapshot/src/rebuild.ts#L268
rr_dataURL replay for <img> / <canvas>: packages/rrweb-snapshot/src/rebuild.ts#L336 and packages/rrweb-snapshot/src/rebuild.ts#L355
- clean separation between artifact and viewer
- tree artifact is easy to store, ship, diff, and post-process
- replay does not require the original page session
- node-id based lookup works naturally across shadow DOM
- external resource fidelity is weak unless another layer is added
- rebuild is structural, not browser-session-faithful
- authored resource bytes and loading semantics are not inherently preserved
- dynamic runtime behavior is not replayed, only captured state
Compared with Playwright's trace snapshots, rrweb-style serialization is more self-contained at the DOM artifact layer. That usually makes the DOM artifact itself richer, but also pushes more responsibility into capture-time serialization and into the rebuilt tree representation.
The resource trade-off follows directly from those APIs:
- rrweb gets good results when browser-exposed CSS/image state can be captured directly from the live page
- but this is still mostly "capture what the browser currently exposes" rather than "archive original resource bytes with an independent replay server contract"
- cross-origin/inaccessible stylesheets, non-inlined images, fonts, and related subresources are where this model starts to show its limits
This is the Playwright-style family.
Core idea:
- capture structural frame snapshots as data
- archive fetched resources separately
- replay through a renderer/server layer that serves archived resources back by URL
Playwright's FrameSnapshot includes structural HTML, resource overrides, viewport, ids, and timestamps.
Shape: packages/trace/src/snapshot.ts#L40
This is not just "serialized DOM plus some convenience fields". The format is designed to cooperate with archived resources and multi-snapshot rendering.
At the same time, Playwright's DOM artifact is less self-sufficient than rrweb's if viewed in isolation. A meaningful part of replay fidelity is intentionally offloaded to the replay contract:
- archived
ResourceSnapshots resourceOverridesSnapshotStorageSnapshotServer
Replay is mediated by storage and server layers:
SnapshotStoragegroups frame snapshots with per-context resources and createsSnapshotRendererinstancesSnapshotServercan render snapshot HTML and serve resources back by request URL/method
Storage: packages/isomorphic/trace/snapshotStorage.ts#L24 Server: packages/isomorphic/trace/snapshotServer.ts#L22
This means the replay contract is closer to "render an archived browsing surface" than "rebuild a tree into a document".
Playwright's serializer is not necessarily simpler overall. The complexity is distributed differently:
- less resource payload is baked straight into the DOM snapshot
- more fidelity is recovered through the replay-time resource contract
- the snapshotter itself still supports incremental references and stylesheet override tracking
Injected snapshotter: packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts#L19 Capture pipeline: packages/playwright-core/src/server/trace/recorder/snapshotter.ts#L121
- stronger replay fidelity for CSS/images/fonts and other fetched assets
- artifact is substantially more self-sufficient at replay time
- preserves more of the page's original URL/resource behavior
- scales better to offline or trace-artifact scenarios
- much larger implementation surface
- requires resource storage, lookup, and replay infrastructure
- more moving parts around caching, content types, overrides, and URL normalization
This is the abstracted Cypress-style family.
Core idea:
- temporarily capture DOM state in memory while the test is running
- clone/import live DOM fragments into a transient document
- sanitize or replace parts that are unsafe to replay directly
- store styles and metadata in side channels
- restore that DOM state on the fly inside the same tested-app iframe environment for runner debugging
Cypress snapshots are not primarily "pure serialized DOM tree" artifacts. At a high level, the stored artifact is a restore-oriented object built around:
- a cloned
<body>subtree - document-level metadata needed to restore the page shell
- side-channel style data or style references
Capture/shape: packages/driver/src/cy/snapshots.ts#L124 Snapshot creation: packages/driver/src/cy/snapshots.ts#L149 Style side channel: packages/driver/src/cy/snapshots.ts#L106
Strictly speaking, this is not the same category as DOM snapshot serialization in the rrweb/Playwright sense. It is closer to a temporary in-memory DOM clone/import plus side-channel replay state.
Important clarification:
importNodeandadoptNodeare standard DOM APIs- Cypress builds its own restore-oriented snapshot object on top of those DOM primitives
- the model is best understood as temporary in-memory DOM-state capture and restore inside the same tested-app iframe environment, rather than as a standalone serialized DOM artifact
The important emphasis is:
- temporary, not durable artifact-first storage
- in-memory, not primarily serialized JSON/tree persistence
- same tested-app iframe runtime environment, not a separate replay surface
- on-the-fly restore for runner interaction/debugging
The capture flow is not "serialize DOM tree into JSON". It is closer to:
- collect document metadata and style side data
- clone the live
<body>into a transientDocumentwithsnapshotDocument.importNode(..., true) - sanitize the cloned body by replacing/removing things Cypress does not want to replay directly
- store the cloned body in a restore-oriented snapshot object
The key DOM API here is importNode, which clones a node from one document into another document.
Cloning into transient document: packages/driver/src/cy/snapshots.ts#L165 and packages/driver/src/cy/snapshots.ts#L176
The note in Cypress is important: they explicitly avoid plain cloneNode here because cloning can trigger side effects with custom elements, so they use importNode into a transient document instead.
Replay assumes a cooperating runtime that knows how to restore the snapshot:
- reset iframe origin constraints if necessary
- fetch styles through Cypress runtime helpers
- replace
<html>attrs - remove the old body
- insert the restored body
- re-apply highlights and runner overlays
Restore: packages/app/src/runner/aut-iframe.ts#L173
This is a different replay contract from generic rebuild. The replay runtime is a first-class part of the mechanism.
Replay again uses standard DOM APIs plus Cypress-owned restore logic:
- the stored body node is moved into the current tested-app iframe document with
adoptNode - Cypress restore code replaces document shell metadata such as
<html>attributes - Cypress restore code replaces/remaps styles
- the old body is removed and the adopted body is appended
The key DOM API here is adoptNode, which moves a node so that it belongs to the target document.
Adoption into current document: packages/driver/src/cy/snapshots.ts#L256 and packages/driver/src/cy/snapshots.ts#L270 Restore in tested-app iframe document: packages/app/src/runner/aut-iframe.ts#L192
- pragmatic when capture and replay live inside one controlled runner
- easy to integrate runner-specific highlighting and UI affordances
- can be simpler than generic rebuild for an in-runner debugger
- artifact is less self-describing than a pure serialized tree
- replay depends more on restore-runtime behavior and side channels
- portability is weaker than artifact-first approaches
- sanitization/replacement rules are part of correctness, not just presentation
full rrweb is best treated here as a higher-level concrete system built on top of the lower-level snapshot/rebuild primitive.
In practical terms:
rrweb-snapshotanswers "how do I serialize and rebuild DOM state?"- full
rrwebanswers "how do I record and replay DOM evolution over time?"
More concretely:
record(...)emits a typed event stream- that stream includes
Meta,FullSnapshot, andIncrementalSnapshot - the full snapshot path internally uses
snapshot(document, ...) - the replay side uses
Replayer, which rebuilds full snapshots and then applies incremental events over time
Recorder: packages/rrweb/src/record/index.ts#L65 Full snapshot emission: packages/rrweb/src/record/index.ts#L337 Incremental event sources: packages/types/src/index.ts#L62 Replayer: packages/rrweb/src/replay/index.ts#L117
So full rrweb is not a peer architecture bucket next to A/B/C in this note. It is a concrete layered system whose DOM-state foundation is closest to Architecture A, with mutation/event timeline machinery added on top.
A practical consequence of this sequence-aware design is that replay can share/dedupe state across snapshots/events rather than treating every captured point as a fully isolated artifact.
Vitest is currently in Architecture A.
It captures point-in-time rrweb snapshots per trace entry and adds Vitest-specific replay metadata around the raw rrweb tree:
- serialized rrweb snapshot tree
- viewport
- window scroll
selectorId- pseudo-class ids
Trace snapshot shape: packages/browser/src/client/tester/trace.ts#L29 Capture: packages/browser/src/client/tester/trace.ts#L104 Replay: packages/ui/client/components/trace/TraceView.vue#L46
Minor replay-specific detail:
- rrweb already has replay-oriented handling for
:hover-style visual restoration - Vitest extends this locally by recording pseudo-class ids and replaying
:hover,:focus, and:focus-within - Playwright's trace viewer appears to lean more on explicit target highlighting than on a comparable generic pseudo-class reapplication layer
- this is best viewed as visual replay polish, not as a major architecture distinction
Pseudo-class capture: packages/browser/src/client/tester/trace.ts#L126 Pseudo-class replay: packages/ui/client/components/trace/TraceView.vue#L63 Local rrweb patch: patches/rrweb-snapshot.patch Playwright highlight-oriented replay: packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts#L150 and packages/isomorphic/trace/snapshotRenderer.ts#L344
So Vitest today is not using:
- Playwright-style resource virtualization
- Cypress-style DOM transplant replay
- full rrweb event timeline replay
This also means current Vitest trace view does not currently exploit cross-snapshot sharing/dedup. Each trace entry is effectively stored as its own point-in-time snapshot payload, which keeps the implementation simple but can duplicate repeated DOM/resource state across steps.
Chromatic is structurally closest to Architecture A extended toward B.
Capture/replay primitive:
- rrweb snapshot JSON is the DOM artifact
- replay still uses rrweb rebuild
Replay in Storybook: packages/shared/storybook-config/preview.ts#L60
But Chromatic adds a resource archive layer:
- intercept network responses with CDP
Fetch - archive allowed-domain resources
- rewrite snapshot CSS/image URLs to archived local paths
Archiver: packages/shared/src/resource-archiver/index.ts#L31 Snapshot URL rewriting: packages/shared/src/write-archive/dom-snapshot.ts#L24
Technically, this is a hybrid:
- artifact starts as rrweb structural serialization
- portability is improved through separate resource capture and path rewriting
- replay is still rebuild-driven, not full resource virtualization
Playwright is Architecture B.
It uses its own frame/resource snapshot model and replay server contract rather than rrweb rebuild.
Snapshot format: packages/trace/src/snapshot.ts#L40 Storage: packages/isomorphic/trace/snapshotStorage.ts#L24 Resource-serving replay: packages/isomorphic/trace/snapshotServer.ts#L77
Playwright is also a good example of sequence-aware snapshot/replay paying off in dedup/sharing. Its snapshot model and replay contract can coordinate repeated state across many snapshots, instead of forcing each snapshot to be a completely standalone fully repeated blob.
Cypress is Architecture C.
It is useful to understand as a technical family, not merely as "a custom UI". The core distinction is the replay contract:
- temporary in-memory DOM-state storage
- body transplant
- style side channels
- same tested-app iframe runtime environment
- on-the-fly page/document restore inside that iframe
- runner-managed highlighting and DOM normalization
Capture: packages/driver/src/cy/snapshots.ts#L149 Restore: packages/app/src/runner/aut-iframe.ts#L173
| Architecture | Capture artifact | Replay contract | Resource model | Portability | Runtime dependency | Main trade-off |
|---|---|---|---|---|---|---|
| A. Structural serialization + generic rebuild | Pure tree + small side metadata | Generic rebuild into target doc | Mostly leave URLs as-is, maybe inline selected assets | Good for DOM artifact portability | Low to medium | Simple and generic, but weak on resource fidelity |
| B. Structural snapshot + resource virtualization | Tree/frame data + archived resources | Renderer/server serves snapshot HTML and resources | Archived resources replayed by URL | Strong | Medium to high | Best fidelity, highest machinery |
| C. Runtime-assisted DOM transplant replay | DOM fragment + side channels | Restore/transplant into controlled runtime | Runtime restores styles/resources via helper logic | Moderate to weak | High | Practical in a runner, but less artifact-self-contained |
Side note:
- cross-snapshot sharing/dedup is an orthogonal concern rather than a pure A/B/C split
- sequence-aware systems such as Playwright trace or full rrweb can reduce duplication across many snapshots/events
- current Vitest trace view is closer to independent per-step point-in-time snapshots, which simplifies the implementation but gives up that dedup opportunity unless another layer is added
The core jump from rrweb-style rebuild to Playwright-style replay is not "better DOM snapshots". It is adding a resource replay contract.
The important distinction is not that Cypress has its own viewer. Playwright also has its own viewer. The technical distinction is that Cypress-style replay is runtime-assisted DOM transplant, while Playwright-style replay is artifact-first resource-backed rendering.
For DOM-state capture alone, rrweb-style snapshots are more self-contained and replay-oriented at the artifact layer. Playwright's format keeps more fidelity responsibility in the replay contract. That does not make Playwright simpler overall; it means complexity is split differently between serializer and replay runtime.
Full rrweb should not be compared as if it were only another snapshot shape. It changes the system from state reconstruction to temporal replay.
Chromatic demonstrates that rrweb-style structural snapshots can be extended significantly by adding resource archiving and path rewriting, without going all the way to Playwright's replay server model.
The Cypress-style model is best understood as an extension of a live runner iframe paradigm, not as a durable replay-artifact architecture.
That has real consequences:
- replay fidelity depends on the original runtime still being alive in a compatible state
- replay is harder to treat as a durable, portable artifact
- switching freely across many tests/snapshots becomes harder unless orchestration keeps those runtime contexts alive
- residual global state can make replay look richer while also making it less isolated and less predictable
This makes the family feel closer to Vitest's current browser-iframe-view paradigm than to rrweb/Playwright-style artifact-first replay:
- Vitest current browser iframe view: inspect a live tested page inside an active iframe
- Cypress snapshot extension: temporarily capture and restore DOM state inside that same live iframe paradigm
- rrweb / Playwright: move toward replay from captured artifacts instead of from runtime liveness
So the architectural limitation is not accidental. It follows directly from relying on same-runtime, on-the-fly restore.
This also shows up directly in user-facing behavior:
- Cypress gates normal snapshot restore while tests are still running
- Playwright can support live/in-progress snapshot display because it renders snapshots into dedicated replay surfaces rather than mutating the live tested page context
Cypress running-test gate: packages/app/src/runner/iframe-model.ts#L112 and packages/reporter/src/commands/command.tsx#L448 Playwright live snapshots: packages/playwright-core/src/server/trace/recorder/tracing.ts#L178, packages/playwright-core/src/server/trace/recorder/tracing.ts#L463, and packages/trace-viewer/src/ui/snapshotTab.tsx#L191
It helps to separate at least three notions of fidelity:
- DOM-state fidelity
- resource fidelity
- runtime/JS fidelity
DOM-state fidelity asks whether the replay reproduces the rendered DOM structure and state:
- element tree
- attributes and form values
- shadow DOM
- scroll/viewport metadata
- selected pseudo-class state
Resource fidelity asks whether the replay reproduces the same CSS/images/fonts/subresources that the original page used.
Runtime/JS fidelity asks whether the replayed page still has the original executable runtime behavior:
- event listeners still wired
- framework/component instances still alive
- timers, observers, and app state machines still running
- custom component logic still executing
These are different problems. A system can have strong DOM-state fidelity while having weak runtime/JS fidelity.
In the rrweb/Playwright family, the core promise is DOM-state replay, not original JS runtime replay.
For rrweb-style rebuild:
- the rebuilt document is reconstructed from serialized structure
- it is not the original JS heap or component instance graph
- custom elements and shadow DOM can replay structurally, but not as the original running app unless the replay environment separately provides that runtime
For Playwright trace:
- the replay is stronger on resources and browser-surface fidelity
- but it is still not a resurrection of the original executing page runtime
- the trace viewer renders archived state through its replay contract rather than resuming the application process
So custom element snapshots are supported here in the sense of rendered DOM state, not in the sense of reviving framework/component logic.
Cypress is even clearer on this point.
Its snapshot capture removes scripts from the stored body:
Script/style stripping: packages/driver/src/cy/snapshots.ts#L129 and packages/driver/src/cy/snapshots.ts#L187
Replay restores DOM and styles by transplant:
Restore: packages/app/src/runner/aut-iframe.ts#L173
That means Cypress snapshots should not be described as "replaying JS" in the strong sense. The mechanism is much closer to:
- clone/import DOM
- strip executable/script-loading pieces
- restore body/html/styles inside a controlled tested-app iframe document
- overlay Cypress-specific highlighting
The important fidelity point is that clone/import by themselves do not preserve ordinary JS listener/runtime wiring:
- DOM cloning/import preserves structure and attributes
- it does not carry over typical
addEventListener(...)registrations - it does not preserve arbitrary JS object graphs or app/framework instance state
So if a restored Cypress snapshot looks "live", that should not usually be attributed to the snapshot artifact preserving JS behavior. It is more likely because the restore happens inside the same already-loaded tested-app iframe runtime environment.
Concrete example:
- if original code registered
document.addEventListener('click', handler)orwindow.addEventListener(...) - and Cypress replay restores DOM inside that same live document/window runtime context
- that global listener can still remain effective during replay
By contrast:
- if original code registered
someElement.addEventListener('click', handler)on a DOM node inside the old body - Cypress snapshot clone/import does not preserve that listener wiring on the cloned/restored DOM subtree
- and replay also removes/replaces the old body subtree
So a global listener on document/window can make replay appear "JS-live" in Cypress, while ordinary element-bound listeners are much less likely to survive through the snapshot/restore mechanism itself.
If Cypress sometimes appears to "replay JS", that is more likely one of:
- the tested app/runtime is still live in the runner outside the pinned snapshot flow
- the user is seeing the currently mounted/live component rather than a purely restored snapshot
- some browser-native behavior still works on the restored DOM
- the current runtime environment still has globals, custom-element definitions, or other loaded code that can interact with the restored DOM
But the snapshot mechanism itself is not reviving the original component/app runtime.
Custom elements fit cleanly into DOM-state fidelity, but only at that level.
What can be preserved well in rrweb/Playwright-style paradigms:
- the rendered custom element tag in the DOM tree
- its attributes and light DOM
- its open shadow DOM content, when captured
- form/control state reflected into DOM properties or serialized attributes
What is not inherently preserved:
- the original custom element class instance identity
- constructor/connectedCallback side effects as they originally happened
- framework/runtime objects behind the element
- arbitrary event listener graphs and live JS execution state
So for custom elements, "snapshot/replay works" means:
- the replay can reconstruct the element's DOM-state representation
- it does not mean the original component runtime has been resumed
This is precisely where rrweb/Playwright-style approaches are strong: they are good at preserving the browser-observable DOM state of a custom element. That is enough for visual inspection and DOM-state debugging, even though it is not equivalent to reviving the original executing element instance.
For Cypress specifically, the effect can be more confusing because restore happens inside the same tested-app iframe runtime environment.
Concrete example:
- original code runs
customElements.define('my-el', MyElement) - that registration lives on the window's
CustomElementRegistry - Cypress later restores DOM containing
<my-el>inside that same live runtime environment
So the restored custom element can appear partially live because the global custom-element registration effect may still exist at replay time.
But that is still different from the snapshot mechanism itself preserving:
- the original custom element instance identity
- the original listener graph attached to the old subtree
- the original framework/app runtime state behind that instance
In other words, Cypress can inherit live global runtime effects that influence restored custom elements, even though the snapshot/restore mechanism itself is still primarily preserving DOM state rather than true JS-runtime state.
- How far can Architecture A be pushed before resource fidelity forces a move toward B?
- Is Chromatic's "archive + rewrite" middle ground sufficient for most offline replay needs?
- Which problems genuinely require timeline replay rather than repeated point-in-time snapshots?
- How should adopted stylesheets, cross-origin CSS, fonts, canvas, and iframes be classified across these architectures?