Capturing a Screenshot of the Visible Tab
Take screenshots in an MV3 extension with chrome.tabs.captureVisibleTab: activeTab versus host permissions, the two-per-second rate limit, PNG versus JPEG, cropping on OffscreenCanvas, full-page stitching and DPR.
Table of Contents
A bug-report tool attaches a screenshot, a note-taking extension clips a region of the page, a visual-testing helper records what the user saw. chrome.tabs.captureVisibleTab produces an image of the visible part of the active tab in a window, and it is the fastest and least intrusive way to do it. It also comes with constraints people meet one at a time: it captures only what is visible, it needs a specific kind of permission, it is rate-limited to two calls per second, and the image is in device pixels rather than CSS pixels. This guide covers each, up to cropping a selection and stitching a full page. It belongs to tabs API and window management.
What the API captures
chrome.tabs.captureVisibleTab(windowId, { format, quality }) renders the currently visible viewport of the active tab in that window and returns a data URL — image/png by default or image/jpeg with a quality from 0 to 100. It captures what the compositor shows: the page plus anything drawn over it in the viewport, but not browser UI, not scrolled-out content, and not other tabs. It requires either activeTab (granted by a user gesture on that tab) or host permission for the page’s origin; <all_urls> lets it capture any page without a gesture. The image is at the device pixel ratio, so a 1280×800 viewport on a 2× display yields a 2560×1600 image. Chrome enforces MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND — two calls per second per extension — and rejects calls beyond that.
Step-by-step: capture, crop and save
1. Capture after a user gesture
1// sw.js
2chrome.action.onClicked.addListener(async (tab) => {
3 const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
4 await saveCapture(dataUrl, tab);
5});
Execution context: the service worker. The action click grants activeTab for the tab, which is enough for captureVisibleTab without any host permission — the cheapest way to offer screenshots. Pass the tab’s windowId; the API captures the active tab of that window, which is the clicked tab here. Capturing restricted pages (chrome://, the Web Store) fails regardless of permissions.
2. Choose the format by content
1const opts = isMostlyPhotographic(tab.url)
2 ? { format: "jpeg", quality: 85 } // much smaller for images and video frames
3 : { format: "png" }; // crisp text, lossless
Execution context: the service worker. PNG keeps text and UI sharp but can be several megabytes for a large retina viewport; JPEG at 80–90 is a fraction of the size and fine for photographic content. If the capture is sent to a server or stored many times, size matters more than you expect.
3. Crop a user-selected region
1export async function cropCapture(dataUrl, rect, dpr) {
2 const blob = await (await fetch(dataUrl)).blob();
3 const bmp = await createImageBitmap(blob);
4 const sx = Math.round(rect.x * dpr), sy = Math.round(rect.y * dpr);
5 const sw = Math.round(rect.width * dpr), sh = Math.round(rect.height * dpr);
6 const canvas = new OffscreenCanvas(sw, sh);
7 canvas.getContext("2d").drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
8 return canvas.convertToBlob({ type: "image/png" });
9}
Execution context: the service worker, where OffscreenCanvas and createImageBitmap are available. The selection rectangle comes from a content script in CSS pixels (from a drag overlay on the page), along with window.devicePixelRatio; multiplying by the ratio converts to the capture’s device pixels. Forgetting the ratio crops the wrong quarter of the image on retina displays.
4. Respect the rate limit
1let lastCapture = 0;
2export async function captureThrottled(windowId, opts) {
3 const wait = 550 - (Date.now() - lastCapture);
4 if (wait > 0) await new Promise((r) => setTimeout(r, wait));
5 lastCapture = Date.now();
6 return chrome.tabs.captureVisibleTab(windowId, opts);
7}
Execution context: the service worker. Calls faster than two per second reject with a quota error. Any feature that captures repeatedly — full-page stitching, an automated sequence — must pace itself. A little over half a second between calls stays safely within the limit.
5. Stitch a full page when needed
1export async function captureFullPage(tab) {
2 const [{ result: m }] = await chrome.scripting.executeScript({
3 target: { tabId: tab.id },
4 func: () => ({ h: document.documentElement.scrollHeight, vh: innerHeight, w: innerWidth, dpr: devicePixelRatio, y0: scrollY }),
5 });
6 const canvas = new OffscreenCanvas(Math.round(m.w * m.dpr), Math.round(m.h * m.dpr));
7 const ctx = canvas.getContext("2d");
8 for (let y = 0; y < m.h; y += m.vh) {
9 await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (y) => scrollTo(0, y), args: [y] });
10 await new Promise((r) => setTimeout(r, 150)); // let lazy content paint
11 const shot = await captureThrottled(tab.windowId, { format: "png" });
12 const bmp = await createImageBitmap(await (await fetch(shot)).blob());
13 ctx.drawImage(bmp, 0, Math.round(Math.min(y, m.h - m.vh) * m.dpr));
14 }
15 await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (y) => scrollTo(0, y), args: [m.y0] });
16 return canvas.convertToBlob({ type: "image/png" });
17}
Execution context: the service worker, with scripting and access to the tab. Fixed headers and sticky elements appear in every slice; hide them from a content script during capture and restore afterwards. Very tall pages exceed canvas size limits (around 16,384–32,767 pixels per side depending on engine), so cap the height or split into several images. The alternative, chrome.debugger with Page.captureScreenshot and captureBeyondViewport, captures full pages in one call but requires the debugger permission, shows an intrusive “is debugging this browser” bar, and draws scrutiny in review.
6. Save or hand off the result
1async function saveCapture(dataUrl, tab) {
2 const blob = await (await fetch(dataUrl)).blob();
3 await storeBlob({ id: crypto.randomUUID(), blob, url: tab.url, title: tab.title, at: Date.now() });
4 await chrome.tabs.create({ url: chrome.runtime.getURL(`viewer.html#latest`) });
5}
Execution context: the service worker. Store screenshots as Blobs in IndexedDB rather than as data URLs in chrome.storage, as covered in storing large blobs and files in an extension. Opening an extension page to view or annotate the capture gives the user immediate feedback that something happened.
7. Be careful with what you capture
Screenshots capture everything visible — open emails, account numbers, other people’s messages. Only capture on an explicit user action, show the user the image before uploading it anywhere, and never capture in the background. Disclose screenshot uploads in the privacy policy; reviewers look closely at extensions that combine <all_urls> with captureVisibleTab.
Common mistakes
- Capturing without a gesture or host permission. The call fails with a permission error.
- Ignoring device pixel ratio when cropping. Crops land in the wrong place on high-density displays.
- Capturing in a loop without throttling. Calls beyond two per second fail.
- Storing data URLs in
chrome.storage. Large, slow and quota-bound. - Using
chrome.debuggerfor convenience. The warning bar and review cost are rarely worth it.
Cross-browser variation
- Chrome / Edge:
captureVisibleTabwithactiveTabor host permission; two calls per second. - Firefox: supports
captureVisibleTaband alsotabs.captureTab(tabId, { rect, scale }), which can capture a specific rectangle — including beyond the viewport with the right options — without stitching. Requires<all_urls>oractiveTab. - Safari: supports
captureVisibleTabin recent versions; output and permission prompts follow Safari’s per-site model.
Verification
- Click the action on a normal page with only
activeTaband confirm a capture is produced. - Select a region on a retina display and confirm the crop matches the selection.
- Call capture three times within a second and confirm your throttle prevents the third failing.
- Capture a long page and confirm the stitched image has no gaps or duplicated slices.
FAQ
Can I capture a background tab?
No. Only the active tab of a window. Activate it first (which the user sees), or use Firefox’s captureTab.
Does it capture video frames?
Usually, yes, but protected (DRM) video renders as black.
Can a content script take the screenshot?
No. Content scripts cannot call captureVisibleTab; message the worker.
Related
- Injecting only after a user gesture with activeTab — the permission model capture relies on.
- Drawing dynamic action icons with OffscreenCanvas — the same canvas in the worker.
- Visual regression testing for extension UI — screenshots in tests.
- Tabs API and window management — the parent topic.