User Behavior Recording Techniques: Video, Screenshot, and DOM Snapshot (rrweb) Comparison and Implementation

This article examines various user behavior recording methods—including WebRTC video capture, canvas-based screenshot recording, and DOM snapshot recording with rrweb—detailing their technical implementations, advantages, limitations, and suitable application scenarios for product analysis, debugging, and automated testing.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
User Behavior Recording Techniques: Video, Screenshot, and DOM Snapshot (rrweb) Comparison and Implementation

Problem Background

In many projects we rely on click and page‑view (PV) tracking to collect user actions, but these points cannot capture contextual usage scenarios needed by product, development, and testing teams.

Product : Need the real usage path to verify that user behavior matches design expectations.

Development : System alerts indicate an error but not the reproduction steps, especially for intermittent issues.

Testing : When users report bugs, the exact steps are often unknown, leading to high communication cost.

Therefore we need a way to record a continuous sequence of user actions—including clicks, scrolls, inputs—and replay them faithfully.

Technical Solutions

2.1 Video Recording (WebRTC)

WebRTC provides real‑time media streams. The relevant APIs are mediaDevices.getDisplayMedia(), new MediaRecorder(), and the ondataavailable event.

Recording flow:

Call mediaDevices.getDisplayMedia() to obtain screen stream after user permission.

Create a new MediaRecorder() for the stream.

Listen to ondataavailable to collect Blob data.

