Building a screen recorder in the browser sounds simple at first: just call getDisplayMedia() and feed it into a MediaRecorder.
But if you want to create a screen studio — with webcam overlays, custom backgrounds, and zero-latency UI — the complexity ramps up fast. This is the architecture behind Gravity Recorder, built entirely with React, the Canvas API, and a handful of modern browser APIs that most developers have never touched.
The Challenge: Compositing in Real-Time
A standard MediaRecorder takes a single stream. To have a screen and a webcam, you usually have to choose one or use CSS to overlay them. But if you want to record the combination as a single video file, you need to composite them.
Step 1: Capturing the Streams
We start by capturing the system audio, screen, and mic/webcam separately:
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { width: 2560, height: 1440 },
audio: true
});
const cameraStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
Step 2: The Canvas Engine
This is where the magic happens. We don't record the streams directly. Instead, we create a high-resolution <canvas> element and run a render loop at 60fps.
In each frame, we draw:
- The background (gradient or custom image)
- The screen capture (scaled and padded with letterboxing)
- The webcam (masked as a circle or rounded rect)
function renderFrame() {
ctx.drawImage(bgImage, 0, 0, 1920, 1080);
ctx.drawImage(screenVideo, screenX, screenY, screenW, screenH);
// Camera bubble with circular mask
ctx.save();
ctx.beginPath();
ctx.arc(camX, camY, radius, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(cameraVideo, camX - radius, camY - radius, radius * 2, radius * 2);
ctx.restore();
requestAnimationFrame(renderFrame);
}
We then capture the canvas as a stream and hand it to MediaRecorder:
const canvasStream = canvas.captureStream(60);
// Audio is added separately (see below)
const recorder = new MediaRecorder(canvasStream, {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 8_000_000
});
Mixing Audio Tracks
Getting clean audio out of a composited recording is harder than it looks. You have two audio sources — system audio from getDisplayMedia and microphone audio from getUserMedia — and you need to merge them into a single track before passing it to the recorder.
The Web Audio API's AudioContext handles this cleanly:
const audioCtx = new AudioContext();
const systemSource = audioCtx.createMediaStreamSource(
new MediaStream(screenStream.getAudioTracks())
);
const micSource = audioCtx.createMediaStreamSource(
new MediaStream(cameraStream.getAudioTracks())
);
const destination = audioCtx.createMediaStreamDestination();
systemSource.connect(destination);
micSource.connect(destination);
// destination.stream is a single mixed audio track
const mixedAudioTrack = destination.stream.getAudioTracks()[0];
You then add mixedAudioTrack to the canvas stream before creating the MediaRecorder. The result is a single video file with both the system and mic audio properly balanced.
A common mistake here is connecting micSource directly to audioCtx.destination (the speaker) — that routes audio back out of the speakers and causes an echo loop. Always connect to a MediaStreamDestination, not the playback destination.
Saving Directly to Disk with the File System Access API
Most screen recorder tutorials end with a Blob URL download — the file pops into your Downloads folder and you have to move it manually. Gravity uses the File System Access API to write recordings directly into whatever folder you choose, behaving like a native app.
The key is to open a writable file handle before you start recording, then stream chunks into it as they arrive:
// Ask the user to pick a save location once, upfront
const fileHandle = await window.showSaveFilePicker({
suggestedName: `recording-${Date.now()}.webm`,
types: [{ accept: { 'video/webm': ['.webm'] } }]
});
const writable = await fileHandle.createWritable();
recorder.ondataavailable = async (e) => {
if (e.data.size > 0) {
await writable.write(e.data);
}
};
recorder.onstop = async () => {
await writable.close();
};
This approach has two major advantages over the Blob-in-memory pattern. First, RAM usage stays flat regardless of how long you record — chunks flow to disk immediately rather than accumulating in memory. Second, if the browser crashes mid-recording, you still have a partially written file on disk that is often recoverable.
For browser compatibility, the File System Access API is supported in Chrome, Edge, and Opera. Firefox uses a polyfill (falls back to the download pattern). Gravity detects support at runtime and uses whichever approach is available.
Multi-Segment Editing with FFmpeg WebAssembly
Once a recording is saved, users often want to trim the beginning and end or cut out a section in the middle. Running FFmpeg in the browser via WebAssembly makes this possible without any server upload.
For a simple trim (single segment to keep), FFmpeg's stream copy mode is near-instant because it doesn't re-encode:
await ffmpeg.exec([
'-ss', String(startSec),
'-to', String(endSec),
'-i', 'input.webm',
'-c', 'copy',
'output.webm'
]);
For multi-segment cuts — where you want to remove sections from the middle of a video — you need the concat demuxer. We generate a temporary list.txt file describing the segments to keep:
ffconcat version 1.0
file 'input.webm'
inpoint 0
outpoint 12.4
file 'input.webm'
inpoint 31.8
outpoint 95.0
Then pass it to FFmpeg:
await ffmpeg.exec([
'-f', 'concat',
'-safe', '0',
'-i', 'list.txt',
'-c', 'copy',
'output.webm'
]);
The inpoint/outpoint approach tells FFmpeg exactly which segments of the source file to include. The entire operation runs in the browser with no upload, typically completing in a few seconds for a 10-minute recording.
Optimizing for Performance
Running a 60fps canvas render loop while simultaneously encoding video is CPU-intensive. A few things that make a real difference:
Unified stream capture. canvas.captureStream(60) gives you a single video track of the composited studio, so the MediaRecorder only has to encode one stream.
IndexedDB chunking as a fallback. If the File System Access API isn't available, we pipe ondataavailable chunks into IndexedDB rather than accumulating them in a Blob. This keeps memory usage flat and prevents data loss on crashes — the chunks are reassembled into a final Blob only when recording stops.
Offscreen Canvas (experimental). Moving the render loop to an OffscreenCanvas inside a Web Worker keeps the main UI thread free for interactions like dragging the webcam bubble or toggling themes while recording is in progress. The browser support story is still evolving, so Gravity uses this as an opt-in path.
requestAnimationFrame throttling. On lower-end hardware, running at 60fps while encoding can cause frame drops. Gravity detects frame budget overruns and drops the render rate to 30fps automatically, keeping the recording smooth even when the CPU is under load.
Lessons Learned
Building Gravity Recorder taught us that the web platform is incredibly capable if you are willing to go beyond the default APIs. The combination of Canvas compositing, the Web Audio API, the File System Access API, and FFmpeg WASM covers most of what native screen recording apps do — without requiring a single install.
The parts that pushed hardest against browser limits were memory management during long recordings and audio sync across the composited streams. If your render loop and audio context start at slightly different times, you'll get drift over a long session. The fix is to use audioCtx.currentTime as the master clock and measure video frame timing against it, correcting drift proactively rather than reactively.
If you want to see this architecture running in production, the full source is open on GitHub.
Check out the Source Code on GitHub →
Frequently Asked Questions
Can I build a screen recorder without a Chrome extension?
Yes. navigator.mediaDevices.getDisplayMedia() is a native browser API available in Chrome, Firefox, Edge, and Safari — no extension required. Extensions add overhead and require user permission grants through the Chrome Web Store. A pure web app approach is simpler for users and more portable across browsers.
Does the File System Access API work in Firefox?
Partially. Firefox supports showOpenFilePicker (reading files) but not showSaveFilePicker (writing files) as of 2026. Gravity falls back to the traditional Blob → Object URL → anchor download pattern in Firefox, which means the file lands in the Downloads folder instead of a chosen location.
How do I handle the case where the user denies screen capture permission?
getDisplayMedia() throws a NotAllowedError if the user cancels or denies. Wrap it in a try/catch and show an appropriate message — don't silently fail. In Chrome, if the user has previously blocked screen capture at the browser level, you'll also need to guide them to chrome://settings/content/camera to reset it.
What video codec should I use for web recordings?
video/webm;codecs=vp9 is the best baseline — it's widely supported, has good compression, and is the codec most MediaRecorder implementations default to. If you need broader compatibility (particularly for Apple platforms), use video/mp4;codecs=h264 — but check MediaRecorder.isTypeSupported() at runtime since support varies.
Can I record audio-only with this approach?
Yes. Skip the canvas entirely, merge your audio sources via AudioContext as described above, and pass the MediaStreamDestination stream directly to MediaRecorder. Set mimeType: 'audio/webm;codecs=opus' for a high-quality audio-only recording — useful for podcast-style content.
