How to Screenshot Single Page Applications with Puppeteer
Learn how to capture reliable screenshots of React, Vue, and Angular apps with Puppeteer — including wait strategies for dynamic content, client-side routing, and common SPA rendering pitfalls.
Single page applications are everywhere — React, Vue, Angular, and their many variants power the majority of modern web apps. But they are uniquely difficult to screenshot programmatically. Where a traditional server-rendered page sends complete HTML that a headless browser can capture immediately, an SPA sends an empty shell and builds the entire UI with JavaScript after the page loads. Take the screenshot too early and you get a blank page or a loading spinner instead of the content you expected.
This guide covers everything you need to reliably screenshot SPAs with Puppeteer: framework-specific wait strategies, generic readiness detection, client-side routing pitfalls, and a production-ready capture function that handles all of it. We assume you already have Puppeteer installed and understand the basics — if not, start with our Puppeteer getting started guide first.
Why SPAs Are Different
Traditional server-rendered pages generate HTML on the server and send it to the browser as a complete document. When Puppeteer navigates to one of these pages, the HTML it receives already contains all the text, images, and structure that makes up the visible page. Waiting for network idle or the load event is usually sufficient to capture a complete screenshot.
Single page applications work differently. The server sends a minimal HTML document — often little more than a shell — and the browser runs JavaScript to build the actual content. Here is what the raw HTML of a typical React application looks like before JavaScript executes:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>My App</title> <link rel="stylesheet" href="/static/css/main.a1b2c3.css" /> </head> <body> <div id="root"></div> <script src="/static/js/main.d4e5f6.js"></script> </body> </html>
That <div id="root"></div> is the entire body content. Everything the user actually sees — navigation, content, images, interactive components — gets created by JavaScript after the browser downloads and executes the bundle. If Puppeteer takes a screenshot before that JavaScript finishes running, the result is a blank page or whatever loading indicator the app shows during initialization.
This is fundamentally different from screenshotting a WordPress blog or a Rails app where the server sends fully rendered markup. With an SPA, the browser is doing the rendering work, and your screenshot automation needs to wait for that work to finish.
The same problem exists with Vue, Angular, Svelte, and every other client-side framework. Vue mounts to <div id="app"></div>, Angular bootstraps into <app-root></app-root>, and the pattern is always the same: empty container in the HTML, JavaScript fills it in later.
The Core Problem: Timing
The obvious question is: why not just use waitUntil: 'networkidle0'? Puppeteer will wait until there are no network requests for 500ms. Surely that means the page is done loading?
Not for SPAs. The networkidle0 strategy detects when network activity stops, but SPA rendering involves multiple phases that don't map neatly to network traffic. Here is a typical timeline of what happens when a browser loads an SPA:
- HTML download (50-200ms): The browser fetches the minimal HTML shell.
- CSS and JS bundle download (200-1000ms): The browser downloads the application's JavaScript bundle and stylesheets. For large apps, this can be several megabytes.
- JavaScript parsing and execution (100-500ms): The browser parses the JS and the framework initializes — React creates its virtual DOM, Vue sets up reactivity, Angular bootstraps modules.
- Initial render (50-200ms): The framework renders the initial component tree into the DOM. This might be a loading state, not the final content.
- Data fetching (200-2000ms): Components mount and trigger API calls to fetch the data they need to display. This is the first real network activity after the JS bundle loaded.
- Data render (50-200ms): API responses arrive and the framework re-renders with actual content.
- Secondary effects (0-1000ms): Images load, fonts swap in, animations run, additional code-split chunks download for below-the-fold content.
The problem is that networkidle0 might fire after step 2 — the JS bundle has downloaded, there is a brief pause in network activity, and Puppeteer thinks the page is ready. But the framework has not even started rendering yet. Or it might fire between steps 4 and 5 — the initial render is done and the app is about to fetch data, but there is a gap of network silence.
SPAs make this worse in several ways:
Waterfall API calls: Many SPAs make sequential API requests. A parent component fetches data, renders its children, and those children fetch their own data. Each round of fetching is preceded by a pause in network activity that could trigger networkidle0.
Code splitting: Modern build tools like Webpack and Vite split the application into chunks that load on demand. Navigating to a route might trigger a dynamic import() that downloads additional JavaScript, parses it, renders new components, which then fetch their own data.
Real-time connections: SPAs often maintain WebSocket connections or use Server-Sent Events for real-time updates. These persistent connections mean networkidle0 may never fire at all, causing the navigation to time out.
The result is that networkidle0 is neither reliable nor sufficient for SPA screenshots. You need strategies that understand when the application's content is actually rendered in the DOM, not just when network activity pauses.
Framework-Specific Wait Strategies
Each JavaScript framework has its own lifecycle and conventions, which means there are framework-specific signals you can listen for to determine when rendering is complete. These strategies are more reliable than generic network-based waiting because they check the actual state of the rendered DOM.
React
React applications mount into a root element — typically <div id="root"> — and populate it with content as components render. The most basic wait strategy is to check that this root element has children:
const puppeteer = require('puppeteer'); async function screenshotReactApp(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for the root element to have content await page.waitForSelector('#root > *', { timeout: 30000 }); await page.screenshot({ path: 'react-app.png' }); await browser.close(); }
That handles the most basic case — waiting until React has rendered something. But "something" might be a loading spinner. For apps that use React Suspense or show loading states while data fetches, you need to wait for the actual content:
async function screenshotReactWithData(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for a specific content element that only appears after data loads await page.waitForSelector('[data-testid="content-loaded"]', { timeout: 30000, visible: true }); await page.screenshot({ path: 'react-with-data.png' }); await browser.close(); }
If you control the React application, you can add data-testid attributes specifically for screenshot automation. This is the most reliable approach because you are explicitly marking when the app considers itself ready.
For apps that use React Suspense boundaries, you can wait for the suspense fallback to disappear:
async function waitForSuspense(page) { // Wait for any suspense fallback to disappear // Assumes fallbacks have a recognizable class or attribute await page.waitForFunction(() => { const fallbacks = document.querySelectorAll('[data-testid="loading-fallback"]'); return fallbacks.length === 0; }, { timeout: 30000 }); }
For server-side rendered React apps using Next.js or Remix, hydration is another concern. The server sends pre-rendered HTML, but the page is not fully interactive until React hydrates — attaching event listeners and reconciling the virtual DOM with the server-rendered markup. During hydration, content can briefly flash or rearrange. You can detect when hydration completes by checking for React's internal markers:
async function waitForReactHydration(page) { await page.waitForFunction(() => { const root = document.getElementById('root') || document.getElementById('__next'); if (!root) return false; // React 18+ sets _reactRootContainer or __reactFiber on hydrated elements const hasReactFiber = Object.keys(root).some( key => key.startsWith('__reactFiber') || key.startsWith('__reactContainer') ); return hasReactFiber; }, { timeout: 30000 }); }
Vue
Vue applications follow a similar pattern to React, mounting into a root element — conventionally <div id="app">. The basic strategy is to wait for that element to have content:
async function screenshotVueApp(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for the Vue app root to have content await page.waitForSelector('#app > *', { timeout: 30000 }); await page.screenshot({ path: 'vue-app.png' }); await browser.close(); }
For more precise detection, you can check for Vue's internal properties on the root element. Vue 3 attaches a __vue_app__ property to the mount element after initialization:
async function waitForVueMount(page) { await page.waitForFunction(() => { const app = document.getElementById('app'); if (!app) return false; // Vue 3 attaches __vue_app__ to the mount element if (app.__vue_app__) return true; // Vue 2 attaches __vue__ to child components const children = app.querySelectorAll('*'); for (const child of children) { if (child.__vue__) return true; } return false; }, { timeout: 30000 }); }
Vue's reactivity system processes changes asynchronously using a microtask queue — the equivalent of Vue.nextTick. After data changes, the DOM does not update synchronously. You can wait for Vue to flush its update queue by checking if the DOM matches expected content:
async function waitForVueData(page, selector) { // Wait for a specific element that only renders after data loads await page.waitForSelector(selector, { visible: true, timeout: 30000 }); // Wait one more tick for Vue to finish any pending reactive updates await page.waitForFunction(() => { return new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve(true); }); }); }); }); }
For Nuxt.js applications (the Vue meta-framework), the hydration concern is the same as with Next.js. The server sends pre-rendered HTML, and Vue hydrates it client-side. The __vue_app__ check handles this case as well — it is only set after hydration completes.
Angular
Angular applications bootstrap into a root component — typically <app-root>. The raw HTML contains an empty <app-root> tag, which Angular replaces with the actual component template after initialization.
The most straightforward detection is to check for the ng-version attribute, which Angular adds to the root element after bootstrapping:
async function screenshotAngularApp(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for Angular to bootstrap — it sets ng-version on the root element await page.waitForSelector('app-root[ng-version]', { timeout: 30000 }); await page.screenshot({ path: 'angular-app.png' }); await browser.close(); }
For deeper stability detection, Angular uses Zone.js to track asynchronous operations. You can query Angular's testability API to determine when all pending async work (HTTP requests, timers, animations) has completed:
async function waitForAngularStability(page) { await page.waitForFunction(() => { // Check if Angular's testability API is available if (typeof window.getAllAngularTestabilities !== 'function') { return false; } const testabilities = window.getAllAngularTestabilities(); if (!testabilities || testabilities.length === 0) { return false; } // Check if all testabilities report stable return testabilities.every(testability => { return testability.isStable(); }); }, { timeout: 30000 }); }
The isStable() method returns true when Angular's Zone.js has no pending macrotasks (setTimeout, HTTP requests) or microtasks (Promise resolutions, change detection). This is the same mechanism that Protractor (Angular's original E2E testing tool) used to synchronize with the application.
For a more robust approach that waits for Angular to stabilize and also checks for specific content:
async function screenshotAngularWithData(url, contentSelector) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for Angular bootstrap await page.waitForSelector('app-root[ng-version]', { timeout: 30000 }); // Wait for Zone.js stability await page.waitForFunction(() => { if (typeof window.getAllAngularTestabilities !== 'function') return false; const testabilities = window.getAllAngularTestabilities(); return testabilities.length > 0 && testabilities.every(t => t.isStable()); }, { timeout: 30000 }); // If a specific content selector is provided, wait for it too if (contentSelector) { await page.waitForSelector(contentSelector, { visible: true, timeout: 15000 }); } await page.screenshot({ path: 'angular-app.png' }); await browser.close(); }
Note that modern Angular applications using standalone components and the new provideZoneChangeDetection may behave slightly differently, but the getAllAngularTestabilities API remains the most reliable way to detect Angular readiness from outside the application.
Framework-Agnostic Readiness Patterns
The framework-specific strategies above are useful when you know what you are screenshotting. But what about a general-purpose screenshot tool that needs to handle any SPA, regardless of framework? You need detection methods that work without knowing the application's internals.
The MutationObserver Approach
The idea is simple: watch the DOM for changes, and consider the page "ready" when the DOM stops changing for a specified duration. If no elements are being added, removed, or modified for, say, 1000 milliseconds, the page has probably finished rendering.
Here is a complete implementation:
async function waitForDomStable(page, { timeout = 30000, idleTime = 1000 } = {}) { await page.waitForFunction( (idleTime) => { return new Promise((resolve, reject) => { let timer = null; let settled = false; const observer = new MutationObserver(() => { // DOM changed — reset the idle timer if (timer) clearTimeout(timer); timer = setTimeout(() => { settled = true; observer.disconnect(); resolve(true); }, idleTime); }); // Observe the entire document for any DOM changes observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); // Start the initial timer in case the DOM is already stable timer = setTimeout(() => { if (!settled) { observer.disconnect(); resolve(true); } }, idleTime); }); }, { timeout }, idleTime ); }
Use it in your screenshot flow:
async function screenshotAnySPA(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for the DOM to stop changing for 1 second await waitForDomStable(page, { idleTime: 1000, timeout: 30000 }); await page.screenshot({ path: 'spa-screenshot.png' }); await browser.close(); }
This approach works remarkably well for most SPAs. The main downside is that it can give false positives. If there is a brief pause between the framework rendering the initial shell and the data fetch completing, the observer might settle prematurely. You can mitigate this by increasing idleTime, though setting it too high slows down every capture even for fast-loading pages.
It can also give false negatives on pages with continuous DOM updates — live tickers, real-time dashboards, animated content. For those pages, the DOM never stops changing, and the function will time out. You need a more targeted strategy for pages with perpetual updates.
The Window Flag Pattern
If you control the SPA's source code, the most reliable approach is to have the application explicitly signal when it is ready for capture. Set a flag on the window object after all critical content has loaded:
In your React/Vue/Angular app:
// In your top-level component, after data has loaded: useEffect(() => { // All critical data is loaded and rendered window.__READY__ = true; }, [data]);
In your Puppeteer script:
async function screenshotWithFlag(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for the app to signal readiness await page.waitForFunction('window.__READY__ === true', { timeout: 30000 }); await page.screenshot({ path: 'ready-screenshot.png' }); await browser.close(); }
This is the gold standard for accuracy because the application knows better than any external heuristic when its content is ready. The tradeoff is that it requires code changes in the application — not always possible when screenshotting third-party sites.
You can make this more sophisticated by having the app signal different stages of readiness:
// In your app window.__RENDER_STATUS__ = 'loading'; // After framework mounts window.__RENDER_STATUS__ = 'mounted'; // After critical data loads window.__RENDER_STATUS__ = 'data_loaded'; // After images and fonts finish window.__RENDER_STATUS__ = 'complete';
// In Puppeteer — wait for the specific stage you need await page.waitForFunction( '(window.__RENDER_STATUS__ === "data_loaded" || window.__RENDER_STATUS__ === "complete")', { timeout: 30000 } );
Network + DOM Combination Strategy
For the most robust generic approach, combine network activity monitoring with DOM stability detection. Wait for both conditions to be true simultaneously — no pending network requests AND no DOM changes for a specified period:
async function waitForNetworkAndDomIdle(page, { timeout = 30000, idleTime = 500 } = {}) { await page.waitForFunction( (idleTime) => { return new Promise((resolve) => { let domTimer = null; let domSettled = false; let networkSettled = false; // Track DOM changes const observer = new MutationObserver(() => { domSettled = false; if (domTimer) clearTimeout(domTimer); domTimer = setTimeout(() => { domSettled = true; checkBothSettled(); }, idleTime); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); // Start initial DOM timer domTimer = setTimeout(() => { domSettled = true; checkBothSettled(); }, idleTime); // Track pending fetch/XHR requests let pendingRequests = 0; const originalFetch = window.fetch; window.fetch = function (...args) { pendingRequests++; networkSettled = false; return originalFetch.apply(this, args).finally(() => { pendingRequests--; if (pendingRequests === 0) { networkSettled = true; checkBothSettled(); } }); }; const originalXhrOpen = XMLHttpRequest.prototype.open; const originalXhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (...args) { return originalXhrOpen.apply(this, args); }; XMLHttpRequest.prototype.send = function (...args) { pendingRequests++; networkSettled = false; this.addEventListener('loadend', () => { pendingRequests--; if (pendingRequests === 0) { networkSettled = true; checkBothSettled(); } }); return originalXhrSend.apply(this, args); }; // If no requests are pending initially, mark network as settled if (pendingRequests === 0) { networkSettled = true; } function checkBothSettled() { if (domSettled && networkSettled) { observer.disconnect(); // Restore original fetch/XHR window.fetch = originalFetch; XMLHttpRequest.prototype.open = originalXhrOpen; XMLHttpRequest.prototype.send = originalXhrSend; resolve(true); } } }); }, { timeout }, idleTime ); }
This is more aggressive than either approach alone. By intercepting fetch and XMLHttpRequest at the application level, it tracks the exact network requests the SPA makes — not all network activity (which might include analytics, third-party scripts, and other noise). Combined with DOM stability, it gives high confidence that the application has finished its load-render cycle.
The downside is complexity. Monkey-patching fetch and XMLHttpRequest inside page.evaluate can have unintended side effects on some pages, and this approach does not account for requests made through web workers or service workers. Use this as a starting point and adjust for your specific targets.
Client-Side Routing Challenges
SPAs use client-side routing to navigate between "pages" without full page loads. This creates specific challenges for screenshot automation because the URL changes but no new HTML document is fetched from the server.
Hash Routing
Older SPAs and some simpler setups use hash-based routing, where the route is embedded in the URL fragment: https://app.example.com/#/dashboard. This works straightforwardly with Puppeteer because the hash is part of the URL that the browser handles natively:
async function screenshotHashRoute(baseUrl, route) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const url = `${baseUrl}/#${route}`; await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for content to render await waitForDomStable(page, { idleTime: 1000 }); await page.screenshot({ path: 'hash-route.png' }); await browser.close(); } // Usage screenshotHashRoute('https://app.example.com', '/dashboard');
Hash routes work because the browser sends the request for the base URL and the JavaScript router reads the hash fragment to determine which view to render. No server configuration is needed — the server always serves the same index.html.
History API Routing
Modern SPAs use the History API for clean URLs: https://app.example.com/dashboard. These look like normal server routes, but they are handled entirely by the client-side router. The challenge is that the server must be configured to serve the SPA's index.html for all routes — otherwise requesting /dashboard directly returns a 404.
From Puppeteer's perspective, navigating to a History API route works exactly like any other URL, as long as the server is configured correctly:
async function screenshotHistoryRoute(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for the router to resolve and content to render await waitForDomStable(page, { idleTime: 1000 }); await page.screenshot({ path: 'history-route.png' }); await browser.close(); }
If you get a blank page or a 404 error when navigating to a specific route, the problem is almost always server-side: the server is not configured to serve index.html for all routes. This is not a Puppeteer problem — the same thing happens if you paste the URL directly into a browser.
For apps where you need to navigate between routes after the initial load — for example, to screenshot the app's internal navigation flow — use Puppeteer to click navigation links or trigger client-side navigation directly:
async function navigateClientSide(page, path) { // Use the app's router to navigate without a full page load await page.evaluate((path) => { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')); }, path); // Wait for the new route's content to render await waitForDomStable(page, { idleTime: 1000 }); }
Note that the pushState + popstate approach works for React Router, Vue Router, and most other client-side routers, but some routers might need a different event or direct API call. If you have access to the router instance, calling its navigation method directly is more reliable.
Capturing Multiple Routes
A common use case is screenshotting every page of an SPA — for visual regression testing, documentation, or generating social card images. Here is a pattern for iterating through routes efficiently by reusing the browser instance:
async function screenshotAllRoutes(baseUrl, routes) { const browser = await puppeteer.launch(); const results = []; for (const route of routes) { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); try { const url = `${baseUrl}${route.path}`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitForDomStable(page, { idleTime: 1000, timeout: 20000 }); const filename = route.path.replace(/\//g, '-').replace(/^-/, '') || 'home'; await page.screenshot({ path: `screenshots/${filename}.png` }); results.push({ route: route.path, status: 'success', file: `${filename}.png` }); } catch (error) { results.push({ route: route.path, status: 'failed', error: error.message }); } finally { await page.close(); } } await browser.close(); return results; } // Usage const routes = [ { path: '/' }, { path: '/about' }, { path: '/dashboard' }, { path: '/settings' }, { path: '/pricing' } ]; screenshotAllRoutes('https://app.example.com', routes) .then(results => console.log(results));
Each route gets its own page instance, which isolates state between captures. Using a new page per route avoids issues where leftover JavaScript state from one route affects the next. The finally block ensures pages are always closed, even if a capture fails.
For large numbers of routes, you can add concurrency with a simple semaphore to capture multiple routes in parallel without overwhelming the browser:
async function screenshotRoutesParallel(baseUrl, routes, { concurrency = 3 } = {}) { const browser = await puppeteer.launch(); const results = []; const queue = [...routes]; const workers = Array.from({ length: concurrency }, async () => { while (queue.length > 0) { const route = queue.shift(); if (!route) break; const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); try { const url = `${baseUrl}${route.path}`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitForDomStable(page, { idleTime: 1000, timeout: 20000 }); const filename = route.path.replace(/\//g, '-').replace(/^-/, '') || 'home'; await page.screenshot({ path: `screenshots/${filename}.png` }); results.push({ route: route.path, status: 'success' }); } catch (error) { results.push({ route: route.path, status: 'failed', error: error.message }); } finally { await page.close(); } } }); await Promise.all(workers); await browser.close(); return results; }
Client-Side Data Fetching
SPAs almost always fetch data from APIs after the initial render. A dashboard fetches metrics, a profile page fetches user data, a product page fetches inventory — the pattern is universal. Capturing a screenshot before these API calls resolve gives you loading states instead of content.
Puppeteer provides events for monitoring network activity at the protocol level. You can intercept requests and responses to build your own "wait for all fetches" logic:
async function waitForPendingRequests(page, { timeout = 30000, idleTime = 500 } = {}) { const pendingRequests = new Set(); let idleTimer = null; return new Promise((resolve, reject) => { const timeoutTimer = setTimeout(() => { cleanup(); reject(new Error( `Timed out waiting for ${pendingRequests.size} pending requests: ` + [...pendingRequests].map(r => r.url()).join(', ') )); }, timeout); function checkIdle() { if (pendingRequests.size === 0) { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { cleanup(); resolve(); }, idleTime); } } function onRequest(request) { const url = request.url(); // Only track API calls, not static assets if (isApiRequest(url)) { pendingRequests.add(request); if (idleTimer) clearTimeout(idleTimer); } } function onResponse(response) { pendingRequests.delete(response.request()); checkIdle(); } function onRequestFailed(request) { pendingRequests.delete(request); checkIdle(); } function cleanup() { clearTimeout(timeoutTimer); if (idleTimer) clearTimeout(idleTimer); page.off('request', onRequest); page.off('response', onResponse); page.off('requestfailed', onRequestFailed); } page.on('request', onRequest); page.on('response', onResponse); page.on('requestfailed', onRequestFailed); // Start idle check immediately in case no API calls are made checkIdle(); }); } function isApiRequest(url) { // Adjust these patterns to match your API endpoints const apiPatterns = [ '/api/', '/graphql', 'googleapis.com', 'supabase.co' ]; const staticPatterns = [ '.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.ico', 'analytics', 'tracking' ]; const isApi = apiPatterns.some(pattern => url.includes(pattern)); const isStatic = staticPatterns.some(pattern => url.includes(pattern)); return isApi || (!isStatic && url.startsWith('http')); }
Use it by setting up the listener before navigation and then waiting for it after:
async function screenshotWithDataWait(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); // Start monitoring BEFORE navigation const requestsIdle = waitForPendingRequests(page, { timeout: 30000, idleTime: 500 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for all API requests to complete await requestsIdle; // Give the framework a moment to re-render with the fetched data await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))); await page.screenshot({ path: 'data-loaded.png' }); await browser.close(); }
The key detail is that request monitoring must start before page.goto() — otherwise you miss requests that fire during the initial page load. The requestAnimationFrame at the end gives the framework one more render cycle to update the DOM with the fetched data before the screenshot is taken.
The isApiRequest function is a heuristic that you should adjust for your target sites. The default implementation tries to distinguish API calls from static assets by URL patterns. For your own applications, you can be more precise — filter to only your API domain, or match specific URL prefixes.
Common Failures and Solutions
Even with the wait strategies above, SPA screenshots can fail in frustrating ways. Here are the most common failure modes with diagnostic approaches and fixes.
Blank Page
The screenshot is completely blank or shows only the background color. This typically means JavaScript failed to execute at all.
Diagnosis:
async function diagnoseBlankPage(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); // Collect console messages and errors const consoleLogs = []; const errors = []; page.on('console', msg => consoleLogs.push({ type: msg.type(), text: msg.text() })); page.on('pageerror', error => errors.push(error.message)); page.on('response', response => { if (response.status() >= 400) { console.log(`HTTP ${response.status()}: ${response.url()}`); } }); await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); // Check if root has content const rootContent = await page.evaluate(() => { const root = document.getElementById('root') || document.getElementById('app') || document.querySelector('app-root'); return root ? root.innerHTML.trim() : 'NO ROOT ELEMENT FOUND'; }); console.log('Root content:', rootContent.substring(0, 200)); console.log('Console errors:', errors); console.log('Console logs:', consoleLogs.filter(l => l.type === 'error')); await browser.close(); }
Common causes and fixes:
Content Security Policy (CSP) headers can block inline scripts or eval, which some bundlers require. If you see CSP errors in the console, you can bypass them for screenshots:
await page.setBypassCSP(true); await page.goto(url, { waitUntil: 'domcontentloaded' });
The wrong waitUntil value can cause page.goto() to resolve before JavaScript has a chance to run. If you are using waitUntil: 'load' (the default), try switching to networkidle2:
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
JavaScript errors in the application itself — a missing environment variable, a failed import, an uncaught exception during initialization — will prevent the SPA from rendering. The diagnostic script above captures these via the pageerror event.
Stuck Loading Spinner
The screenshot shows a loading indicator instead of content. The framework rendered, but data fetching failed or timed out.
Diagnosis:
async function diagnoseLoadingSpinner(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const failedRequests = []; page.on('requestfailed', request => { failedRequests.push({ url: request.url(), reason: request.failure().errorText }); }); page.on('response', response => { if (response.status() >= 400) { failedRequests.push({ url: response.url(), status: response.status() }); } }); await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); // Wait a bit more to see if any late requests fail await new Promise(resolve => setTimeout(resolve, 5000)); console.log('Failed requests:', failedRequests); // Check for visible loading indicators const hasSpinner = await page.evaluate(() => { const spinnerSelectors = [ '.loading', '.spinner', '[class*="loading"]', '[class*="spinner"]', '.skeleton', '[class*="skeleton"]', '[role="progressbar"]' ]; return spinnerSelectors.some(sel => { const el = document.querySelector(sel); return el && el.offsetParent !== null; // visible check }); }); console.log('Has visible spinner:', hasSpinner); await browser.close(); }
Fix: If API calls are failing because the server rejects requests from headless Chrome (missing cookies, authentication, bot detection), you may need to set headers or cookies before navigation:
async function screenshotWithAuth(url, cookies, headers) { const browser = await puppeteer.launch(); const page = await browser.newPage(); // Set extra headers for API calls if (headers) { await page.setExtraHTTPHeaders(headers); } // Set cookies for authentication if (cookies) { await page.setCookie(...cookies); } await page.goto(url, { waitUntil: 'domcontentloaded' }); await waitForDomStable(page, { idleTime: 1500 }); await page.screenshot({ path: 'authed-screenshot.png' }); await browser.close(); }
Partial Render
Some sections of the page rendered correctly but others show loading states or are completely missing. This usually happens with code-split SPAs where different sections of the page load independently.
Fix: Use a progressive wait strategy that checks for multiple content areas:
async function waitForAllSections(page, selectors, { timeout = 30000 } = {}) { const startTime = Date.now(); for (const selector of selectors) { const remaining = timeout - (Date.now() - startTime); if (remaining <= 0) { throw new Error(`Timed out waiting for sections. Missing: ${selector}`); } try { await page.waitForSelector(selector, { visible: true, timeout: remaining }); } catch (error) { console.warn(`Section ${selector} did not appear within timeout`); // Continue rather than failing — capture what we can } } } // Usage await page.goto(url, { waitUntil: 'domcontentloaded' }); await waitForAllSections(page, [ '.header-content', '.main-dashboard', '.sidebar-nav', '.footer-links' ], { timeout: 30000 }); await page.screenshot({ path: 'complete-page.png' });
Hydration Mismatch Flash
For server-rendered SPAs (Next.js, Nuxt, Angular Universal), the screenshot captures the server-rendered HTML, but during hydration the client-side JavaScript replaces it with different content, causing a flash. If the screenshot fires during this transition, you get a broken or partially rendered state.
Fix: Wait for hydration to complete by checking for framework-specific hydration markers, then wait an additional frame for the DOM to stabilize:
async function waitForHydration(page) { // Wait for the framework to hydrate await page.waitForFunction(() => { // React hydration check const root = document.getElementById('root') || document.getElementById('__next'); if (root) { const hasReactFiber = Object.keys(root).some( key => key.startsWith('__reactFiber') || key.startsWith('__reactContainer') ); if (hasReactFiber) return true; } // Vue hydration check const vueRoot = document.getElementById('app') || document.getElementById('__nuxt'); if (vueRoot && vueRoot.__vue_app__) return true; // Angular hydration check const ngRoot = document.querySelector('[ng-version]'); if (ngRoot) return true; return false; }, { timeout: 30000 }); // Wait two animation frames for the DOM to settle after hydration await page.evaluate(() => new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve(); }); }); })); }
This function checks for React, Vue, and Angular hydration markers in sequence. Once any one of them is detected, it waits two animation frames to let the DOM settle before returning. This is useful for general-purpose tools that do not know in advance which framework the target page uses.
A Complete SPA Screenshot Function
Here is a production-ready function that combines the strategies above into a single, robust capture pipeline. It handles framework detection, DOM stability, network idle, client-side routing, error recovery, and cleanup.
const puppeteer = require('puppeteer'); async function screenshotSPA(url, options = {}) { const { width = 1280, height = 800, deviceScaleFactor = 1, format = 'png', quality = undefined, timeout = 30000, idleTime = 1000, waitForSelector = null, waitForFlag = null, bypassCSP = false, cookies = null, headers = null, outputPath = null } = options; const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); try { // Configure viewport await page.setViewport({ width, height, deviceScaleFactor }); // Set bypass CSP if needed if (bypassCSP) { await page.setBypassCSP(true); } // Set cookies before navigation if (cookies) { await page.setCookie(...cookies); } // Set custom headers if (headers) { await page.setExtraHTTPHeaders(headers); } // Collect errors for diagnostics const pageErrors = []; page.on('pageerror', error => pageErrors.push(error.message)); // Navigate to the URL await page.goto(url, { waitUntil: 'domcontentloaded', timeout }); // Strategy 1: If a specific flag is expected, wait for it if (waitForFlag) { await page.waitForFunction( `window.${waitForFlag} === true`, { timeout } ); } // Strategy 2: If a specific selector is expected, wait for it else if (waitForSelector) { await page.waitForSelector(waitForSelector, { visible: true, timeout }); } // Strategy 3: Auto-detect framework and wait for readiness else { await autoDetectAndWait(page, { timeout, idleTime }); } // Final wait: let fonts render and animations settle await page.evaluate(() => new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve(); }); }); })); // Capture the screenshot const screenshotOptions = { type: format, ...(outputPath && { path: outputPath }), ...(quality !== undefined && format !== 'png' && { quality }) }; const buffer = await page.screenshot(screenshotOptions); // Report any JavaScript errors that occurred if (pageErrors.length > 0) { console.warn(`Page had ${pageErrors.length} JavaScript errors:`, pageErrors); } return buffer; } finally { await page.close(); await browser.close(); } } async function autoDetectAndWait(page, { timeout = 30000, idleTime = 1000 } = {}) { // Detect which framework is in use and apply the appropriate wait strategy const framework = await page.evaluate(() => { // Check for React const reactRoot = document.getElementById('root') || document.getElementById('__next'); if (reactRoot) { const hasReact = Object.keys(reactRoot).some( key => key.startsWith('__reactFiber') || key.startsWith('__reactContainer') ); if (hasReact) return 'react'; // React might still be loading — check for the empty root pattern if (reactRoot.id === 'root' || reactRoot.id === '__next') return 'react-pending'; } // Check for Vue const vueRoot = document.getElementById('app') || document.getElementById('__nuxt'); if (vueRoot && vueRoot.__vue_app__) return 'vue'; if (vueRoot && (vueRoot.id === 'app' || vueRoot.id === '__nuxt')) return 'vue-pending'; // Check for Angular if (document.querySelector('[ng-version]')) return 'angular'; if (document.querySelector('app-root')) return 'angular-pending'; return 'unknown'; }); switch (framework) { case 'react': case 'react-pending': // Wait for React root to have meaningful content await page.waitForSelector('#root > *, #__next > *', { timeout }); break; case 'vue': case 'vue-pending': // Wait for Vue app root to have content await page.waitForSelector('#app > *, #__nuxt > *', { timeout }); break; case 'angular': case 'angular-pending': // Wait for Angular to bootstrap await page.waitForSelector('[ng-version]', { timeout }); // Wait for Zone.js stability if available try { await page.waitForFunction(() => { if (typeof window.getAllAngularTestabilities !== 'function') return true; const testabilities = window.getAllAngularTestabilities(); return testabilities.length > 0 && testabilities.every(t => t.isStable()); }, { timeout: Math.min(timeout, 10000) }); } catch { // Zone.js stability check timed out — continue with DOM stability } break; default: // Unknown framework — wait for body to have substantial content await page.waitForFunction(() => { return document.body && document.body.children.length > 0 && document.body.innerText.trim().length > 0; }, { timeout }); break; } // Regardless of framework, wait for DOM stability await waitForDomStableInternal(page, { timeout: Math.min(timeout, 15000), idleTime }); } async function waitForDomStableInternal(page, { timeout = 15000, idleTime = 1000 } = {}) { try { await page.waitForFunction( (idleTime) => { return new Promise((resolve) => { let timer = null; const observer = new MutationObserver(() => { if (timer) clearTimeout(timer); timer = setTimeout(() => { observer.disconnect(); resolve(true); }, idleTime); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); // Initial timer in case DOM is already stable timer = setTimeout(() => { observer.disconnect(); resolve(true); }, idleTime); }); }, { timeout }, idleTime ); } catch { // DOM stability check timed out — page might have continuous updates // Continue anyway rather than failing console.warn('DOM stability check timed out — page may have continuous updates'); } }
Usage examples:
// Basic usage — auto-detection const buffer = await screenshotSPA('https://react-app.example.com', { outputPath: 'screenshot.png' }); // With a specific ready flag (your own app) const buffer2 = await screenshotSPA('https://your-app.com/dashboard', { waitForFlag: '__READY__', width: 1440, height: 900 }); // With a content selector const buffer3 = await screenshotSPA('https://vue-app.example.com', { waitForSelector: '[data-testid="dashboard-loaded"]', format: 'jpeg', quality: 85, outputPath: 'dashboard.jpg' }); // With authentication const buffer4 = await screenshotSPA('https://app.example.com/settings', { cookies: [ { name: 'session', value: 'abc123', domain: 'app.example.com' } ], headers: { 'X-Custom-Header': 'value' }, bypassCSP: true });
This function is intentionally structured as a single pipeline with clear fallback behavior. The auto-detection tries framework-specific strategies first, then falls back to generic DOM stability checking. Every wait step has a timeout so the function always completes — even if the page is permanently broken.
Production Reliability Patterns
The complete function above handles a single screenshot reliably. In production, you also need retry logic, concurrency management, and observability. Here are the key patterns to add on top.
Retry with Exponential Backoff
Some failures are transient — a network timeout, a temporary server error, a Chrome crash. Retrying with increasing delays handles these without manual intervention:
async function screenshotWithRetry(url, options = {}, { maxRetries = 3 } = {}) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await screenshotSPA(url, options); } catch (error) { lastError = error; console.error(`Attempt ${attempt}/${maxRetries} failed for ${url}: ${error.message}`); if (attempt < maxRetries) { const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw new Error( `All ${maxRetries} attempts failed for ${url}. Last error: ${lastError.message}` ); }
Health Checking
Before processing a batch of screenshots, verify that the browser can launch and navigate successfully:
async function healthCheck() { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); try { const page = await browser.newPage(); await page.goto('about:blank', { timeout: 5000 }); await page.close(); return { status: 'healthy' }; } catch (error) { return { status: 'unhealthy', error: error.message }; } finally { await browser.close(); } }
Structured Logging
For debugging production failures, log the full context of each capture attempt:
function logCapture(url, options, result, duration) { const logEntry = { timestamp: new Date().toISOString(), url, options: { width: options.width, height: options.height, format: options.format }, result: result.success ? 'success' : 'failure', duration_ms: duration, ...(result.error && { error: result.error }), ...(result.pageErrors && result.pageErrors.length > 0 && { pageErrors: result.pageErrors }) }; console.log(JSON.stringify(logEntry)); }
These patterns give you a basic production safety net. For a deeper treatment of timeout tuning, memory leak detection, and Chrome process management, see our debugging guide for Puppeteer timeouts and memory leaks.
When to Use an API Instead
SPAs are the hardest category of web pages to screenshot reliably. Every SPA has its own combination of framework, routing strategy, data fetching pattern, and rendering lifecycle. The wait strategies in this guide work well for applications you control or ones you have tested against, but building a general-purpose SPA screenshot system that handles arbitrary sites is a significant engineering investment.
The timing problem alone requires choosing between framework-specific detection (which limits you to known frameworks), DOM mutation observation (which can give false positives), and application-level flags (which require source code changes). Then multiply that by client-side routing, code splitting, authentication, CSP policies, and server-side rendering with hydration. Each layer adds failure modes.
RenderScreenshot handles all of this automatically. The rendering infrastructure uses smart wait strategies that detect framework initialization, monitor DOM stability, and track network activity to determine when an SPA is ready for capture — without requiring any changes to the target application.
A single API call replaces the entire pipeline we built in this guide:
curl "https://api.renderscreenshot.com/v1/screenshot?url=https://react-app.example.com/dashboard&width=1280&height=800" \ -H "Authorization: Bearer rs_live_..."
The service handles framework detection, data loading, hydration, CSP bypass, font rendering, and all the edge cases covered in this post. Screenshots are captured on edge infrastructure and cached on a global CDN. You can use wait strategy parameters to fine-tune behavior for specific targets, but the defaults work for the vast majority of SPAs out of the box.
Sign up free to get 50 credits — enough to test against your specific SPA and see the results before committing to any approach.
Related Guides
- Taking Screenshots with Puppeteer — Start here for Puppeteer basics
- Puppeteer Full Page Screenshot — Auto-scroll techniques for lazy-loaded SPA content
- Puppeteer Timeouts and Memory Leaks — Debugging guide for production issues
- Best Screenshot API Comparison — When to use an API instead
Have questions about capturing SPAs? Check our documentation or reach out at [email protected].