<code style="padding: 16px; color: #abb2bf; display: -webkit-box; font-family: Operator Mono, Consolas, Monaco, Menlo, monospace; font-size: 12px"><span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">template</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">video</span> <span style="color: #d19a66; line-height: 26px">ref</span>=<span style="color: #98c379; line-height: 26px">\"playerRef\"</span>></span><span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">video</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleStart\"</span>></span>开启录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handlePause\"</span>></span>暂停录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleResume\"</span>></span>继续录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleStop\"</span>></span>结束录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleReplay\"</span>></span>播放录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleReset\"</span>></span>重置内容<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">button</span>></span><br/><span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">template</span>></span><br/><br/><span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">script</span> <span style="color: #d19a66; line-height: 26px">lang</span>=<span style="color: #98c379; line-height: 26px">\"ts\"</span> <span style="color: #d19a66; line-height: 26px">setup</span>></span><span style="line-height: 26px"><br/><span style="color: #c678dd; line-height: 26px">import</span> { ref, reactive } <span style="color: #c678dd; line-height: 26px">from</span> <span style="color: #98c379; line-height: 26px">\'vue\'</span>;<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> playerRef = ref();<br/><span style="color: #c678dd; line-height: 26px">const</span> state = reactive({<br/>  <span style="color: #d19a66; line-height: 26px">mediaRecorder</span>: <span style="color: #56b6c2; line-height: 26px">null</span> <span style="color: #c678dd; line-height: 26px">as</span> <span style="color: #56b6c2; line-height: 26px">null</span> | MediaRecorder,<br/>  <span style="color: #d19a66; line-height: 26px">blobs</span>: [] <span style="color: #c678dd; line-height: 26px">as</span> Blob[],<br/>});<br/>  <br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// 开始录制</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handleStart = <span style="color: #c678dd; line-height: 26px">async</span> () => {<br/>  <span style="color: #c678dd; line-height: 26px">const</span> stream = <span style="color: #c678dd; line-height: 26px">await</span> navigator.mediaDevices.getDisplayMedia();<br/>  state.mediaRecorder = <span style="color: #c678dd; line-height: 26px">new</span> MediaRecorder(stream, {<br/>    <span style="color: #d19a66; line-height: 26px">mimeType</span>: <span style="color: #98c379; line-height: 26px">\'video/webm\'</span>,<br/>  });<br/>  state.mediaRecorder.addEventListener(\'dataavailable\', (e: BlobEvent) => {<br/>    state.blobs.push(e.data);<br/>  });<br/>  state.mediaRecorder?.start();<br/>};<br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// canvas录制(特殊处理)</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handleCanvasRecord = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> {<br/>  <span style="color: #c678dd; line-height: 26px">const</span> stream = canvas.captureStream(<span style="color: #d19a66; line-height: 26px">60</span>); <span style="color: #5c6370; font-style: italic; line-height: 26px">// 60 FPS recording</span><br/>  <span style="color: #c678dd; line-height: 26px">const</span> recorder = <span style="color: #c678dd; line-height: 26px">new</span> MediaRecorder(stream, {<br/>    <span style="color: #d19a66; line-height: 26px">mimeType</span>: <span style="color: #98c379; line-height: 26px">\'video/webm;codecs=vp9\'</span>,<br/>  });<br/>  recorder.ondataavailable = <span style="line-height: 26px">(e) =></span> {<br/>    state.blobs.push(e.data);<br/>  };<br/>}<br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// 暂停录制</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handlePause = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> { state.mediaRecorder?.pause() };<br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// 继续录制</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handleResume = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> { state.mediaRecorder?.resume() };<br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// 停止录制</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handleStop = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> { state.mediaRecorder?.stop() };<br/><span style="color: #5c6370; font-style: italic; line-height: 26px">// 播放录制</span><br/><span style="color: #c678dd; line-height: 26px">const</span> handleReplay = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> {<br/>  <span style="color: #c678dd; line-height: 26px">if</span> (state.blobs.length === <span style="color: #d19a66; line-height: 26px">0</span> || !playerRef.value) <span style="color: #c678dd; line-height: 26px">return</span>;<br/>  <span style="color: #c678dd; line-height: 26px">const</span> blob = <span style="color: #c678dd; line-height: 26px">new</span> Blob(state.blobs, { <span style="color: #d19a66; line-height: 26px">type</span>: <span style="color: #98c379; line-height: 26px">\'video/webm\'</span> });<br/>  playerRef.value.src = URL.createObjectURL(blob);<br/>  playerRef.value.play();<br/>};<br/>   <br/><span style="color: #c678dd; line-height: 26px">const</span> handleReset = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> {<br/>  state.blobs = [];<br/>  state.mediaRecorder = <span style="color: #56b6c2; line-height: 26px">null</span>;<br/>  playerRef.value.src = <span style="color: #56b6c2; line-height: 26px">null</span>;<br/>};<br/><span style="color: #c678dd; line-height: 26px">const</span> handleDownload = <span style="line-height: 26px"><span style="line-height: 26px">()</span> =></span> {<br/>  <span style="color: #c678dd; line-height: 26px">if</span> (state.blobs.length === <span style="color: #d19a66; line-height: 26px">0</span>) <span style="color: #c678dd; line-height: 26px">return</span>;<br/>  <span style="color: #c678dd; line-height: 26px">const</span> blob = <span style="color: #c678dd; line-height: 26px">new</span> Blob(state.blobs, { <span style="color: #d19a66; line-height: 26px">type</span>: <span style="color: #98c379; line-height: 26px">\'video/webm\'</span> });<br/>  <span style="color: #c678dd; line-height: 26px">const</span> url = URL.createObjectURL(blob);<br/>  <span style="color: #c678dd; line-height: 26px">const</span> a = <span style="color: #e6c07b; line-height: 26px">document</span>.createElement(\'a\');<br/>  a.href = url;<br/>  a.style.display = \'none\';<br/>  a.download = \'record.webm\';<br/>  a.click();<br/>};<br/></span><span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">script</span>></span><br/></code>

Issues with this approach:

Requires user consent and visible UI, making recording perceptible.

Cannot mask sensitive data in the video.

Browser compatibility varies.

2.2 Page Screenshot (html2canvas)

By periodically capturing canvas snapshots with html2canvas and playing them back at the same frame rate, we can simulate recording.

Problems:

Canvas cannot capture animations, may produce layout shifts.

High performance cost and large resource size (e.g., 200 KB per image).

Masking elements removes them entirely, affecting layout.

