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.

Published September 18, 2026 Updated September 18, 2026 9 min read
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.

Starting a tab recordingThe user clicks the action, the worker obtains a stream id for the tab, creates an offscreen document, and the document redeems the id with getUserMedia and starts a MediaRecorder.UserService workerOffscreen documentMediaRecorderaction.onClicked(tab)the gesturetabCapture.getMediaStreamId({targetTabId})createDocument(USER_MEDIA) + start(streamId)getUserMedia({chromeMediaSourceId})new MediaRecorder(stream).start(1000)dataavailable chunks
The stream id is short-lived and single-use — redeem it immediately, in the document that will consume it.

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.

Three ways a recording ends, one place that noticesThe user's stop button, the browser's capture indicator and closing the source tab all converge on the offscreen document's finish handler, which reports back to the worker.Stop buttonrec:stop messageBrowser indicatortrack 'ended'Tab closedtrack 'ended'all converge on finish()Stop recorderassemble the Blobrec:finishedto the workerClear badge + statestorage.session
Only the first path starts in your own code — the other two arrive as a track ending.

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.

Recording size by duration and resolutionApproximate MediaRecorder WebM output size for ten-minute recordings at 480p, 720p and 1080p, plus a one-hour 1080p recording.10 min, 480p90 MB10 min, 720p220 MB10 min, 1080p480 MB60 min, 1080p2900 MBrenderer will be killed
Anything past the second bar should stream chunks to IndexedDB rather than hold them in the document's memory.

Cross-browser variation

  • Chrome / Edge: tabCapture.getMediaStreamId from the worker plus an offscreen document with the USER_MEDIA reason is the supported MV3 path from Chrome 116. desktopCapture offers screen and window capture through the browser’s picker.
  • Firefox: no tabCapture and no offscreen API. Screen capture goes through getDisplayMedia in 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 ended event and treat it as a stop, or your UI will claim to be recording after the user ended it.

Verification

  1. 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.

  1. Confirm the tab’s audio remains audible during capture.
  2. Stop from the browser’s own capture indicator rather than your button, and confirm your badge clears.
  3. 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.

Other MV3 Architecture & Extension Lifecycle Resources