Capturing Audio and Video in an Offscreen Document
Record a tab, the screen or the microphone from an MV3 extension — tabCapture stream ids, the USER_MEDIA and DISPLAY_MEDIA reasons, MediaRecorder in an offscreen document, and saving the result.
Table of Contents
Screen recorders, meeting transcribers and tab-audio tools all hit the same wall in Manifest V3: getUserMedia and MediaRecorder need a document, the service worker is not one, and the popup that could host them is destroyed the moment the user clicks back into the page they wanted to record. The offscreen document is the supported home for a capture session — but the stream has to be obtained in one context and consumed in another, which is where most implementations go wrong. This guide is part of offscreen documents and DOM access.
Why capture splits across two contexts
chrome.tabCapture.getMediaStreamId must be called from the service worker in response to a user gesture on the extension’s action. It returns an opaque stream id, not a stream. That id is then redeemed with getUserMedia in a document — and the only document that can live for the length of a recording without being visible is the offscreen one.
Step-by-step
1. Declare the permissions
1{
2 "permissions": ["tabCapture", "offscreen", "storage"],
3 "action": { "default_title": "Record this tab" }
4}
Execution context: parsed at install. tabCapture produces an install warning and draws review questions; the justification should name the recording feature and state where recordings go. No host permission is needed — the gesture on the action is the authorisation for that tab.
2. Get the stream id from the gesture
1// service worker
2chrome.action.onClicked.addListener(async (tab) => {
3 const { recording } = await chrome.storage.session.get("recording");
4 if (recording) return stopRecording();
5
6 const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tab.id });
7 await ensureOffscreen(["USER_MEDIA"], "Record the active tab's audio and video at the user's request.");
8 await chrome.runtime.sendMessage({ target: "offscreen", type: "rec:start", streamId });
9 await chrome.storage.session.set({ recording: { tabId: tab.id, startedAt: Date.now() } });
10 await chrome.action.setBadgeText({ text: "REC" });
11});
Execution context: the service worker, inside the click handler. getMediaStreamId refuses outside a gesture, so it cannot be moved to a later alarm or message. Recording state lives in storage.session because the worker will be evicted many times during a long recording.
3. Redeem the id and record in the offscreen document
1// offscreen/host.js
2let recorder, chunks = [];
3
4async function start(streamId) {
5 const stream = await navigator.mediaDevices.getUserMedia({
6 audio: { mandatory: { chromeMediaSource: "tab", chromeMediaSourceId: streamId } },
7 video: { mandatory: { chromeMediaSource: "tab", chromeMediaSourceId: streamId } },
8 });
9
10 // Keep the tab audible to the user while it is being captured.
11 const ctx = new AudioContext();
12 ctx.createMediaStreamSource(stream).connect(ctx.destination);
13
14 recorder = new MediaRecorder(stream, { mimeType: "video/webm;codecs=vp9,opus" });
15 recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
16 recorder.start(1000); // a chunk per second, so a crash loses little
17}
Execution context: the offscreen document, which has navigator.mediaDevices, MediaRecorder and AudioContext. The audio re-route matters: capturing a tab mutes it for the user by default, and a recorder that silences the video the user is watching is a bug report waiting to happen.
4. Stop and hand back a file
1function stop() {
2 return new Promise((resolve) => {
3 recorder.onstop = () => {
4 const blob = new Blob(chunks, { type: "video/webm" });
5 chunks = [];
6 recorder.stream.getTracks().forEach((t) => t.stop());
7 resolve(URL.createObjectURL(blob));
8 };
9 recorder.stop();
10 });
11}
Execution context: the offscreen document. Stopping every track is what releases the capture indicator in the tab strip — forget it and the browser keeps showing a recording badge after the recording has ended.
1// service worker
2async function stopRecording() {
3 const { url } = await chrome.runtime.sendMessage({ target: "offscreen", type: "rec:stop" });
4 await chrome.downloads.download({ url, filename: `tab-recording-${Date.now()}.webm`, saveAs: true });
5 await chrome.storage.session.remove("recording");
6 await chrome.action.setBadgeText({ text: "" });
7}
Execution context: the service worker. The object URL belongs to the offscreen document, so the document must stay open until the download has started — closing it first revokes the URL and the download fails. The download mechanics are in managing downloads from an extension.
5. Screen and microphone capture
Recording the whole screen or a window uses chrome.desktopCapture.chooseDesktopMedia, which shows the browser’s own picker. The microphone uses a plain getUserMedia({ audio: true }), which triggers a permission prompt that must be shown in a visible page the first time.
1// options page — prime the microphone permission once, visibly
2document.querySelector("#enable-mic").addEventListener("click", async () => {
3 const s = await navigator.mediaDevices.getUserMedia({ audio: true });
4 s.getTracks().forEach((t) => t.stop()); // permission granted; offscreen may use it now
5});
Execution context: the options page. An offscreen document cannot display a permission prompt, so a first-time microphone request from there simply fails. Granting once from a visible extension page covers every later use from the offscreen document, because both share the extension’s origin.
Keeping state honest across a long session
A recording can last longer than dozens of worker lifetimes, and three independent things can end it: your stop button, the browser’s own capture indicator, and the recorded tab being closed. The extension’s notion of “recording” must track all three, or the badge and the popup will eventually disagree with reality.
The offscreen document is the only context alive for the whole session, so it should be the one that reports the end — whichever way it happened.
1// offscreen/host.js — one exit path, whatever caused it
2function watch(stream) {
3 for (const track of stream.getTracks()) {
4 track.addEventListener("ended", () => finish("track-ended"), { once: true });
5 }
6}
7
8async function finish(reason) {
9 if (!recorder || recorder.state === "inactive") return;
10 const url = await stop();
11 chrome.runtime.sendMessage({ type: "rec:finished", reason, url });
12}
Execution context: the offscreen document. ended fires when the user stops capture from the browser’s indicator and when the source tab closes, so a single listener covers both external stops.
1// service worker — reconcile on every wake, not only on messages
2chrome.runtime.onStartup.addListener(async () => {
3 await chrome.storage.session.remove("recording"); // no capture survives a restart
4 await chrome.action.setBadgeText({ text: "" });
5});
Execution context: the service worker. After a browser restart no capture can still be running, so any recording flag left in storage is stale by definition — clearing it on startup is simpler than trying to detect it.
Memory, length and what to do about long recordings
MediaRecorder chunks accumulate in the offscreen document’s memory until you stop. A one-hour 1080p recording can reach several gigabytes, and the renderer will be killed long before that. Anything longer than a few minutes needs the chunks moved out of memory as they arrive.
1recorder.ondataavailable = async (e) => {
2 if (!e.data.size) return;
3 const buf = await e.data.arrayBuffer();
4 await putChunk(sessionId, chunkIndex++, buf); // IndexedDB, keyed by session and index
5};
Execution context: the offscreen document, writing to the extension origin’s IndexedDB. On stop, the chunks are read back in order into a Blob for download and then deleted. This also makes a crash survivable: the chunks already written can be offered to the user as a partial recording on the next start — the pattern from choosing between chrome.storage and IndexedDB.
Cross-browser variation
- Chrome / Edge:
tabCapture.getMediaStreamIdfrom the worker plus an offscreen document with theUSER_MEDIAreason is the supported MV3 path from Chrome 116.desktopCaptureoffers screen and window capture through the browser’s picker. - Firefox: no
tabCaptureand no offscreen API. Screen capture goes throughgetDisplayMediain a visible extension page, and there is no equivalent to capturing another tab’s audio silently. - Safari: extension-initiated capture is not available. Treat recording as a Chrome-only capability and hide the action elsewhere, via the approach in building a capability matrix for your extension.
- All three: the browser shows its own recording indicator and users can stop capture from it. Listen for the track’s
endedevent and treat it as a stop, or your UI will claim to be recording after the user ended it.
Verification
- Start a recording and confirm the offscreen document exists and holds a live stream:
1await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
2// [{ contextType: "OFFSCREEN_DOCUMENT", … }]
Execution context: the service worker console. Then open chrome://inspect/#other to find and inspect the offscreen document itself and watch chunks.length grow.
- Confirm the tab’s audio remains audible during capture.
- Stop from the browser’s own capture indicator rather than your button, and confirm your badge clears.
- Record for twenty minutes and confirm memory in the browser task manager stays flat when chunks are streamed to IndexedDB.
FAQ
Can I start recording from a keyboard shortcut?
Yes — a command defined in the manifest counts as a gesture for tabCapture in the same way the action click does. A message from a content script does not.
Why does getUserMedia reject with “Permission denied” in the offscreen document?
For the microphone, because the permission was never granted in a visible page. For a tab stream, because the stream id expired — redeem it immediately after obtaining it.
Can I record without the browser’s recording indicator?
No, and attempting to hide it is a policy violation. The indicator is the user’s guarantee that capture is visible.
Related
- Creating and closing offscreen documents — the host lifecycle a recording depends on.
- Playing audio from an extension — the simpler media case.
- Managing downloads from an extension — saving the recording.
- Offscreen documents and DOM access — the parent guide.