You fire off a screenshot request and get back... a blank white page. Or maybe a loading spinner frozen in time. If you've ever tried to capture a website screenshot of a modern JavaScript application, you know this pain.
The problem is straightforward: most websites in 2026 render their content client-side. React, Vue, Angular, Next.js, Svelte -- they all fetch data and build the DOM after the initial HTML loads. A naive screenshot tool that captures the page on DOMContentLoaded will miss everything.
This guide covers reliable strategies for capturing accurate screenshots of JavaScript-heavy websites, with practical code examples you can use today.
Traditional server-rendered pages send complete HTML to the browser. The content is right there in the response. But single-page applications (SPAs) work differently:
If you screenshot at step 1, you get nothing. At step 2, maybe a skeleton loader. You need to wait until step 5 before capturing -- but how do you know when "done" is?
The most reliable general-purpose approach is waiting for network activity to settle. Once all API calls, image loads, and asset fetches complete, the page is likely rendered.
With GrabShot's screenshot API, you can specify network idle as a wait condition:
curl "https://grabshot.dev/api/screenshot?url=https://app.example.com/dashboard&wait_until=networkidle0&api_key=YOUR_API_KEY" \
--output dashboard.png
The networkidle0 strategy waits until there are zero network connections for at least 500ms. For most SPAs, this is the sweet spot -- it catches the initial data fetch, subsequent API calls, and lazy-loaded images.
There's also networkidle2, which allows up to 2 ongoing connections. This is useful for pages with persistent WebSocket connections or long-polling that would cause networkidle0 to time out.
const fetch = require('node-fetch');
const fs = require('fs');
async function screenshotSPA(url) {
const params = new URLSearchParams({
url: url,
wait_until: 'networkidle0',
width: 1440,
height: 900,
api_key: process.env.GRABSHOT_API_KEY
});
const response = await fetch(
`https://grabshot.dev/api/screenshot?${params}`
);
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status}`);
}
const buffer = await response.buffer();
fs.writeFileSync('screenshot.png', buffer);
console.log('Screenshot saved');
}
screenshotSPA('https://app.example.com/dashboard');
import requests
import os
def screenshot_spa(url, output="screenshot.png"):
params = {
"url": url,
"wait_until": "networkidle0",
"width": 1440,
"height": 900,
"api_key": os.environ["GRABSHOT_API_KEY"]
}
resp = requests.get(
"https://grabshot.dev/api/screenshot",
params=params
)
resp.raise_for_status()
with open(output, "wb") as f:
f.write(resp.content)
print(f"Saved to {output}")
screenshot_spa("https://app.example.com/dashboard")
Sometimes network idle isn't enough. Maybe the page has animations that trigger after data loads, or a charting library that renders asynchronously. In these cases, you can tell the API to wait for a specific CSS selector to appear in the DOM.
# Wait for the main chart to render before capturing
curl "https://grabshot.dev/api/screenshot?url=https://analytics.example.com&wait_for=.chart-container svg&api_key=YOUR_API_KEY" \
--output analytics.png
This is particularly useful for:
The simplest approach, and sometimes the only one that works for complex animations or canvas-based rendering:
curl "https://grabshot.dev/api/screenshot?url=https://fancy-animation.example.com&delay=3000&api_key=YOUR_API_KEY" \
--output animation.png
The delay parameter (in milliseconds) adds a wait after the page load event fires. Use this as a last resort -- it's less efficient than network idle or selector-based waiting, but it handles edge cases like:
For the most reliable results with complex SPAs, combine multiple strategies:
import requests
import os
def screenshot_complex_spa(url):
"""
Capture a complex SPA with multiple loading stages:
1. Wait for network to settle
2. Wait for main content selector
3. Add a small delay for animations
"""
params = {
"url": url,
"wait_until": "networkidle2",
"wait_for": ".main-content",
"delay": 1000,
"width": 1440,
"height": 900,
"full_page": "true",
"api_key": os.environ["GRABSHOT_API_KEY"]
}
resp = requests.get(
"https://grabshot.dev/api/screenshot",
params=params
)
resp.raise_for_status()
return resp.content
# Capture a React dashboard with charts
png = screenshot_complex_spa("https://app.example.com/analytics")
with open("full-dashboard.png", "wb") as f:
f.write(png)
GrabShot handles JavaScript rendering out of the box. 25 free screenshots per month, no credit card required.
Try the API →| Framework | Recommended Strategy | Notes |
|---|---|---|
| React (CRA) | networkidle0 | Works well out of the box. Add wait_for=#root > div if you get blank pages. |
| Next.js (SSR) | load | Server-rendered, so basic load event often suffices. Use networkidle0 for pages with client-side data fetching. |
| Vue / Nuxt | networkidle0 | Similar to React. Nuxt SSR pages are easier to capture. |
| Angular | networkidle0 + delay | Angular's change detection can cause late renders. A 500ms delay after network idle helps. |
| Svelte / SvelteKit | networkidle0 | Lightweight, fast hydration. Usually no issues. |
| Streamlit / Dash | networkidle2 + delay | WebSocket-based, so networkidle0 may time out. Use networkidle2 with a 2-3s delay. |
Many JavaScript apps require login. You can pass cookies or headers to authenticate before capturing:
# Pass a session cookie to screenshot an authenticated dashboard
curl "https://grabshot.dev/api/screenshot?url=https://app.example.com/dashboard&cookies=session_id%3Dabc123%3Bdomain%3D.example.com&api_key=YOUR_API_KEY" \
--output authenticated-dashboard.png
For token-based auth (common in SPAs), you can inject localStorage or pass custom headers:
const params = new URLSearchParams({
url: 'https://app.example.com/dashboard',
wait_until: 'networkidle0',
// Inject a bearer token via custom headers
headers: JSON.stringify({
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
}),
api_key: process.env.GRABSHOT_API_KEY
});
const response = await fetch(
`https://grabshot.dev/api/screenshot?${params}`
);
If your SPA screenshots still look wrong, work through this list:
If you need to generate screenshots of your own SPA (for social sharing, PDF exports, or thumbnails), here's a practical Node.js service:
const express = require('express');
const fetch = require('node-fetch');
const app = express();
const GRABSHOT_KEY = process.env.GRABSHOT_API_KEY;
app.get('/preview/:pageId', async (req, res) => {
try {
const targetUrl = `https://yourapp.com/page/${req.params.pageId}`;
const params = new URLSearchParams({
url: targetUrl,
wait_until: 'networkidle0',
wait_for: '.page-content',
width: 1200,
height: 630, // OG image dimensions
api_key: GRABSHOT_KEY
});
const screenshot = await fetch(
`https://grabshot.dev/api/screenshot?${params}`
);
if (!screenshot.ok) {
return res.status(502).json({ error: 'Screenshot failed' });
}
// Cache for 1 hour
res.set('Cache-Control', 'public, max-age=3600');
res.set('Content-Type', 'image/png');
screenshot.body.pipe(res);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(3000);
This gives you dynamic OG images for every page in your SPA -- search engines and social platforms will see a proper preview instead of your app's loading spinner.
Capturing screenshots of JavaScript-heavy websites doesn't have to be painful. The key is understanding that these pages need time to render, and using the right wait strategy for your use case.
Start with networkidle0 -- it works for 80% of SPAs out of the box. If that's not enough, layer on element selectors and small delays. And if you're building screenshot functionality into your own app, GrabShot's API handles the rendering complexity so you don't have to manage headless browsers yourself.