How to Screenshot Dynamic Content When a Page Uses requestAnimationFrame
A requestAnimationFrame callback does not mean a page is ready. Wait for the application state you need, then capture it reliably with Playwright.
requestAnimationFrame (rAF) schedules a callback before the browser’s next repaint. It does not tell you that a page’s network requests, fonts, images, or application updates have finished. To capture dynamic content reliably, first wait for the specific application state you need—such as a chart’s data being ready or an animation reaching a chosen progress value—then take the screenshot.
For visual regression tests, Playwright’s expect(page).toHaveScreenshot() waits for two consecutive screenshots to match. For a particular animated state, synchronize with the application’s own state and call page.screenshot(). The best method depends on what you need to capture: a whole rendered page, a canvas frame, or a user-selected screen surface.
1. Understand what requestAnimationFrame tells you
The browser calls an rAF callback before the next repaint. It is one-shot: an animation schedules another frame by requesting rAF again. Its cadence generally follows the display’s refresh rate, and browsers usually pause callbacks in hidden tabs and iframes. These properties make rAF useful for coordinating visual updates, but a callback is not a universal “page ready” signal. See MDN’s requestAnimationFrame documentation.
For example, an rAF callback may run while a fetch request is still pending, before a web font loads, or before application code applies data received asynchronously. Waiting one or two frames can help ensure a known update has passed through the rendering cycle, but it cannot prove unrelated asynchronous work has completed. Define readiness in terms of the content you want to see.
2. Capture a known application state with Playwright
Use a real application signal whenever possible. The following runnable Node.js example assumes the page sets window.chartReady to true after the desired chart data and visual state have been applied.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
try {
await page.goto('https://example.com/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.chartReady === true);
// Optional: allow a final state update queued for the next rendering frame.
await page.evaluate(() => new Promise(requestAnimationFrame));
await page.screenshot({ path: 'chart.png', fullPage: true });
} finally {
await browser.close();
}
})();
Install Playwright with npm install playwright and install its browser with npx playwright install chromium. Replace the example URL and readiness flag with your application’s real values. waitForFunction waits until its predicate becomes truthy; it does not make an arbitrary predicate meaningful. The extra rAF is optional and only useful when the final state needs to pass through a rendering callback.
Wait for a visible state instead of a private flag
If the application exposes a reliable visible marker, wait for it directly:
await page.locator('[data-testid="chart-loaded"]').waitFor({ state: 'visible' });
await page.screenshot({ path: 'chart.png' });
For a specific animation progress, expose or inspect a real progress value and wait for the target. For example, if the application writes a numeric value to a data attribute:
await page.waitForFunction(() => {
const chart = document.querySelector('[data-testid="chart"]');
return chart && Number(chart.getAttribute('data-progress')) >= 1;
});
await page.screenshot({ path: 'chart-complete.png' });
Prefer a stable predicate over waitForTimeout. A fixed delay may be too short on a slow run and unnecessarily long on a fast one. Use a timeout only as a bound for failure, not as evidence that the desired content has appeared.
3. Make visual regression snapshots stable
When the goal is comparison against a baseline, use Playwright’s screenshot assertion:
const { test, expect } = require('@playwright/test');
test('dashboard screenshot is stable', async ({ page }) => {
await page.goto('https://example.com/dashboard');
await page.locator('[data-testid="chart-loaded"]').waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot('dashboard.png');
});
toHaveScreenshot() waits for two consecutive screenshots to match before comparison. That behavior helps with transient changes, but a continuously moving animation may never settle. In that case, put the page into a defined state, stop or control the animation, or mask the region that is intentionally variable. Playwright’s assertion behavior is documented in its PageAssertions API.
Be precise about the API and defaults. Playwright’s page.screenshot() allows animations by default. Screenshot assertions document disabled animations as their default. The screenshot option animations: 'disabled' affects CSS animations, transitions, and Web Animations: finite animations are fast-forwarded and infinite animations are canceled to their initial state, then replayed after capture. It does not freeze an rAF-driven JavaScript animation at a progress value you choose. See the Page API for screenshot options.
// Stabilize supported CSS/Web Animations for a one-off capture.
await page.screenshot({ path: 'page.png', animations: 'disabled' });
// Screenshot assertion: use only after the page is in the intended state.
await expect(page).toHaveScreenshot('page.png', { animations: 'disabled' });
4. Choose the right capture path
| Method | What it captures | Best fit | Important constraint |
|---|---|---|---|
| ScreenshotNeo | A rendered webpage screenshot or PDF from one API request | Capturing a URL without running your own browser setup | It captures the page state reached by the time the service takes the shot; it does not provide a documented rAF progress-synchronization option. |
| Playwright screenshot | Rendered page or selected element | Automated tests and application-controlled state synchronization | Your code must wait for the right application condition. |
Canvas captureStream() |
Canvas frames as a media stream | Canvas-only output where a video stream is acceptable | It does not capture surrounding DOM as a page screenshot. |
| Screen Capture API | A user-selected tab, window, or display | User-authorized screen sharing or recording | The browser asks the user to select a surface. |
| html2canvas | An image reconstructed from DOM data | In-page DOM-derived rendering | It is not a literal browser screenshot and has CSS and cross-origin limitations. |
For automated whole-page or element screenshots where you control the application, Playwright is usually the direct fit. The canvas captureStream API and requestFrame() are for canvas media capture. The Screen Capture API is for user-selected surfaces. html2canvas documentation describes its DOM reconstruction approach and limitations.
5. Capture a canvas frame as a stream
If the thing you need is specifically a canvas frame, captureStream(0) disables automatic frame capture. Call requestFrame() on the returned video track to request a frame:
const canvas = document.querySelector('canvas');
if (!canvas) throw new Error('Canvas not found');
const stream = canvas.captureStream(0);
const [track] = stream.getVideoTracks();
track.requestFrame();
// Use `stream` with a media recorder or another video-stream consumer.
// Stop the track when finished.
track.stop();
This outputs a video stream, not a PNG screenshot of the page. If you need a still image, a canvas can also be exported through its canvas image APIs when its origin is clean. Cross-origin content can taint a canvas; MDN documents a SecurityError for a canvas that is not origin-clean. Check target-browser support and the captureStream reference before relying on this path.
6. Screenshot a URL without managing a browser
ScreenshotNeo is a website screenshot API and MCP server. A GET request with a URL returns a PNG, JPEG, WebP, or PDF. It is useful when you need a rendered page capture without installing and operating Playwright in your own environment. Since it captures the page as rendered by the service, it is not a substitute for application-level synchronization to a particular rAF animation progress value.
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 (await import('node:fs/promises')).writeFile('shot.webp', bytes);
Replace the target URL and save the API key as a secret in your application. See the ScreenshotNeo API documentation for request parameters and response details. The parameter names used by other screenshot APIs also work, which can simplify switching.
Or skip the browser setup
One request returns the screenshot:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
ScreenshotNeo accepts cookie and consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. An MCP server gives Claude, Cursor, and other MCP clients the take_screenshot, get_page_info, and capture_pdf tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Sign up for 1,000 free screenshots a month, no card required.
7. Troubleshoot common capture failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot shows an old or empty chart | Capture happened before data or the chart’s final state was applied. | Wait for a real app readiness flag, data marker, or expected DOM state. Add an rAF wait only if the final update is queued for a render frame. |
waitForFunction times out |
The readiness predicate never becomes true, the page is still loading, or the code uses the wrong state signal. | Inspect the predicate in the page, confirm the application sets it, and wait for a bounded condition tied to the actual content. |
| Screenshot assertion never stabilizes | An animation, clock, rotating content, or live data keeps changing pixels. | Set the app to a fixed state, control the changing source, or mask the intentionally dynamic region. Do not expect the assertion to select a particular animation frame. |
Animation differs despite animations: 'disabled' |
The movement is driven by JavaScript rAF rather than a supported CSS/Web Animation. | Expose and set the desired application state or progress before calling page.screenshot(). |
| rAF callback does not run in a background page | Browsers commonly pause rAF in hidden tabs and iframes. | Run the capture in a visible/foreground context where applicable, or use browser automation to control the page lifecycle. |
captureStream() throws a security error |
The canvas is not origin-clean, often due to cross-origin media without suitable CORS permission. | Serve assets with appropriate CORS headers or use a capture path that does not export the tainted canvas. |
| Canvas stream has no requested frame | The track was not obtained, or manual capture was used without a zero-rate stream and requestFrame(). |
Use canvas.captureStream(0), get its video track, then call requestFrame(). |
| Screen Capture API does not start automatically | It requires a user selection and permission. | Prompt the user to select the intended tab, window, or display; use browser automation for unattended page screenshots. |
| html2canvas omits an image or iframe | Cross-origin restrictions or unsupported CSS/rendering behavior. | Review html2canvas’s documented limitations and CORS configuration, or use a real browser screenshot when fidelity matters. |
8. Reliability, performance, and cost
- Wait on the narrowest meaningful condition. A specific readiness flag or selector avoids arbitrary delays and reduces flaky timing.
- Bound waits and clean up browser resources. Give navigation and readiness waits a sensible timeout for your environment, and close pages or browser processes in a
finallyblock so failed captures do not leave workers running. - Control sources of visual variation. Data, timestamps, random values, fonts, viewport size, and animation state can all affect pixels. Fix these inputs for regression snapshots where possible.
- Use the output path that matches the requirement. Canvas streams are useful for video workflows; Playwright and screenshot APIs return page images; screen capture is user-driven. Converting between these paths can add complexity without improving fidelity.
- Account for capture workload. Running browsers consumes compute and needs browser installation and lifecycle management. A hosted API removes that browser operations work but has plan limits and pricing. ScreenshotNeo offers 1,000 shots per month free without a card; paid tiers are 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 outcomes instead of assuming every request is a usable image. ScreenshotNeo identifies page verdict and billing status in
X-Page-VerdictandX-Billedheaders. Failed loads and cache hits are not billed.
9. Frequently asked questions
Does one requestAnimationFrame callback mean the browser has painted the screenshot?
No. It runs before a repaint; it is not a guarantee that all asynchronous page work is complete or that a screenshot has already captured that paint.
Can I capture an exact JavaScript animation frame with Playwright’s disabled animations option?
Not by that option alone. Synchronize the application to the desired progress or state, then capture it.
Is canvas captureStream a way to screenshot an entire webpage?
No. It produces a media stream from canvas contents. The surrounding DOM is not included.
Which method should I use for a visual regression test?
Use Playwright’s toHaveScreenshot() after making the page state deterministic. Use a separate app-state synchronization step when you need a particular point in an animation.


