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

> 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.

Published October 11, 2026 · By Sarangan Raviraj · Canonical: https://clicksnap.live/blog/how-we-made-full-page-screenshots-2x-faster-chrome-extension

## 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:

```ts
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](https://chromewebstore.google.com/detail/hgcifincdljnbpbdpjhaobmfnlkakhkf). Press Alt+Shift+F (Option+Shift+F on a Mac) on any long page. The [release notes](/docs/version-history) list everything that changed in 1.1.
