Engineering•9 min read

How We Made Full-Page Screenshots 2× Faster in a Chrome Extension

Pacing captureVisibleTab, stitching on the editor canvas, and the bugs we found along the way.

How We Made Full-Page Screenshots 2× Faster in a Chrome Extension
Raviraj Sarangan
Raviraj Sarangan
Lead Extension Architect & Founder • Published October 11, 2026
Summary

ClickSnap 1.1 made full-page capture about twice as fast (10.1 s to 5.4 s for a 10-screen page) by pacing captureVisibleTab calls to Chrome’s two-per-second limit instead of fixed waits, keeping slices as PNG Blobs, and stitching them directly onto the editor canvas so nothing is base64-encoded before the user sees the image.

The numbers

We measure full-page capture with a Playwright benchmark that loads the packaged extension, opens a long test page with a sticky header, triggers a capture and waits until the editor has painted the image (1280×800 window, headless Chromium):

Page1.0.11.1
10 screens10.1 s5.4 s
30 screens28.0 s16.0 s

Each number is the median of two runs on the same machine. Your times will differ with page length, screen size and device pixel ratio, but the ratio between versions holds.

Where the time went

A full-page capture in a Manifest V3 extension is a loop: scroll the page, call chrome.tabs.captureVisibleTab, repeat, then stitch the slices. Version 1.0.1 spent about one second per slice. Only half of that was necessary.

1. Fixed waits on top of the rate limit. Chrome allows two captureVisibleTab calls per second (MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND). We had a 550 ms sleep before each capture, plus a 180 ms "settle" wait after scrolling and an 80 ms buffer. Those waits ran one after another, so slices landed about 1 s apart instead of 0.5 s.

2. Base64 everywhere. After the loop we PNG-encoded the stitched canvas, converted it to a base64 data URL in the service worker (there is no FileReader there, so it was a JavaScript loop), saved that string to IndexedDB twice, opened the editor tab, read it back and decoded it again. On a long page that is several seconds before the user sees anything.

3. Software canvases. Several canvases were created with willReadFrequently: true, which keeps them on the CPU. Nothing needed to read pixels back except the pixelate tool.

What we changed

Pace to the real limit

Instead of fixed sleeps, we remember when the last capture started and only wait for whatever is left of the 500 ms window. Scrolling and the browser repaint happen inside that window rather than after it:

const wait = MIN_CAPTURE_INTERVAL_MS - (Date.now() - lastCaptureStart);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastCaptureStart = Date.now();
return await captureTabVisibleArea(windowId);

The page side waits two animation frames after scrolling (one to apply the scroll, one to paint) instead of a fixed 180 ms. If Chrome still reports the rate-limit error we retry once instead of failing the whole capture.

Keep slices as Blobs and let the editor stitch

Each slice is converted straight to a Blob with fetch(dataUrl).blob(). When the loop ends, the service worker saves the raw slices and opens the editor immediately. The editor decodes each slice with createImageBitmap, draws it onto its own (GPU-backed) canvas and releases it, decoding the next slice while drawing the current one. The user sees the image as soon as the slices are drawn; the PNG encode and the save to the library happen afterwards in the background.

Copy got the same treatment: with no annotations it reuses the encoded PNG, and otherwise it passes a Promise<Blob> to ClipboardItem so the clipboard write starts inside the click’s user activation.

Hide sticky elements once

To stop a sticky header appearing on every slice, we hide fixed and sticky elements after the first slice. The old code rescanned the whole DOM with getComputedStyle on every slice, and on the second scan it recorded our own visibility: hidden as the element’s “original” value, so headers stayed hidden after the capture. Now we scan once, then a MutationObserver reports only elements added or restyled during the capture, and each element’s original inline style is recorded the first time and never overwritten.

A bug the benchmark didn’t catch

Our three content scripts were bundled as plain scripts with minified top-level names (let e, let a…). Content scripts injected by the same extension share one isolated world, so running “select area” after “delayed capture” on the same page threw Identifier 'e' has already been declared and the second capture silently did nothing. Each script is now bundled as a self-contained IIFE with esbuild, with a guard so re-injection doesn’t register listeners twice.

Limits we can’t remove

  • About 0.5 s per screen. That is Chrome’s rate limit for captureVisibleTab; every extension that uses it, including GoFullPage, is bound by it. The chrome.debugger API can capture beyond the viewport in one call, but it shows a warning banner and needs a much more alarming permission, so we don’t use it.
  • 16,384 pixels per side. Very tall pages are shown scaled down in the editor; Export saves them at full resolution as several parts or one multi-page PDF.

Try it

ClickSnap is free on the Chrome Web Store. Press Alt+Shift+F (Option+Shift+F on a Mac) on any long page. The release notes list everything that changed in 1.1.

Start capturing with ClickSnap today

Lossless full-page scrolling with smart sticky suppression, 14 annotation tools, and 100% private local storage.