← Back to Blog

80,000 Pixels Tall: Geometry Budgets, Viewport Traps and a Deadlock in MV3 Full-Page Capture

October 202613 min read

The Page That Killed the Renderer

Companion to Full-Page Screenshots in Chrome Extensions: Stitching, Fixed Elements and Memory Limits, which covers the scroll-and-stitch path and canvas limits. This article is about the CDP path, the geometry budget, and the deadlock.

"Full-page screenshot" sounds like a solved problem. Scroll, capture, stitch. Then you point it at an infinite feed and the renderer process dies with an out-of-memory error.

The concrete case: a CSDN homepage. A 30-screen scroll-and-stitch run left the page at 85,691 px tall, 16,791 DOM nodes and 3,903 images. Every additional screen of scrolling appended more content, which meant the capture loop never reached a bottom. The renderer eventually exhausted memory and the extension surfaced a generic "exceeded memory" error that told the user nothing actionable.

What followed was four separate dead ends, each one only obvious after measuring rather than reasoning. This article is the postmortem — the traps, the numbers, and the geometry budget that finally bounded the problem.

Dead End #1: Stretch the Viewport to the Whole Page

The classic trick for full-page capture is to call Emulation.setDeviceMetricsOverride with the viewport height set to the full document height, then take a single screenshot. It works beautifully on static article pages, which is why it propagates through every tutorial.

On an infinite feed it fails in a way that is hard to see. When you enlarge the viewport to the current document height, the "load more" sentinel at the bottom of the feed is pulled back into the visible area. The site loads another batch. The document grows. You are now capturing a viewport that is shorter than the page it was sized for.

Measured on that same feed: within 300 ms of the override, document height went from 3,954 px to 6,609 px. The resulting screenshot covered roughly the first half of the page and silently stopped — no error, no warning, just a truncated image that looked plausible.

Dead End #2: Scroll Until You Reach the Bottom

The obvious fix is to trigger lazy loading first: read scrollHeight once, then scroll from top to bottom in steps so images and cards render.

The bug is the word "once". Infinite feeds do not have a bottom. Looping from the initial scrollHeight still appends content at every step, so the loop keeps extending its own target. In the CSDN case the reachable height after 30 screens was already 85,691 px — and the page was still growing when the renderer ran out of memory.

The fix is not a bigger loop, it is a hard stop:

const LAZY_LOAD_MAX_MS = 12000;
const LAZY_LOAD_MAX_STEPS = 40;

// Stop on whichever comes first: wall clock or step count.
// An infinite feed has no natural bottom — impose one.
while (steps < LAZY_LOAD_MAX_STEPS && Date.now() - startedAt < LAZY_LOAD_MAX_MS) {
  window.scrollBy(0, viewportHeight);
  await nextFrame();
  steps++;
}

A bounded lazy-load pass is not just a performance guard. It is the difference between "the capture stops early and says so" and "the tab dies".

The Geometry Budget

Once scrolling is bounded, the remaining risk is asking Chrome for a bitmap it cannot allocate. A 30,000 px tall page at device pixel ratio 2 is a 60,000 px wide-by-tall allocation before any encoding — hundreds of megabytes in one shot.

So the capture requests are constrained by two constants and one pure function:

const MAX_CAPTURE_SIDE = 16000;   // physical px per side
const MAX_CAPTURE_AREA = 50e6;    // physical px total

function computeCaptureGeometry(cssWidth, dpr) {
  const width = Math.max(1, Math.round(cssWidth || 1));
  // Shrink the scale if the page is wider than the side cap allows
  const scale = Math.max(0.1, Math.min(dpr || 1, MAX_CAPTURE_SIDE / width));
  const maxCssHeight = Math.floor(
    Math.min(MAX_CAPTURE_SIDE, Math.floor(MAX_CAPTURE_AREA / (width * scale))) / scale
  );
  return { scale, maxCssHeight };
}

Two things are easy to get wrong here:

When the document exceeds maxCssHeight, the extension captures the top portion and reports captureTruncated with the actual cutoff. A truncated screenshot the user knows about is strictly better than a failed capture or a dead tab.

The Capture Path That Does Not Touch the Viewport

For normal documents the fix removes the viewport override entirely:

await sendCDP(debuggee, 'Page.captureScreenshot', {
  format: 'png',
  captureBeyondViewport: true,
  clip: { x: 0, y: 0, width, height: captureHeight, scale: captureScale }
});