<code style="padding: 16px; color: #abb2bf; display: -webkit-box; font-family: Operator Mono, Consolas, Monaco, Menlo, monospace; font-size: 12px"><span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">template</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">el-button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleStart\"</span>></span>开启录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">el-button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">el-button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleStop\"</span>></span>停止录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">el-button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">el-button</span> @<span style="color: #d19a66; line-height: 26px">click</span>=<span style="color: #98c379; line-height: 26px">\"handleReplay\"</span>></span>播放录制<span style="line-height: 26px"></<span style="color: #e06c75; line-height: 26px">el-button</span>></span><br/>  <span style="line-height: 26px"><<span style="color: #e06c75; line-height: 26px">img</span> :src=<span style="color: #98c379; line-height: 26px">\"state.imgs[state.num ?? 0]\"</span> /></span><br/></<span style="color: #e06c75; line-height: 26px">template</span>><br/><br/><<span style="color: #e06c75; line-height: 26px">script</span> <span style="color: #d19a66; line-height: 26px">lang</span>=<span style="color: #98c379; line-height: 26px">\"ts\"</span> <span style="color: #d19a66; line-height: 26px">setup</span>><br/><span style="color: #c678dd; line-height: 26px">import</span> { reactive } <span style="color: #c678dd; line-height: 26px">from</span> <span style="color: #98c379; line-height: 26px">\'vue\'</span>;<br/><span style="color: #c678dd; line-height: 26px">import</span> html2canvas <span style="color: #c678dd; line-height: 26px">from</span> <span style="color: #98c379; line-height: 26px">\'html2canvas\'</span>;<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> state = reactive({<br/>  <span style="color: #d19a66; line-height: 26px">visible</span>: <span style="color: #56b6c2; line-height: 26px">false</span>,<br/>  <span style="color: #d19a66; line-height: 26px">imgs</span>: [] <span style="color: #c678dd; line-height: 26px">as</span> string[],<br/>  <span style="color: #d19a66; line-height: 26px">num</span>: <span style="color: #d19a66; line-height: 26px">0</span>,<br/>  <span style="color: #d19a66; line-height: 26px">recordInterval</span>: <span style="color: #56b6c2; line-height: 26px">null</span> <span style="color: #c678dd; line-height: 26px">as</span> any,<br/>  <span style="color: #d19a66; line-height: 26px">replayInterval</span>: <span style="color: #56b6c2; line-height: 26px">null</span> <span style="color: #c678dd; line-height: 26px">as</span> any,<br/>});<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> FPS = <span style="color: #d19a66; line-height: 26px">30</span>;<br/><span style="color: #c678dd; line-height: 26px">const</span> interval = <span style="color: #d19a66; line-height: 26px">1000</span> / FPS;<br/><span style="color: #c678dd; line-height: 26px">const</span> handleStart = <span style="color: #c678dd; line-height: 26px">async</span> () => {<br/>  handleReset();<br/>  state.recordInterval = setInterval(() => {<br/>    <span style="color: #c678dd; line-height: 26px">if</span> (state.imgs.length > <span style="color: #d19a66; line-height: 26px">100</span>) {<br/>      handleStop();<br/>      <span style="color: #c678dd; line-height: 26px">return</span>;<br/>    }<br/>    html2canvas(document.body).then((canvas: any) => {<br/>      <span style="color: #c678dd; line-height: 26px">const</span> img = canvas.toDataURL();<br/>      state.imgs.push(img);<br/>    });<br/>  }, interval);<br/>};<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> handleStop = () => {<br/>  state.recordInterval && clearInterval(state.recordInterval);<br/>};<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> handleReplay = <span style="color: #c678dd; line-height: 26px">async</span> () => {<br/>  state.recordInterval && clearInterval(state.recordInterval);<br/>  state.num = <span style="color: #d19a66; line-height: 26px">0</span>;<br/>  state.visible = <span style="color: #56b6c2; line-height: 26px">true</span>;<br/>  state.replayInterval = setInterval(() => {<br/>    <span style="color: #c678dd; line-height: 26px">if</span> (state.num >= state.imgs.length - <span style="color: #d19a66; line-height: 26px">1</span>) {<br/>      clearInterval(state.replayInterval);<br/>      <span style="color: #c678dd; line-height: 26px">return</span>;<br/>    }<br/>    state.num++;<br/>  }, interval);<br/>};<br/><br/><span style="color: #c678dd; line-height: 26px">const</span> handleReset = () => {<br/>  state.imgs = [];<br/>  state.recordInterval = <span style="color: #56b6c2; line-height: 26px">null</span>;<br/>  state.replayInterval = <span style="color: #56b6c2; line-height: 26px">null</span>;<br/>  state.num = <span style="color: #d19a66; line-height: 26px">0</span>;<br/>};<br/></code>

