How To Develop a Screen Recorder Chrome Extension

- Use
chrome.tabCapture to capture the active tab after the user clicks the extension.- Use an offscreen document because Manifest V3 service workers cannot run
MediaRecorder.- Pass the tab’s stream ID from the service worker to the offscreen document.
- Record the resulting
MediaStream with the browser’s MediaRecorder API.- Save the recording as WebM unless you explicitly convert it to MP4.
- Request only the permissions the extension genuinely needs.
- Use
getDisplayMedia() instead when users must select an entire screen, application window, or different tab.Recording a browser tab sounds straightforward: request the screen, pass the stream to MediaRecorder, and download the result. That approach works in a simple webpage demo, but a Chrome extension introduces a few complications.
A Manifest V3 service worker cannot access MediaRecorder or other DOM-based browser APIs. A popup disappears as soon as it loses focus. A content script can be interrupted when the page navigates. And if tab audio is captured incorrectly, the user may stop hearing it during the recording.
In this guide, we will build a functional Chrome extension that:
- Records the currently active Chrome tab
- Captures tab video and audio
- Continues recording after the extension popup closes
- Preserves audio playback while recording
- Stops when requested or when the captured tab closes
- Downloads the recording as a WebM file
- Uses Chrome’s current Manifest V3 architecture
What Is a Screen Recorder Chrome Extension?
A screen recorder Chrome extension is a browser extension that captures visual and, optionally, audio output from a browser tab, window, or display. It converts the captured media stream into a video file that can be downloaded, uploaded, or processed further.
The extension we build in this tutorial records the active browser tab. This is ideal for recording product demonstrations, bug reports, tutorials, presentations, and browser-based workflows.
How Screen Recording Works in a Chrome Extension
The recording process involves three browser capabilities:
chrome.tabCaptureObtains permission to capture the active tab.getUserMedia()Converts the generated stream ID into aMediaStream.MediaRecorderencodes that stream into video chunks.
The important architectural decision is where these operations happen.
The user starts or stops recording through the popup. The extension’s service worker coordinates the operation and obtains a stream ID. An offscreen document consumes the stream and runs MediaRecorder.
This separation is necessary because service workers do not have DOM access. Chrome provides the Offscreen API specifically for background work that needs DOM and web-platform APIs. Since Chrome 116, a stream ID created by a service worker can be consumed by an offscreen document belonging to the same extension.
Chrome Extension Components We Need
Our screen recorder uses four main components.
1. Manifest file
manifest.json defines the extension, its permissions, its popup, and its background service worker.
2. Popup
The popup provides Start Recording and Stop Recording buttons. It does not perform the recording itself because a popup is destroyed when the user clicks outside it.
3. Service worker
The service worker obtains the active tab, creates the offscreen document, generates the tab-capture stream ID, and coordinates downloads.
4. Offscreen document
The offscreen document contains the actual MediaRecorder implementation. It remains available after the popup closes and can use browser APIs that require a document environment.
A content script is not required for this implementation. We are capturing the tab’s rendered media, not reading or modifying its DOM. Removing unnecessary content scripts also lets us avoid broad host permissions such as <all_urls>.
Project Structure
Create the following directory structure:
chrome-screen-recorder/
├── manifest.json
├── background.js
├── offscreen.html
├── offscreen.js
└── popup/
├── popup.html
├── popup.js
└── styles.cssIcons can be added before publishing, but they are not required while testing the extension locally.
Step 1: Create the Manifest
Add the following code to manifest.json:
{
"manifest_version": 3,
"name": "Chrome Tab Recorder",
"version": "1.0.0",
"description": "Record the active Chrome tab with audio and download it as a WebM video.",
"minimum_chrome_version": "116",
"permissions": [
"activeTab",
"tabCapture",
"offscreen",
"downloads",
"storage"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup/popup.html"
}
}Each permission has a specific purpose:
| Permission | Why it is needed |
activeTab | Temporarily grants access to the tab on which the user invokes the extension |
tabCapture | Allows the extension to capture audio and video from that tab |
offscreen | Creates a hidden document in which MediaRecorder can operate |
downloads | Saves the completed recording to the user’s computer |
storage | Preserves the current recording status for the popup |
The original approach of requesting tabs, scripting, and <all_urls> is unnecessary here. Chrome Web Store policy requires extensions to request the narrowest permissions needed for their stated purpose.
Step 2: Build the Popup
Create popup/popup.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chrome Tab Recorder</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="container">
<h1>Tab Recorder</h1>
<p id="status" class="status">Ready to record</p>
<button id="startBtn" type="button">
Start Recording
</button>
<button id="stopBtn" type="button" disabled>
Stop and Download
</button>
</main>
<script src="popup.js"></script>
</body>
</html>Add the popup styling in popup/styles.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f7f7f8;
color: #1f2937;
}
.container {
width: 280px;
padding: 20px;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
}
.status {
margin: 0 0 16px;
color: #6b7280;
font-size: 14px;
}
button {
width: 100%;
padding: 10px 14px;
margin-top: 10px;
border: 0;
border-radius: 8px;
background: #ff7a00;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #e96f00;
}
button:disabled {
background: #d1d5db;
cursor: not-allowed;
}
#stopBtn {
background: #dc2626;
}
#stopBtn:hover:not(:disabled) {
background: #b91c1c;
}Now create popup/popup.js:
const startButton = document.querySelector("#startBtn");
const stopButton = document.querySelector("#stopBtn");
const statusText = document.querySelector("#status");
function updateInterface(isRecording, message) {
startButton.disabled = isRecording;
stopButton.disabled = !isRecording;
statusText.textContent = message;
}
async function sendToBackground(message) {
return chrome.runtime.sendMessage({
target: "background",
...message
});
}
async function refreshStatus() {
try {
const response = await sendToBackground({
type: "GET_STATUS"
});
const isRecording = Boolean(response?.isRecording);
updateInterface(
isRecording,
isRecording ? "Recording in progress" : "Ready to record"
);
} catch (error) {
updateInterface(false, "Unable to read recording status");
}
}
startButton.addEventListener("click", async () => {
updateInterface(true, "Starting recording...");
try {
const response = await sendToBackground({
type: "START_RECORDING"
});
if (!response?.success) {
throw new Error(response?.error || "Could not start recording");
}
updateInterface(true, "Recording in progress");
window.close();
} catch (error) {
updateInterface(false, error.message);
}
});
stopButton.addEventListener("click", async () => {
stopButton.disabled = true;
statusText.textContent = "Preparing download...";
try {
const response = await sendToBackground({
type: "STOP_RECORDING"
});
if (!response?.success) {
throw new Error(response?.error || "Could not stop recording");
}
updateInterface(false, "Recording saved");
} catch (error) {
updateInterface(true, error.message);
}
});
refreshStatus();Closing the popup after recording starts is intentional. The offscreen document continues recording independently, and the user can reopen the popup when they want to stop.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
Step 3: Create the Offscreen Document
Add offscreen.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Recording Process</title>
</head>
<body>
<script src="offscreen.js"></script>
</body>
</html>An offscreen document must be a static HTML file packaged with the extension. It cannot be focused or shown like a normal extension page. Chrome also limits offscreen documents primarily to the Runtime API for extension communication.
Step 4: Implement the MediaRecorder
Create offscreen.js:
let mediaRecorder = null;
let mediaStream = null;
let audioContext = null;
let recordedChunks = [];
function selectMimeType() {
const supportedTypes = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm"
];
return supportedTypes.find((type) =>
MediaRecorder.isTypeSupported(type)
);
}
async function startRecording(streamId) {
if (mediaRecorder?.state === "recording") {
throw new Error("A recording is already in progress");
}
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: "tab",
chromeMediaSourceId: streamId
}
},
video: {
mandatory: {
chromeMediaSource: "tab",
chromeMediaSourceId: streamId
}
}
});
/*
* Capturing a tab normally prevents its audio from playing locally.
* Route the captured audio back to the default output so the user
* can continue hearing it.
*/
if (mediaStream.getAudioTracks().length > 0) {
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(mediaStream);
source.connect(audioContext.destination);
}
recordedChunks = [];
const mimeType = selectMimeType();
const options = mimeType
? {
mimeType,
videoBitsPerSecond: 5_000_000
}
: undefined;
mediaRecorder = new MediaRecorder(mediaStream, options);
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
});
mediaRecorder.addEventListener("stop", saveRecording, {
once: true
});
mediaStream.getVideoTracks()[0].addEventListener(
"ended",
() => {
if (mediaRecorder?.state === "recording") {
mediaRecorder.stop();
}
},
{ once: true }
);
mediaRecorder.start(1000);
await chrome.runtime.sendMessage({
target: "background",
type: "RECORDING_STARTED"
});
}
function stopRecording() {
if (!mediaRecorder || mediaRecorder.state !== "recording") {
throw new Error("No active recording was found");
}
mediaRecorder.stop();
}
async function saveRecording() {
const recordingType =
mediaRecorder?.mimeType || "video/webm";
const recording = new Blob(recordedChunks, {
type: recordingType
});
const recordingUrl = URL.createObjectURL(recording);
const filename = `tab-recording-${new Date()
.toISOString()
.replaceAll(":", "-")}.webm`;
try {
await chrome.runtime.sendMessage({
target: "background",
type: "DOWNLOAD_RECORDING",
url: recordingUrl,
filename
});
} finally {
setTimeout(() => URL.revokeObjectURL(recordingUrl), 30_000);
mediaStream?.getTracks().forEach((track) => track.stop());
await audioContext?.close();
recordedChunks = [];
mediaRecorder = null;
mediaStream = null;
audioContext = null;
}
}
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.target !== "offscreen") {
return;
}
if (message.type === "START_RECORDING") {
startRecording(message.streamId)
.then(() => sendResponse({ success: true }))
.catch((error) => {
sendResponse({
success: false,
error: error.message
});
});
return true;
}
if (message.type === "STOP_RECORDING") {
try {
stopRecording();
sendResponse({ success: true });
} catch (error) {
sendResponse({
success: false,
error: error.message
});
}
}
}
);Why the recording is saved as WebM
Do not create a WebM recording and simply name it .mp4. Changing a filename does not convert its underlying media container.
Chrome’s MediaRecorder typically supports WebM with VP8 or VP9 video and Opus audio. The code checks the available formats before creating the recorder, with a basic WebM format as its fallback.
If your product specifically requires MP4, you will need an additional conversion process. That might run locally through WebAssembly-based FFmpeg or on a backend server. Both options add processing time and resource usage.
Why audio is routed through an AudioContext
When tabCapture captures a tab’s audio, Chrome stops playing that audio to the user by default. The audio is still present in the captured stream, but it is no longer sent to the normal output.
Connecting the stream to audioContext.destination restores local playback without removing the audio from the recording. This behavior is documented in the official chrome.tabCapture
Step 5: Build the Background Service Worker
Add the following code to background.js:
const OFFSCREEN_DOCUMENT_PATH = "offscreen.html";
let creatingOffscreenDocument = null;
async function ensureOffscreenDocument() {
const offscreenUrl = chrome.runtime.getURL(
OFFSCREEN_DOCUMENT_PATH
);
const existingContexts = await chrome.runtime.getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
documentUrls: [offscreenUrl]
});
if (existingContexts.length > 0) {
return;
}
if (creatingOffscreenDocument) {
await creatingOffscreenDocument;
return;
}
creatingOffscreenDocument =
chrome.offscreen.createDocument({
url: OFFSCREEN_DOCUMENT_PATH,
reasons: ["USER_MEDIA"],
justification:
"Record audio and video captured from the active tab"
});
try {
await creatingOffscreenDocument;
} finally {
creatingOffscreenDocument = null;
}
}
async function startRecording() {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
if (!activeTab?.id) {
throw new Error("Chrome could not identify the active tab");
}
if (
activeTab.url?.startsWith("chrome://") ||
activeTab.url?.startsWith("chrome-extension://")
) {
throw new Error(
"Chrome does not allow this page to be recorded"
);
}
await ensureOffscreenDocument();
const streamId = await chrome.tabCapture.getMediaStreamId({
targetTabId: activeTab.id
});
const response = await chrome.runtime.sendMessage({
target: "offscreen",
type: "START_RECORDING",
streamId
});
if (!response?.success) {
throw new Error(
response?.error || "The recorder could not start"
);
}
return { success: true };
}
async function stopRecording() {
const response = await chrome.runtime.sendMessage({
target: "offscreen",
type: "STOP_RECORDING"
});
if (!response?.success) {
throw new Error(
response?.error || "The recorder could not stop"
);
}
return { success: true };
}
chrome.runtime.onInstalled.addListener(async () => {
await chrome.storage.session.set({
isRecording: false
});
});
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.target !== "background") {
return;
}
if (message.type === "GET_STATUS") {
chrome.storage.session
.get("isRecording")
.then(({ isRecording = false }) => {
sendResponse({ isRecording });
});
return true;
}
if (message.type === "START_RECORDING") {
startRecording()
.then(sendResponse)
.catch((error) => {
sendResponse({
success: false,
error: error.message
});
});
return true;
}
if (message.type === "STOP_RECORDING") {
stopRecording()
.then(sendResponse)
.catch((error) => {
sendResponse({
success: false,
error: error.message
});
});
return true;
}
if (message.type === "RECORDING_STARTED") {
chrome.storage.session
.set({ isRecording: true })
.then(() => sendResponse({ success: true }));
return true;
}
if (message.type === "DOWNLOAD_RECORDING") {
chrome.downloads
.download({
url: message.url,
filename: message.filename,
saveAs: true
})
.then(async (downloadId) => {
await chrome.storage.session.set({
isRecording: false
});
sendResponse({
success: true,
downloadId
});
})
.catch(async (error) => {
await chrome.storage.session.set({
isRecording: false
});
sendResponse({
success: false,
error: error.message
});
});
return true;
}
}
);Why we check for an existing offscreen document
Chrome allows an extension to have only one offscreen document per profile at a time. Two nearly simultaneous start requests could otherwise attempt to create the same document twice.
The shared promise prevents that race condition, while chrome.runtime.getContexts() lets the service worker reuse an existing recorder document.
Why recording state is stored separately
Manifest V3 service workers are not persistent. Chrome may suspend one when it is idle and create a fresh instance when another event arrives.
A regular JavaScript variable in background.js therefore cannot be treated as reliable application state. chrome.storage.session keeps the status available for the current browser session without writing it permanently to disk.
Step 6: Load the Extension in Chrome
To run the extension locally:
- Open
chrome://extensions/. - Turn on Developer mode.
- Select Load unpacked.
- Choose the
chrome-screen-recorderdirectory. - Pin the extension to the Chrome toolbar.
- Open a normal website and click the extension.
- Select Start Recording.
- Reopen the popup and choose Stop and Download.
Chrome will display a recording indicator while the tab is being captured. After stopping, the extension should ask where to save the WebM file.
If you change the manifest or service worker, reload the extension from chrome://extensions/. Changes to the popup may also require closing and reopening it.
Recording a Window or the Entire Screen
chrome.tabCapture is appropriate when the extension should record the active tab immediately after the user invokes it. It does not capture the Chrome frame, another application, or the full desktop.
Use navigator.mediaDevices.getDisplayMedia() when users need to select among:
- A browser tab
- An application window
- An entire monitor
The basic request is:
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});This request opens Chrome’s native sharing picker. The user must explicitly choose what to share, and the browser provides a visible capture indicator.
For production use, run this from a dedicated extension page opened by a clear user action. Avoid placing the recorder directly in a content script: Chrome notes that content-script capture can end when the page navigates. A persistent recording workflow should use an extension page or an offscreen document with the DISPLAY_MEDIA reason.
A single extension can support both modes:
- Record this tab with
chrome.tabCapture - Choose screen or window with
getDisplayMedia()
Keep those options clearly labeled because the permissions and user expectations are different.
Common Problems and Their Fixes
The extension cannot record chrome:// pages
Chrome protects internal pages such as chrome://extensions, the Chrome Web Store, and browser settings. Extensions cannot inject scripts into or capture every restricted browser surface.
Test with a regular HTTPS website instead.
The popup closes when recording starts
That is expected Chrome behavior. Popup pages are temporary. The recording must run in the offscreen document, not in the popup.
The video has no audio
Confirm that the captured tab is actually producing audio and that the stream request includes an audio constraint. Some protected media may restrict capture, and system-wide audio availability can vary by operating system and selected capture source.
The tab becomes silent during recording
Route the captured stream through an AudioContext, as shown in offscreen.js. Tab capture redirects the tab’s audio away from its normal output unless the extension reconnects it.
The file has an .mp4 extension but will not play
A filename is not a format conversion. MediaRecorder output created as video/webm must use a .webm extension unless you perform genuine MP4 transcoding.
Recording consumes too much memory
The tutorial stores the complete recording in memory before downloading it. That is reasonable for shorter recordings, but long sessions can create hundreds of megabytes of Blob data.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
For a production recorder, consider:
- Uploading chunks incrementally to a backend
- Writing chunks through a supported local file workflow
- Setting recording-duration limits
- Monitoring total recorded size
- Warning users before available memory becomes a problem
Stop Recording does not work after switching tabs
This implementation records the originally selected tab and stops through the extension’s shared offscreen document. It does not depend on whichever tab is active when the Stop button is clicked.
That is an important improvement over implementations that send the stop message to the current active tab, which may no longer be the tab running the recorder.
Security and Privacy Considerations
A recording extension can capture website content, private conversations, account information, and other sensitive data. Privacy cannot be treated as a final publishing checkbox.
At minimum:
- Start recording only after an explicit user action.
- Display a clear recording state.
- Stop all media tracks when recording ends.
- Explain whether recordings stay local or leave the device.
- Do not request
<all_urls>unless the extension genuinely needs continuous access to every site. - Never upload recordings without clear disclosure and consent.
- Encrypt recording data during transmission and protect stored files.
- Provide retention and deletion controls if recordings are stored remotely.
The Chrome Web Store considers captured website content to be user data, even when it is processed only on the user’s device. Extensions handling user data may therefore need an accurate privacy policy and appropriate in-product disclosures.
Taking the Extension Beyond the Demo
The tutorial gives you a reliable local recording foundation. A commercial screen recorder will usually need additional product engineering.
Useful additions include:
- Microphone selection and mixing
- Webcam overlay
- Recording countdown
- Elapsed-time indicator
- Pause and resume controls
- Resolution and bitrate selection
- Automatic recording recovery
- Long-recording chunk uploads
- Cloud storage
- Team workspaces
- Shareable recording links
- Server-side MP4 conversion
- Captions and transcription
- Cursor highlighting
- Basic trimming and editing
- Recording access controls
Microphone and tab audio should be managed as separate input sources. An AudioContext can combine both into one destination stream before MediaRecorder receives it.
Likewise, a webcam overlay is more than adding a second video track. A common implementation draws the screen and camera frames onto a <canvas>, then records the canvas stream with canvas.captureStream().
If you are turning a recorder into a supported SaaS product, experienced software developers can help with media processing, scalable uploads, secure storage, extension review requirements, and the web application surrounding the extension.
Frequently Asked Questions
Can a Chrome extension record the entire desktop?
Yes. Use getDisplayMedia() and let the user select an entire screen from Chrome’s native sharing dialog. chrome.tabCapture is limited to browser-tab content and does not capture the complete desktop.
Can the extension record system audio?
Tab audio can be captured reliably with chrome.tabCapture. System-wide audio availability depends on the selected display source, Chrome, and the operating system. It should not be assumed to work identically everywhere.
Can a Chrome extension record the microphone and tab audio together?
Yes. Request microphone access separately, combine microphone and tab audio through an AudioContext, and add the mixed audio track to the stream passed into MediaRecorder.
Why does this tutorial require Chrome 116 or later?
Chrome 116 enabled a tab-capture stream ID created in a Manifest V3 service worker to be consumed by an offscreen document, making persistent background tab recording much cleaner.
Can MediaRecorder save directly as MP4?
Support depends on the browser and available codecs. WebM remains the safer Chrome recording format. If MP4 is mandatory, check format support or transcode the completed recording afterward.
Does the extension need a content script?
No. A content script is unnecessary when the extension only captures rendered tab media. You need one only if features such as cursor tracking or page-element highlighting require access to the page DOM.
Will recording continue if the user changes tabs?
Yes. The offscreen document continues capturing the original tab. However, closing that tab or stopping its captured media track will end the recording and trigger the download process.
Can this extension be published in the Chrome Web Store?
Yes, after adding required icons, store assets, accurate disclosures, and a privacy policy where applicable. The implementation must also comply with Chrome’s single-purpose, permission, security, and user-data policies.
Our Final Words
A dependable Chrome screen recorder is not simply a getDisplayMedia() call placed inside a content script. The recording must survive popup closure, work within Manifest V3’s service-worker lifecycle, preserve tab audio, produce a correctly formatted file, and respect user privacy.
The architecture in this guide separates those responsibilities cleanly. The popup handles user intent, the service worker coordinates Chrome APIs, and the offscreen document owns the media stream and MediaRecorder lifecycle.
Once this foundation works, you can add microphones, webcam overlays, cloud uploads, editing, transcription, and team sharing without rebuilding the capture system from scratch.