captureBeyondViewport renders beyond the visible area without resizing it, so the infinite-feed sentinel never gets pulled into view. But there is an exception worth knowing: pages whose scrolling happens in an inner container (GitHub-style layouts) do not respond to captureBeyondViewport correctly — the document itself is viewport-sized and the tall part lives inside a nested scroller.

The heuristic that worked: if the measured content height exceeds the document height by more than ~10 px, the tall content is inside a nested scroller, so keep the viewport-stretch path for that tab. Otherwise use the clip path.

"It Captured One Screen Short"

After switching to the clip path, captures came back about 2,600 px short on lazy-loaded pages. The cause was measurement timing: the last batch of lazy-loaded content had been requested but not laid out, so scrollHeight was read one screen behind reality.

Fixed sleeps are the wrong answer — too short on slow pages, wasted time on fast ones. The reliable signal is stability:

// PS_FULL_WAIT_STABLE: resolve when scrollHeight (and image count)
// has not changed for `quiet` ms, or `timeout` elapses.
{ type: 'PS_FULL_WAIT_STABLE', quiet: 250, timeout: 1500 }

250 ms of quiet is enough to catch a settled layout; the 1.5 s cap prevents a page with a constantly animating counter from blocking the capture forever.

The Deadlock That Required a Browser Restart

The worst bug in the whole sequence was not about memory. It was a lock that never released.

The flow was: take an isCapturing lock, ask the content script to wait for a render frame, then capture. The wait used requestAnimationFrame. When the tab is backgrounded — or the window is minimised, or the user switches tabs during a long capture — rAF callbacks do not fire. The message never resolves. And chrome.tabs.sendMessage has no default timeout, so the promise simply hangs.

Now add the keep-alive: a 20-second interval touching chrome.storage so the service worker is not recycled mid-capture. That keep-alive keeps the stuck worker alive. The result is a permanent lock: every subsequent capture request sees isCapturing === true, returns "busy", and the only recovery is restarting the browser.

Three guards fix it, and each one is necessary:

const SEND_TO_TAB_TIMEOUT = 8000;    // hard cap per content-script round trip
const CAPTURE_LOCK_TIMEOUT = 120000; // treat a lock older than 2 min as stale

let isCapturing = false;
let captureStartedAt = 0;
let captureToken = 0;

// 1. Every message is raced against a timeout
function sendToTab(tabId, msg) {
  return Promise.race([
    chrome.tabs.sendMessage(tabId, msg),
    new Promise((_, reject) => setTimeout(() => reject(new Error('PS_TIMEOUT')), SEND_TO_TAB_TIMEOUT))
  ]);
}

// 2. A lock with a timestamp can go stale
function isLocked() {
  return isCapturing && Date.now() - captureStartedAt < CAPTURE_LOCK_TIMEOUT;
}

// 3. A superseded capture must not release the current one's lock
const token = ++captureToken;
try { /* ... */ } finally {
  if (captureToken === token) { isCapturing = false; stopKeepalive(); }
}

The token matters more than it looks. Without it, an abandoned capture's finally block releases the lock held by the capture that superseded it, which reopens the door to two concurrent captures writing to the same storage key.

The 10 MB Wall

chrome.storage.session is capped at 10 MB. A full-page PNG at 16,000 px does not fit, so the old implementation stored the image, got evicted, retried, evicted again — an eviction loop that spun until the service worker was recycled.

The fix was to move captured images to IndexedDB (a small db.js module) and keep only metadata in session storage. Eviction became explicit: keep the five most recent captures, iterate with openKeyCursor and delete the oldest. Annotations followed the same principle — mosaic regions are stored as rectangles, not as mutated ImageData. A 20-rectangle annotation is bytes; a mutated full-page bitmap is megabytes.

Checklist

  1. Bound lazy-load scrolling by time and step count — infinite feeds have no bottom.
  2. Never size the viewport to the document on a feed; use captureBeyondViewport + clip.
  3. Detect inner-scroller pages (contentHeight > docHeight + 10) and keep a separate path for them.
  4. Budget capture in physical pixels: side cap plus area cap, and down-scale the width when needed.
  5. Truncate deliberately and tell the user, instead of allocating a bitmap that will fail.
  6. Wait for layout stability (quiet window + timeout), never for a fixed sleep.
  7. Race every cross-context message against a timeout; assume rAF never fires in a hidden tab.
  8. Timestamp your locks, expire them, and guard release with a token.
  9. Keep large binaries out of storage.session; IndexedDB plus explicit eviction.

References

Related Articles