ScreenshotNeo

BlogHow-to

How to Capture a Tab Screenshot in a Chrome Extension

Capture the visible area of the active Chrome tab with Manifest V3, the right permission, complete code, troubleshooting, and a ScreenshotNeo alternative.

By the ScreenshotNeo team1 October 20268 min read

Use chrome.tabs.captureVisibleTab() from your extension service worker, popup, or another extension page. It returns a Promise that resolves to an image data URL for the visible area of the active tab. In Manifest V3, request activeTab for a screenshot started by a user action, or <all_urls> only when the feature genuinely needs broad host access.

This API captures the current viewport. It does not capture the complete height of a long page. Chrome also limits the operation to two calls per second, so treat it as an expensive operation and avoid rapid repeated calls.

1. Minimal Manifest V3 extension

Create a directory with these files:

tab-shot/
├── manifest.json
├── service-worker.js
└── popup.html

manifest.json

{
  "manifest_version": 3,
  "name": "Tab Screenshot",
  "version": "1.0.0",
  "description": "Capture the visible area of the active tab.",
  "permissions": ["activeTab"],
  "action": {
    "default_title": "Capture tab",
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "service-worker.js"
  }
}

activeTab grants temporary host permission for the current tab after a user invokes the extension. Chrome documents that this permission does not trigger a permission warning. See the activeTab documentation and the captureVisibleTab reference.

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Tab Screenshot</title>
    <style>
      body { min-width: 220px; font: 14px system-ui, sans-serif; padding: 12px; }
      button { width: 100%; padding: 8px; }
      #status { margin-top: 8px; white-space: pre-wrap; }
    </style>
  </head>
  <body>
    <button id="capture">Capture visible tab</button>
    <div id="status" role="status"></div>
    <script src="popup.js"></script>
  </body>
</html>
const button = document.querySelector('#capture');
const status = document.querySelector('#status');

button.addEventListener('click', async () => {
  button.disabled = true;
  status.textContent = 'Capturing…';

  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (!tab || tab.windowId === undefined) {
      throw new Error('No active tab was found.');
    }

    const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
      format: 'png'
    });

    const link = document.createElement('a');
    link.href = dataUrl;
    link.download = 'tab-screenshot.png';
    link.click();
    status.textContent = 'Saved tab-screenshot.png';
  } catch (error) {
    status.textContent = `Capture failed: ${error.message}`;
  } finally {
    button.disabled = false;
  }
});

Add popup.js to the directory, then open chrome://extensions, enable Developer mode, choose Load unpacked, and select the directory. Click the extension button, then click Capture visible tab.

2. Capture from a service worker after the toolbar action

A service worker can capture directly when the user clicks the extension action. This keeps the capture logic out of the popup, which closes as soon as focus moves away.

// manifest.json
{
  "manifest_version": 3,
  "name": "Toolbar Tab Screenshot",
  "version": "1.0.0",
  "permissions": ["activeTab", "downloads"],
  "background": { "service_worker": "service-worker.js" },
  "action": { "default_title": "Capture visible tab" }
}
// service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
  try {
    const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
      format: 'png'
    });

    await chrome.downloads.download({
      url: dataUrl,
      filename: 'tab-screenshot.png',
      saveAs: true
    });
  } catch (error) {
    console.error('Unable to capture tab:', error);
  }
});

The downloads permission is needed only for this download-based example. If you display or upload the data URL instead, omit that permission.

3. Sending a request from a content script

Content scripts cannot call the Tabs API directly. Send a message to the extension context, then perform the capture in the service worker or an extension page.

// content.js
const response = await chrome.runtime.sendMessage({ type: 'capture-visible-tab' });
if (!response.ok) {
  console.error(response.error);
}
// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type !== 'capture-visible-tab') return;

  (async () => {
    try {
      const windowId = sender.tab?.windowId;
      if (windowId === undefined) throw new Error('The message did not come from a tab.');
      const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: 'png' });
      sendResponse({ ok: true, dataUrl });
    } catch (error) {
      sendResponse({ ok: false, error: error.message });
    }
  })();

  return true;
});

Validate message types and sender information before capturing. Do not let arbitrary web-page messages trigger captures without a deliberate extension flow.

4. Options and return value

Input Meaning
windowId The window whose active tab should be captured. Passing the queried tab’s window ID avoids ambiguity.
format: "png" Returns a PNG data URL.
format: "jpeg" Returns a JPEG data URL.
quality JPEG quality value when supported by the API; use it only with JPEG.

The resolved value is a string such as data:image/png;base64,…. Assign it to an image element’s src, store it, upload it, or pass it to a download API.

const image = document.createElement('img');
image.src = dataUrl;
document.body.append(image);

5. Choosing permissions

Use activeTab for a user-invoked screenshot

  • Declare "permissions": ["activeTab"].
  • Start capture from a toolbar click, command, or another user invocation.
  • Access is temporary for the current tab.

