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.

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):
| Page | 1.0.1 | 1.1 |
|---|---|---|
| 10 screens | 10.1 s | 5.4 s |
| 30 screens | 28.0 s | 16.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. Thechrome.debuggerAPI 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.