Why chrome.tabs.captureVisibleTab Captures the Wrong Area and How to Fix It
Fix wrong-tab, cropped, offset, zoomed, and partial screenshots from chrome.tabs.captureVisibleTab with permissions, timing, geometry, and full-page patterns.
chrome.tabs.captureVisibleTab() captures the visible viewport of the currently active tab in a window. It does not accept a tab ID. Wrong-tab images usually mean the active tab or window changed before capture; cropped or offset images usually come from viewport-only capture, zoom, display scaling, or coordinate assumptions.
Use this order to fix it:
- Query the active tab in the intended, last-focused window immediately before calling the API.
- Pass that window ID to
captureVisibleTab(windowId, options); never pass a tab ID as the first argument. - Confirm
activeTabor<all_urls>permission and any file-URL access needed. - Record the tab ID, window ID, URL, zoom, and decoded bitmap dimensions for each capture.
- Use a scrolling and stitching strategy for a full page; this API only captures what is visible.
What captureVisibleTab actually captures
The method takes an optional windowId and capture options. Chrome captures the page visible in the active tab of that window. A tab ID cannot select an inactive tab. If the user switches tabs, opens a popup, or focuses another window between your tab query and the capture call, the returned image can belong to a different page.
The result is a data URL. The default format is PNG; JPEG is available with a quality value. The image dimensions describe the captured viewport bitmap, not the complete scrollable document.
Minimal correct implementation
Manifest V3 permissions
{
"manifest_version": 3,
"name": "Visible Tab Capture Demo",
"version": "1.0.0",
"permissions": ["activeTab", "tabs"],
"action": { "default_title": "Capture visible tab" },
"background": { "service_worker": "background.js" }
}
activeTab grants temporary access after a user invokes the extension. Use <all_urls> instead when your workflow needs persistent access to matching hosts. Chrome pages, extension pages, and data URLs have special restrictions; file URLs also require the user’s file-access setting.
background.js
chrome.action.onClicked.addListener(async () => {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true
});
if (!tab || tab.id === undefined || tab.windowId === undefined) {
throw new Error("No active tab in the last-focused window");
}
console.log({
tabId: tab.id,
windowId: tab.windowId,
url: tab.url,
title: tab.title
});
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: "png"
});
const response = await fetch(dataUrl);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
console.log("Captured bitmap", bitmap.width, bitmap.height);
bitmap.close();
await chrome.downloads.download({
url: dataUrl,
filename: "visible-tab.png",
saveAs: true
});
});
Add downloads to the manifest if you use the download call:
"permissions": ["activeTab", "tabs", "downloads"]
Keep the tab query and capture close together. The logging pattern is diagnostic practice: it lets you prove which tab and window Chrome selected and whether the bitmap size matches your expectation.
Fix screenshots of the wrong tab or window
Use a window ID, not a tab ID
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true
});
const image = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: "png"
});
The first argument is a window ID. Passing tab.id can select the wrong window or fail. If your extension manages several windows, choose one explicitly and query its active tab:
const windowId = 123; // obtain this from chrome.windows or your UI
const [tab] = await chrome.tabs.query({ active: true, windowId });
if (!tab) throw new Error("That window has no active tab");
const image = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
Prevent focus races
A popup closes when focus changes, and a user can switch tabs while asynchronous code is running. Capture in the action click handler or service worker, re-query immediately before capture, and log the selected tab. Do not assume that a previously stored tab remains active.
Fix permission and restricted-page failures
The API requires activeTab or <all_urls>. With activeTab, the user must invoke the extension on the target tab. Sensitive pages such as chrome:// pages and other extensions’ pages have additional restrictions. File URLs require file access enabled for the extension. A permission error is not an image-cropping problem: verify access first.
try {
const image = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
} catch (error) {
console.error("captureVisibleTab failed", {
message: error.message,
lastError: chrome.runtime.lastError?.message
});
}
When using callback-style APIs, read chrome.runtime.lastError inside the callback. In promise-based code, catch the rejected promise.
Fix cropped images and the “wrong area” expectation
captureVisibleTab captures only the visible viewport. It does not capture content below the fold, browser chrome, extension popups, or the entire document. To capture a full page, scroll through the document, capture each viewport, and stitch the images. Hide fixed headers while stitching or account for their repeated pixels.
Full-page capture outline
async function captureFullPage(tabId, windowId) {
const [{ result: metrics }] = await chrome.scripting.executeScript({
target: { tabId },
func: () => ({
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
viewport: window.innerHeight,
x: window.scrollX,
y: window.scrollY
})
});
const shots = [];
for (let y = 0; y < metrics.height; y += metrics.viewport) {
await chrome.scripting.executeScript({
target: { tabId },
args: [y],
func: (top) => window.scrollTo(0, top)
});
await new Promise(resolve => setTimeout(resolve, 100));
shots.push({ y, dataUrl: await chrome.tabs.captureVisibleTab(windowId, { format: "png" }) });
}
await chrome.scripting.executeScript({
target: { tabId },
args: [metrics.x, metrics.y],
func: (x, y) => window.scrollTo(x, y)
});
return { metrics, shots };
}
This is an implementation pattern, not a promise that every site will stitch perfectly. Lazy-loaded content, sticky elements, animations, responsive breakpoints, and scroll snapping can change pixels between frames. Add a content-script helper to disable animation and temporarily hide fixed elements when your page permits it. The manifest needs scripting and host access for executeScript.
Zoom, device scale, and coordinate confusion
Chrome exposes zoom inspection and control through chrome.tabs.getZoom(), getZoomSettings(), and setZoom(). A page’s CSS viewport coordinates and the screenshot bitmap’s pixel coordinates are not universally related by one documented formula across browser zoom and display scaling. Treat conversion as implementation-specific and measure the returned bitmap.
const zoom = await chrome.tabs.getZoom(tab.id);
const settings = await chrome.tabs.getZoomSettings(tab.id);
console.log({ zoom, settings });
// Only change zoom when your UX requires it; restore the user setting afterward.
await chrome.tabs.setZoom(tab.id, 1.0);
try {
const image = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
// process image
} finally {
await chrome.tabs.setZoom(tab.id, zoom);
}
Changing zoom can reflow responsive layouts and should be avoided in a passive capture tool. If you must map an element’s CSS rectangle to bitmap pixels, compare a known element’s measured rectangle with the decoded image dimensions on each target environment.
Capture options and rate limits
| Option | Use |
|---|---|
format |
png (default) or jpeg |
quality |
JPEG quality; it has no useful effect for PNG |
windowId |
Window whose active tab is captured |
Chrome documents a maximum of two capture calls per second. Queue requests and apply backoff instead of firing one call per scroll event. For a full-page stitcher, delay between frames and stop on errors; do not retry rapidly.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot shows another tab | Active tab changed or wrong window was used | Query active:true,lastFocusedWindow:true immediately before capture and pass tab.windowId. |
| “Invalid window ID” | A tab ID was passed as the window ID | Use tab.windowId or a value from chrome.windows. |
| Permission denied | Missing permission, no user invocation, or restricted URL | Add activeTab/<all_urls>, invoke on the tab, and check file or sensitive-page restrictions. |
| Only the visible section appears | The API is viewport-only | Scroll and stitch, or use a full-page screenshot service. |
| Image is offset after scrolling | Sticky headers, scrollbars, or CSS-to-pixel assumptions | Measure bitmap dimensions, hide fixed elements during stitching, and align overlaps. |
| Repeated calls fail or lag | More than two calls per second | Serialize calls and add delay/backoff. |
| Blank or partially loaded page | Capture occurred before rendering or lazy loading finished | Wait for a selector, network idle equivalent, or a deliberate delay before capture. |
Reliability and performance checklist
- Capture only after the user action or after confirming the target tab is still active.
- Log tab ID, window ID, URL, zoom, format, and decoded width/height.
- Use PNG for lossless UI text; JPEG for smaller photographic images.
- Throttle to two calls per second or fewer.
- For full pages, use overlap between frames and verify the final height.
- Disable animation and stabilize lazy content where you control the page.
- Restore scroll position and zoom in a
finallyblock.
Or skip the browser setup
For server-side screenshots, ScreenshotNeo provides a single GET request that returns PNG, JPEG, WebP, or PDF. Its capture flow accepts cookie and consent banners, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and lets you turn those steps off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether the shot was billed.
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)
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}`);
See the ScreenshotNeo API documentation for authentication and options. You can request full-page captures, a CSS-selected element, dark mode, device presets or custom viewports, retina scale, custom CSS and JavaScript, clicks, waits, blocked resources, headers, cookies, user agents, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, bulk capture, usage data, and PDF output. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
Plans include 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Can I capture an inactive tab?
Not with captureVisibleTab directly. It captures the active tab in a window. Activate the tab first or use a different capture architecture.
Does the API include browser toolbars?
No. It captures the web page’s visible region, not Chrome’s browser interface.
Why does a retina display change dimensions?
Display scaling and zoom affect the bitmap. Decode the returned image and use its actual dimensions instead of assuming CSS pixels map one-to-one.
How do I avoid duplicate fixed headers in a stitched image?
Temporarily hide fixed and sticky elements during each frame, or crop repeated overlap when composing the final bitmap.
When should I use a service instead of an extension?
Use an extension when capture must happen in the user’s active browser context. Use a service when you need repeatable server-side jobs, PDFs, bulk URLs, webhooks, or AI-agent access.


