Why Not Just Use html2canvas
The obvious approach to "capture the whole page" is a DOM-to-canvas library. It re-renders the page you describe into a canvas. It is also, for screenshot purposes, the wrong tool: it approximates the browser's rendering rather than recording it. Cross-origin images taint or fail, shadow DOM and iframes are inconsistent, web fonts and CSS features drift, and anything drawn via <canvas>, WebGL, or video is either missed or captured blank.
The alternative used by every serious screenshot extension is embarrassingly simple: take real screenshots of the real viewport, scroll, and stitch. The pixels come from the compositor, so fidelity is not an approximation — it is the page. What makes it hard is not the idea; it is five practical constraints that this article walks through one by one:
chrome.tabs.captureVisibleTabis rate-limited to 2 calls per second- The capture scale is not
navigator.devicePixelRatioin any way you should trust blindly position: fixedandposition: stickyelements appear in every single frame- Canvas dimensions and total area have hard limits — exceeding them fails silently
- Decoded frames are large enough to exhaust memory on tall pages
The captureVisibleTab Quota
captureVisibleTab can only capture the active tab of a window. That single sentence has two consequences most implementations get wrong: you must activate the target tab before capturing, and you cannot sneak a screenshot of a background tab. The API's first parameter is a windowId, not a tabId — a classic source of bugs when code passes the wrong id and captures some other window's content.
// Activate the tab first — captureVisibleTab only sees the active tab
await chrome.tabs.update(tabId, { active: true });
const tab = await chrome.tabs.get(tabId);
const windowId = tab.windowId; // note: captureVisibleTab takes a WINDOW id
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, {
format: 'png',
// quality: 92 // only meaningful for jpeg
});
The second constraint is the quota: MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND = 2. Call it a third time within a second and Chrome rejects the promise with a quota error. A naive for loop over 40 scroll positions will fail on the second iteration — or worse, "mostly work" on fast machines and fail intermittently on slow ones.
The fix is an explicit pacer. Every capture goes through a gate that enforces a minimum interval of ~550 ms, comfortably under the 2/second ceiling:
const MIN_CAPTURE_INTERVAL = 550; // ms; the quota is 2 calls per second
let lastCaptureAt = 0;
async function capturePaced(windowId) {
const wait = MIN_CAPTURE_INTERVAL - (Date.now() - lastCaptureAt);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastCaptureAt = Date.now();
return chrome.tabs.captureVisibleTab(windowId, { format: 'png' });
}
This pacing sounds expensive — 40 frames means a ~22-second capture. In practice the scroll-and-settle wait for each step (next section) usually exceeds 550 ms anyway, so the pacer almost never adds real delay. It only exists to make the loop deterministic instead of racing the quota.
The Scroll-and-Stitch Loop
The core loop is: measure the page, scroll to position y, wait until the frame is stable, capture, advance by one viewport minus an overlap. The overlap matters: without it, sub-pixel scroll rounding and rounding in scrollTo produce hairline seams or one-pixel content loss at every boundary. 80–120 CSS px of shared content absorbs that rounding.
async function captureFullPage(tabId) {
const [{ result: vp }] = await chrome.scripting.executeScript({
target: { tabId },
func: () => ({
width: window.innerWidth,
height: window.innerHeight,
pageHeight: (document.scrollingElement || document.documentElement).scrollHeight,
}),
});
const overlap = 100; // CSS px shared between consecutive frames
const step = vp.height - overlap;
const frames = [];
for (let y = 0; ; y += step) {
// Clamp the last scroll so the page never over-scrolls past the bottom
const target = Math.max(0, Math.min(y, vp.pageHeight - vp.height));
await scrollAndSettle(tabId, target);
frames.push({ y: target, dataUrl: await capturePaced(windowId) });
if (target >= vp.pageHeight - vp.height) break;
}
return { frames, viewport: vp };
}
Two details in that loop earn their weight. First, the clamp: a page 1.7 viewports tall needs exactly 2 frames, and without clamping the second frame's scrollTo(0, 1.7·vh) silently lands at 0.7·vh anyway — so record the position you asked for after clamping, or your stitch offsets will be wrong. Second, the scroll itself must be deterministic:
function scrollAndSettle(tabId, y) {
return chrome.scripting.executeScript({
target: { tabId },
func: (targetY) => new Promise(resolve => {
const root = document.documentElement;
const prev = root.style.scrollBehavior;
root.style.scrollBehavior = 'auto'; // defeat CSS `scroll-behavior: smooth`
window.scrollTo(0, targetY);
requestAnimationFrame(() => requestAnimationFrame(() => {
root.style.scrollBehavior = prev;
setTimeout(resolve, 80); // one more beat for async content
}));
}),
args: [y],
});
}
The double requestAnimationFrame covers the compositor committing the new scroll position. The 80 ms tail covers images that decode asynchronously right after entering the viewport — the kind that would otherwise produce half-painted frames. And the scrollBehavior override handles sites that set html { scroll-behavior: smooth }, which turns every scrollTo into an animation that your capture would photograph mid-flight.
The scroll container problem: a surprising number of sites don't scroll the window at all. They putoverflow-y: autoon an inner container and the body stays fixed. Before capturing, verify thatdocument.scrollingElement.scrollHeight > innerHeight. If not, find the element whoseoverflow-ycomputes toauto/scrolland whosescrollHeightexceeds itsclientHeight, and scroll that instead. One check up front avoids the charming failure mode where every frame is identical.
Getting the Scale Right
Frames do not come back at CSS-pixel size. On a 2× display, captureVisibleTab returns an image at device pixel ratio — and that ratio is the effective one, which multiplies monitor scale factor by browser page zoom. Write code that trusts window.devicePixelRatio and you will eventually ship a broken build: page zoom changes between captures, edge cases in per-monitor DPI, and virtualized environments all break the assumption.
The robust approach is to derive the scale from the artifact itself. Decode the first frame, divide its pixel width by the viewport width you measured in CSS px, and use that ratio for every coordinate conversion:
const probe = await toBitmap(frames[0].dataUrl);
const scale = probe.width / viewport.width; // DPR x page zoom, measured
const pageWidth = probe.width;
const pageHeight = Math.round(viewport.pageHeight * scale);
async function toBitmap(dataUrl) {
const blob = await (await fetch(dataUrl)).blob();
return createImageBitmap(blob); // decoded, ready to draw
}
Every later offset is then Math.round(frame.y * scale) when drawn. If the probe says 1.75, you resize canvases at 1.75 — no guessing involved.
Ghosting: Fixed and Sticky Elements
The first time you stitch a real site, the result looks haunted: the site header repeats at the top of every frame's segment, a chat bubble appears five times, a cookie banner floats mid-page. That's position: fixed behaving exactly as specified — pinned to the viewport — while your screenshot sweeps the viewport down the page.
Trying to crop or paper over the duplicates never works reliably. The correct move is to hide overlays before capturing and restore them afterward. You don't need to walk every element with querySelectorAll('*'); the viewport perimeter tells you everything that can follow the scroll. Sample points along the edges and viewport-cross intersections with elementsFromPoint, then keep anything computing to fixed or sticky:
function hideOverlays() {
const found = new Set();
const xs = [0, innerWidth / 2, innerWidth - 1];
const ys = [0, innerHeight / 2, innerHeight - 1];
for (const x of xs) for (const y of ys) {
for (const el of document.elementsFromPoint(x, y)) {
const pos = getComputedStyle(el).position;
if (pos === 'fixed' || pos === 'sticky') found.add(el);
}
}
const saved = [];
for (const el of found) {
saved.push([el, el.style.visibility]);
el.style.visibility = 'hidden'; // visibility, not display: keeps layout
}
return saved;
}
Use visibility: hidden rather than display: none — hiding must not reflow the page, or you'll change the very geometry you measured. Save and restore the previous inline value, not a hard-coded reset.
One decision to make consciously: when to hide. A sticky header legitimately belongs in the first frame — it's the header as the user actually sees the top of the page. The pattern I use: capture frame 0 with overlays visible, hide them for frames 1..n, restore at the end. That yields one honest header and no repeats.
Lazy Content, Animations, and the Pre-Scroll Pass
Modern pages don't exist below the fold until you scroll. loading="lazy" images, IntersectionObserver-driven rendering, and content-visibility: auto sections all mean a fast programmatic scroll captures mostly empty space. Before the capture loop, do a slower reconnaissance pass: scroll through the whole page once, then come back to the top and wait for in-flight images to finish decoding.
async function warmUpPage(tabId) {
return chrome.scripting.executeScript({
target: { tabId },
func: async () => {
const root = document.scrollingElement || document.documentElement;
const step = innerHeight * 0.8;
const max = root.scrollHeight;
for (let y = 0; y < max; y += step) {
window.scrollTo(0, y);
await new Promise(r => setTimeout(r, 60)); // let observers fire
}
window.scrollTo(0, 0);
await Promise.all([...document.images].filter(i => !i.complete).map(i =>
new Promise(r => {
i.addEventListener('load', r, { once: true });
i.addEventListener('error', r, { once: true });
setTimeout(r, 2000); // never hang on a dead image
})
));
},
});
}
If your capture loop is paced at 550 ms per frame anyway, you can skip the separate warm-up and use a slower scroll during the first pass. Pick one, not both.
The other source of non-reproducible frames is animation: carousels advancing, skeleton shimmer, marquee tickers. Each frame catches a different moment, and the stitched result shows the same widget in several states. Pause everything at the start and resume at the end:
const running = document.getAnimations().filter(a => a.playState === 'running');
running.forEach(a => a.pause());
// ...capture...
running.forEach(a => a.play());
Whatever you hid, paused, or overrode, restore in a finally block. A screenshot feature that leaves the page in a modified state after a failed capture is a bug report waiting to happen.
Canvas Limits and Downscaling
Here is the failure that wastes days: your stitch code is correct, but the exported image is blank. Canvas 2D has two hard limits, and exceeding either doesn't throw — Chrome logs a warning and silently ignores draw calls.
- Maximum dimension: 65,535 px in either direction (Chrome)
- Maximum area: 268,435,456 px total (2²⁸) — on a canvas wider than 4096, the area limit bites before the dimension limit does
Do the arithmetic before allocating anything, because the numbers involved are large. A 1400 CSS px-wide page at 2× scale is a 2800 px-wide canvas; the area cap allows 268 435 456 / 2800 ≈ 95,869 px of height — but the dimension cap of 65,535 wins. And a full-page screenshot 2800 × 65000 px is 182 megapixels of RGBA — about 728 MB of backing store before PNG encoding even starts, which will peg memory on modest machines.
const MAX_DIM = 65535;
const MAX_AREA = 268_435_456;
const maxHeightFor = (widthPx) =>
Math.min(MAX_DIM, Math.floor(MAX_AREA / widthPx));
const height = Math.round(viewport.pageHeight * scale);
const limit = maxHeightFor(pageWidth);
let outputScale = 1;
if (height > limit) {
// Downscale to fit the canvas — a 4x-too-tall capture at 1/4 scale
// still shows every pixel of content, just smaller.
outputScale = limit / height;
}
const canvas = new OffscreenCanvas(
Math.round(pageWidth * outputScale),
Math.round(height * outputScale)
);
Each frame then draws at Math.round(frame.y * scale * outputScale). Downscaling is the pragmatic default: the user gets one complete, correct image, and the failure mode (slightly softer than native) is far better than a blank one. If the user actually needs native resolution for a very tall page, the honest answer is to emit numbered tiles — encode in vertical slices and either zip them or attach them in order:
const TILE_H = 8000;
async function encodeTiles(canvas) {
const blobs = [];
for (let y = 0; y < canvas.height; y += TILE_H) {
const h = Math.min(TILE_H, canvas.height - y);
const tile = new OffscreenCanvas(canvas.width, h);
tile.getContext('2d').drawImage(canvas, 0, y, canvas.width, h,
0, 0, canvas.width, h);
blobs.push(await tile.convertToBlob({ type: 'image/png' }));
}
return blobs;
}
One more encoding note from production: PNG-encoding a 100+ megapixel canvas can take ten seconds or more and is CPU-bound; JPEG at quality 0.92 encodes in a fraction of that. Offer PNG for fidelity when the page is short, JPEG when it is enormous, and tell the user which one they got.
The CDP Alternative: captureBeyondViewport
If an extension is allowed to use the debugger permission, the whole stitching problem can be skipped. The Chrome DevTools Protocol exposes a single-shot full-page capture: Page.captureScreenshot with captureBeyondViewport: true renders past the visible area and returns one image.
async function captureWithCDP(tabId) {
const target = { tabId };
await chrome.debugger.attach(target, '1.3');
try {
const [{ result: size }] = await chrome.scripting.executeScript({
target: { tabId },
func: () => ({
width: Math.max(document.body.scrollWidth,
document.documentElement.scrollWidth),
height: (document.scrollingElement || document.documentElement).scrollHeight,
}),
});
const { data } = await chrome.debugger.sendCommand(
target, 'Page.captureScreenshot',
{
format: 'png',
fromSurface: true,
captureBeyondViewport: true,
clip: { x: 0, y: 0, width: size.width, height: size.height, scale: 1 },
}
);
return `data:image/png;base64,${data}`;
} finally {
await chrome.debugger.detach(target); // always detach
}
}
It is faster, seam-free, and handles fixed elements more gracefully — they render once at their layout position instead of repeating. But the trade-offs are real and should be a conscious choice:
- It fails when DevTools is open on the tab — only one debugger client can attach, and the error is ugly.
- It shows a warning banner ("This browser is being debugged") while attached, which is user-visible and, for extensions distributed through the Chrome Web Store, a review-flagging permission.
- It still doesn't solve lazy content — the renderer draws the full height in one pass, but images the page never scrolled into view may still be unloaded. The pre-scroll warm-up is still required.
- Extremely tall pages can still fail inside the renderer's own raster limits, with far less control over fallback behavior than the manual loop gives you.
My rule: use the scroll-and-stitch path as the default engine, because it needs no scary permissions and degrades predictably. Keep CDP as an opt-in "high quality" mode for users who understand what the debugger banner means.
Memory Discipline
A screenshot pipeline holds three kinds of memory at once, and each has a different disposal strategy:
- Data URLs — each full-viewport PNG frame is 2–6 MB of base64 text. Forty frames in an array is easily 100–300 MB. Draw each frame into the canvas as soon as it arrives and drop the string; never accumulate all frames "to stitch later" unless the page is short.
- Decoded bitmaps — a 3840 × 2160
ImageBitmapis ~33 MB of unpadded RGBA that JavaScript GC does not track as heap pressure. Callbitmap.close()immediately afterdrawImage; don't wait for GC to feel like it. - The destination canvas — the single largest allocation, sized by the arithmetic in the previous section. Release it explicitly (
canvas.width = 0; canvas.height = 0;) after encoding, or at minimum drop every reference.
Where the work runs matters too. The capture orchestration must live in the service worker or popup (chrome.tabs and chrome.debugger aren't available elsewhere), but the stitching — decoding, drawing, encoding — is exactly the long, CPU-bound job that races the MV3 service worker's idle termination. Push it into an offscreen document, which gives you a stable DOM-less context that lives as long as the work does; the service worker just forwards frames and downloads the result. That split — thin orchestrator, fat offscreen worker — is the architecture I've settled on for every capture feature, and it is worth setting up on day one rather than retrofitting after the first "capture dies at frame 27" bug.
Production Checklist
- Activate the tab before capturing; remember
captureVisibleTabtakes a windowId, and only sees the active tab. - Pace every capture at ≥ 550 ms to stay under the 2/sec quota.
- Override
scroll-behavior: smooth, scroll, wait two rAFs plus a tail delay before capturing. - Clamp the final scroll position and record the clamped y, not the requested one.
- Detect the real scroll container instead of assuming the window scrolls.
- Derive scale from
bitmap.width / innerWidth— never trustdevicePixelRatioalone. - Pre-scroll for lazy content; wait for pending image loads with a timeout.
- Capture frame 0 with overlays visible, hide fixed/sticky elements for the rest, restore in
finally. - Pause running animations during capture, resume after.
- Check canvas limits (65,535 px dim / 2²⁸ px area) before allocating; downscale or tile.
bitmap.close()after every draw; drop data URLs as soon as they're consumed.- Run the stitch in an offscreen document; keep the service worker as a thin orchestrator.
References
- chrome.tabs.captureVisibleTab — Chrome Docs
- chrome.scripting API Reference
- chrome.debugger API Reference
- CDP Page.captureScreenshot
- createImageBitmap — MDN