Use <all_urls> only for a broad feature

"permissions": ["<all_urls>"]

This is appropriate only when your extension must capture pages without a current user invocation or across many hosts. Host permissions and extension permissions are declared separately. Chrome’s permission guide recommends requesting only the access the feature requires; optional permissions can defer a request until the user enables a feature.

File URLs and restricted pages

  • chrome:// pages, other extensions’ pages, and data: URLs have special restrictions. Chrome documents capture through this method with activeTab for these sensitive cases.
  • file:// pages require the user to enable Allow access to file URLs in the extension details page.

6. What this API cannot do

captureVisibleTab captures only the visible viewport of the active tab. It does not automatically stitch a full document, scroll through the page, or capture content below the fold.

For a full-page result, an extension must coordinate scrolling, capture multiple viewports, and stitch the images. That approach has edge cases: fixed headers can repeat, lazy-loaded content may change as the page scrolls, and pages can move while captures are being assembled. A separate screenshot service can handle full-page capture without shipping browser orchestration in the extension.

chrome.tabCapture is a different API. It produces a media stream containing tab audio and video for streaming use cases; it does not return a still image data URL.

7. Rate limits, performance, and reliability

  • Rate: Chrome documents MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND as 2. Queue requests and keep at least 500 ms between captures when continuously sampling.
  • Cost: The operation is expensive. Disable the button while a capture is in progress and avoid polling loops.
  • Memory: A data URL contains base64 image data. Release references after saving or uploading large screenshots.
  • Consistency: Capture only after the tab is active and the page has reached the visual state you want. A capture immediately after navigation can show an intermediate page.
  • Service-worker lifetime: Keep asynchronous work inside the event handler and handle rejections. Do not depend on mutable global state surviving worker suspension.
let lastCaptureAt = 0;

async function captureWithThrottle(windowId) {
  const wait = Math.max(0, 500 - (Date.now() - lastCaptureAt));
  if (wait) await new Promise(resolve => setTimeout(resolve, wait));
  lastCaptureAt = Date.now();
  return chrome.tabs.captureVisibleTab(windowId, { format: 'png' });
}

8. Troubleshooting

Symptom Cause Fix
Cannot access contents of url or permission error The extension lacks the required host access, or the user did not invoke the extension for activeTab. Trigger from a user action with activeTab, or request the specific host permission the feature needs.
Capture fails on a file:// page File access is disabled for the extension. Open the extension details page and enable Allow access to file URLs.
Capture fails on a Chrome or extension page These are restricted pages. Test on a normal web page and follow Chrome’s documented activeTab restrictions.
Only the visible portion is returned This is the defined scope of captureVisibleTab. Use a scrolling and stitching design for full-page output, or use a full-page screenshot service.
Too many calls or intermittent failures The two-calls-per-second limit was exceeded. Throttle and queue captures; disable duplicate clicks.
The popup closes before saving Focus moved away from the popup during an asynchronous operation. Capture and download from the service worker, or finish all work before the popup closes.
dataUrl is undefined The Promise rejected and the error was not handled. Wrap the call in try/catch and log the complete error object.

9. Security and privacy checklist

  • Request the narrowest permission that satisfies the feature.
  • Explain why a screenshot is taken and where it is stored or uploaded.
  • Do not transmit the data URL to a server unless the user expects that behavior.
  • Restrict runtime messages to known message types and trusted extension flows.
  • Remove image data from memory after processing when screenshots contain sensitive information.

10. Or skip the browser setup

If you need screenshots from a backend, CI job, script, or AI workflow, ScreenshotNeo provides a single GET request that returns PNG, JPEG, WebP, or PDF. Its capture pipeline accepts cookie and consent banners before the shot and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', image);

ScreenshotNeo includes full-page capture with lazy images loaded, CSS-element capture, device presets and custom viewports, dark mode, retina scale, custom CSS and JavaScript, click and wait controls, request blocking, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, and an OpenAPI specification. Parameter names used by other screenshot APIs also work, which helps when switching.

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. Create a free ScreenshotNeo account and start with the 1,000 free screenshots.

11. FAQ

Does the screenshot include browser chrome?

No. The result is the web content visible inside the tab viewport, not Chrome’s toolbar or other browser UI.

Can I capture a tab that is not active?

The method captures the active tab in a window. Query the target window and make it active as part of a deliberate extension flow, then capture it.

Is the result a file or a URL?

It is an image data URL string. Convert it to a Blob, upload it, display it in an image element, or pass it to the downloads API.

Should I use PNG or JPEG?

PNG preserves sharp text and transparency. JPEG can reduce size for photographic content; use the JPEG quality option when you need that tradeoff.

Can a content script call captureVisibleTab?

No. Message the service worker or an extension page and call the Tabs API there.