ScreenshotNeo

BlogHow-to

How to preserve scroll position when capturing dynamic web content

Keep the intended content in view when dynamic pages change layout. Choose between restoring a pixel offset, anchoring a content item, and waiting for layout changes before capture.

By the ScreenshotNeo team4 October 202610 min read

To preserve scroll position during a dynamic-page capture, first decide what must stay fixed: the same document offset, the same content item, or the visible view while content above it loads. Use the browser’s native scroll anchoring for ordinary layout shifts, save and restore an offset for exact coordinate matching, or save a content item’s identity and relative position when that item matters most. For browser back/forward navigation, history.scrollRestoration controls the default restoration behavior. None of these mechanisms alone tells a screenshot system that every page update has finished.

This guide shows how to choose and implement each approach, including nested scrollers, dynamic content, timing, and capture-specific failure modes. The browser APIs discussed here are documented by MDN’s History API reference, its scroll anchoring guide, and the Resize Observer API reference.

1. Choose what “same scroll position” means

These goals are different and can require different restoration logic:

Goal What to preserve Best starting point
Same document coordinate The document’s vertical offset, such as 1,200 CSS pixels Save and restore window.scrollY, or the actual nested scroller’s scrollTop
Same content item A known article, message, row, or card at the same viewport-relative position Save a stable item identifier and its position relative to the scroll container
Stable view while content above loads The content currently being read remains in roughly the same place Allow browser scroll anchoring to work, then verify it in the target browser
Browser back/forward restoration The browser’s session-history location Keep history.scrollRestoration set to "auto" unless the app owns restoration

A raw offset is simple but can land on different content after items are inserted or removed above it. An item-based anchor can preserve meaning, but it requires stable identifiers and a known scroll container. Browser anchoring helps with common layout shifts, but does not guarantee that a separate capture workflow waits for the page to settle.

2. Let native scroll anchoring handle ordinary layout shifts

Scroll anchoring attempts to compensate for changes outside the viewport so the point being read remains visible. It is normally the least code for a user who is already viewing a page while content above them changes. See MDN’s overview of scroll anchoring.

Do not opt out globally unless the page needs that behavior. The CSS property overflow-anchor: none excludes a container or region from anchoring; exclusions can affect descendants, so apply them narrowly and test the result.

/* Usually leave anchoring enabled. Opt out only for a region that needs it. */
.unstable-carousel {
  overflow-anchor: none;
}

Support varies across browser and device versions. MDN reports scroll anchoring as newly available across latest devices and browser versions since September 2026, while warning that older devices may not support it. Check the actual browsers used for capture rather than assuming uniform behavior. MDN documents the compatibility caveat.

3. Restore a document or nested-container offset

When exact coordinate restoration is the requirement, record the scroll offset and restore it after the relevant content has been rendered. The following runnable browser example stores and restores the document’s vertical offset for the current tab session:

// Save before navigating away or before a controlled content update.
sessionStorage.setItem("saved-scroll-y", String(window.scrollY));

// Call after the content needed for the return view has rendered.
function restoreDocumentOffset() {
  const raw = sessionStorage.getItem("saved-scroll-y");
  if (raw === null) return;

  const y = Number(raw);
  if (!Number.isFinite(y)) return;

  window.scrollTo({ top: y, left: window.scrollX, behavior: "instant" });
}

// A minimal example: wait for DOM parsing, then restore.
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", restoreDocumentOffset, { once: true });
} else {
  restoreDocumentOffset();
}

This example is suitable when the document is the scroller and the page has enough content at restoration time. If images, ads, or asynchronous data later change layout, the same numeric offset may no longer show the same content. Restore again only when your application has a reliable signal that those relevant updates have completed; repeated blind corrections can cause visible jumps.

For a nested scrolling element, save and restore that element’s scrollTop instead. The element must exist and have its scrollable content before restoration:

const panel = document.querySelector("#results-panel");
if (!panel) throw new Error("Results panel was not found");

// Save before the update or navigation.
sessionStorage.setItem("results-panel-scroll-top", String(panel.scrollTop));