2.3 DOM Snapshot Recording (rrweb)

rrweb records DOM changes as JSON snapshots and replays them. It consists of three parts: rrweb‑snapshot (snapshot & rebuild), rrweb (record & replay), and rrweb‑player (UI controls).

Recording process: a full‑page snapshot is taken, then mutation observers and event listeners capture incremental changes.

Replay process: snapshots are rebuilt in a sandboxed iframe, and events are replayed according to timestamps.

<code style="padding: 16px; color: #abb2bf; display: -webkit-box; font-family: Operator Mono, Consolas, Monaco, Menlo, monospace; font-size: 12px"><template><br/>  <button @click=\"handleStart\">开启录制</button><br/>  <button @click=\"handleStop\">结束录制</button><br/>  <button @click=\"handleReplay\">播放录制</button><br/>  <div class=\"replay\" ref=\"replayRef\"></div><br/></template><br/><br/><script lang=\"ts\" setup><br/>import { reactive, ref } from \"vue\";<br/>import * as rrweb from \"rrweb\";<br/>import rrwebPlayer from \"rrweb-player\";<br/>import \"rrweb-player/dist/style.css\";<br/><br/>const replayRef = ref();<br/>const state = reactive({<br/>  events: [] as any[],<br/>  stopFn: null as any,<br/>});<br/><br/>const handleStart = () => {<br/>  state.stopFn = rrweb.record({<br/>    emit(event) {<br/>      if (state.events.length > 100) {<br/>        // 当事件数量大于 100 时停止录制<br/>        handleStop();<br/>      } else {<br/>        state.events.push(event);<br/>      }<br/>    },<br/>  });<br/>  ElMessage(\'开始录制\');<br/>};<br/><br/>const handleStop = () => {<br/>  state.stopFn?.();<br/>  ElMessage(\'已停止录制\');<br/>};<br/><br/>const handleReplay = () => {<br/>  new rrwebPlayer({<br/>    target: replayRef.value, // 可以自定义 DOM 元素<br/>    props: { events: state.events },<br/>  });<br/>};<br/></script></code>

2.4 Solution Comparison

Aspect

Video Recording

Page Screenshot

DOM Snapshot

Open‑source library

WebRTC native

html2canvas

rrweb

User perception

Visible

Invisible

Invisible

Output size

Large

Large

Relatively small

Compatibility

Depends on API support

Partial

Good

Operability

Weak

Weak

Strong (supports masking, encryption)

Replay fidelity

Lossy, set at recording

Lossy, set at recording

High fidelity

Application Scenarios

#

Scenario

Description

1

Product feature analysis

Record real user paths to evaluate feature usage and guide optimization.

2

User interview recording

Capture actual user interactions during interviews for later review.

3

Issue reproduction

Preserve the exact steps leading to a bug to reduce communication overhead.

4

Automated test case generation

Convert recorded actions into test scripts.

5

Other

Case review, behavior monitoring, process quality inspection, etc.

Platform Solutions

Sentry and Hotjar both provide recording & replay features built on rrweb, with additional analytics such as heatmaps.

Conclusion

Dom‑snapshot recording with rrweb offers the best balance of fidelity, size, and operability compared with video capture and canvas screenshots, and is widely adopted in commercial solutions.

References

User behavior recording techniques – Juejin

Frontend recording and replay system – Juejin

Browser recording research – Zhihu

WebRTC quick start – Juejin

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontenduser behaviorVuerecordingWebRTCrrweb
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.