// Restore after the panel's content is rendered.
const savedTop = Number(sessionStorage.getItem("results-panel-scroll-top"));
if (Number.isFinite(savedTop)) {
  panel.scrollTop = savedTop;
}

For browser session-history navigation specifically, the History API property history.scrollRestoration has two values. "auto" lets the browser restore the location; "manual" delegates restoration to application code. Leave it automatic unless your app implements and owns a complete restoration flow.

// Default browser handling for back/forward navigation:
history.scrollRestoration = "auto";

// Only when the application takes responsibility for restoring positions:
// history.scrollRestoration = "manual";

See MDN: History: scrollRestoration property.

4. Preserve a content item when the page is reflowing

If the same article, message, or result must remain visible, store its stable identifier and its offset from the scroll container’s visible top. After the update, find the item and adjust the container by the difference. This is application-level guidance built from the browser’s scrolling primitives; the APIs do not define a universal semantic-anchor algorithm.

// Save the first visible item and its position inside a scroll container.
function saveAnchor(container, item) {
  const containerTop = container.getBoundingClientRect().top;
  const itemTop = item.getBoundingClientRect().top;

  return {
    id: item.dataset.itemId,
    offset: itemTop - containerTop
  };
}

// Restore after the relevant items have been rendered.
function restoreAnchor(container, saved) {
  const item = [...container.querySelectorAll("[data-item-id]")]
    .find(node => node.dataset.itemId === saved.id);
  if (!item) return false;

  const containerTop = container.getBoundingClientRect().top;
  const currentOffset = item.getBoundingClientRect().top - containerTop;
  container.scrollTop += currentOffset - saved.offset;
  return true;
}

const container = document.querySelector("#feed");
const visibleItem = container?.querySelector('[data-item-id="post-42"]');
if (container && visibleItem) {
  const saved = saveAnchor(container, visibleItem);
  // Render or update the feed here, then call restoreAnchor(container, saved).
}

Use identifiers that survive rerenders, such as a database key or stable route slug. Avoid using a list index if insertions or sorting can change what that index refers to. If the item was removed, choose a documented fallback such as the next surviving item or a saved coordinate.

5. Use ResizeObserver as a signal, not a “page is ready” detector

ResizeObserver reports changes to an observed element’s content or border-box size. It can tell application code that a relevant region resized and is a useful signal for deciding whether to re-evaluate an anchor. It does not identify the semantically correct anchor and does not prove that all scripts, images, or network activity on the page are finished. MDN: Resize Observer API.

const content = document.querySelector("#dynamic-content");
const scroller = document.querySelector("#feed");

if (content && scroller) {
  const saved = {
    id: "post-42",
    offset: 120 // Replace with the measured item-to-container offset.
  };

  let scheduled = false;
  const observer = new ResizeObserver(() => {
    if (scheduled) return;
    scheduled = true;

    requestAnimationFrame(() => {
      scheduled = false;
      // Re-evaluate only if the application still needs to preserve this anchor.
      restoreAnchor(scroller, saved);
    });
  });

  observer.observe(content);
  // When the relevant update sequence is complete:
  // observer.disconnect();
}

In production, measure and save the anchor immediately before the update rather than using the illustrative fixed offset above. Observe a region whose size actually affects the anchor. Disconnect when no longer needed. Avoid callback writes that resize the observed element again: that can create repeated resize cycles. If a write is intentional, defer it with requestAnimationFrame and make the update idempotent. MDN describes resize-loop considerations.

6. Capture only after the relevant layout change

A screenshot can preserve the scroll position perfectly and still capture the wrong state if it runs before the desired content appears or before restoration completes. There is no universal “page settled” signal established by these browser APIs. Use a site-specific condition you control: a particular element is present, an application render has completed, or a known update promise has resolved. A fixed delay can be a fallback, but it is not proof that a page has finished changing.

  1. Identify whether the document or a nested element scrolls.
  2. Choose coordinate fidelity or content-item fidelity.
  3. Allow relevant content to render, then restore the saved offset or item relationship if needed.
  4. Wait for the specific page condition that matters to the capture.
  5. Capture and inspect whether the intended item is still in the viewport.

In an automated browser, perform these steps in the page or automation flow before taking the screenshot. Keep the capture flow’s wait condition tied to the page’s actual content instead of assuming that a generic network-idle event covers delayed timers, lazy content, or application-specific work.

7. cURL, Python, and Node.js capture examples

These examples request a screenshot of a public page with ScreenshotNeo. They demonstrate capture, not app-specific scroll restoration: arrange the target page’s desired scroll state in your own app or browser workflow first. The ScreenshotNeo documentation describes the API options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

8. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. A single GET request captures a URL as PNG, JPEG, WebP, or PDF. Cookie banners are accepted like a visitor and removed along with 60+ known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. Its MCP server gives Claude, Cursor, and other MCP clients the take_screenshot, get_page_info, and capture_pdf tools.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

That call avoids setting up a browser for a straightforward URL capture. ScreenshotNeo also supports full-page captures, element selectors, custom CSS and JavaScript, wait conditions, viewport and device presets, caching, signed links, async jobs, and bulk capture. One thousand screenshots a month are free with no card; paid plans start at $5 for 3,000. See the API documentation, then sign up for 1,000 free screenshots a month, with no card.

9. Troubleshooting

Symptom Likely cause Fix
The same pixel offset shows different content Content above the position was inserted, removed, or resized Preserve a stable item and relative offset instead of only a raw coordinate
The browser returns to the top on back navigation App code set history.scrollRestoration to "manual" without restoring the position, or restoration ran before content existed Use "auto" unless the application owns restoration; otherwise restore after the relevant content renders
A nested panel does not restore The code read or wrote window.scrollY instead of the panel’s scrollTop Identify the actual scroll container and use its offset
The anchor item cannot be found It was removed, its identifier changed, or the query ran before rendering Use stable identifiers, wait for rendering, and define a fallback for removed items
The view jumps after restoration More layout changes occurred after the restore, or multiple restoration callbacks ran Wait for the relevant application update, coalesce callbacks, and restore only when needed
ResizeObserver repeatedly fires The callback changes the observed element’s size and triggers another observation Avoid self-triggering writes; defer intentional changes with requestAnimationFrame and disconnect when done
The screenshot is taken before the intended state The capture started before app-specific content or restoration completed Wait for a specific selector or application completion signal; a delay alone is not a readiness guarantee
Anchoring behaves differently across capture browsers Scroll anchoring support or page opt-outs differ Check target browser support and inspect overflow-anchor rules

10. Performance, reliability, and cost

Saving an offset or a stable identifier is lightweight; the reliability work is choosing the correct scroller, waiting for the right update, and avoiding repeated corrective scrolling. Observe only relevant elements with ResizeObserver, coalesce work into an animation frame, and disconnect observers when the update is complete. Avoid polling layout continuously.

Native anchoring reduces custom code but is browser-dependent and can be affected by opt-outs. Application-managed restoration is more explicit, but its correctness depends on stable identifiers and timing. For captures, repeated retries and arbitrary long waits add latency and can increase the work done by your own browser infrastructure. Use a specific readiness condition and capture once the required state is reached.

ScreenshotNeo bills only clean shots: bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. Each response includes X-Page-Verdict and X-Billed headers. Plans are Free: 1,000/month; Starter: $5 for 3,000; Growth: $15 for 15,000; Pro: $39 for 60,000; Scale: $99 for 250,000; and Business: $249 for 1,000,000. Yearly billing gives two months free, and every feature is on every plan. Check the documentation for available capture configuration.

11. Frequently asked questions

Does setting scrollRestoration to manual preserve a position automatically?

No. It delegates the restoration behavior to application code. Save and restore the position yourself, or keep the browser’s default automatic behavior.

Will scroll anchoring keep an exact pixel offset?

Its goal is to keep the visible document point stable across certain layout changes; it is not a promise that a fixed numeric offset remains unchanged.

Does ResizeObserver tell me when a page is ready for a screenshot?

No. It reports size changes for observed elements. Your capture still needs a page-specific completion condition.

Should I save a number or an element identifier?

Save a number when the coordinate is the requirement. Save an identifier and relative position when the same content item must remain visible after reflow.