How to Screenshot JavaScript SPAs and Dynamic Websites via API

February 23, 2026 · 9 min read

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.

Why JavaScript Websites Break Simple Screenshot Tools

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:

  1. The server sends a minimal HTML shell (often just a <div id="root"></div>)
  2. JavaScript bundles download and execute
  3. The app makes API calls to fetch data
  4. Components render based on the fetched data
  5. Images, fonts, and other assets load asynchronously

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?

Strategy 1: Network Idle Detection

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

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.

Node.js

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');

Python

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")

Strategy 2: Wait for a Specific Element

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:

Strategy 3: Add a Fixed Delay

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:

Strategy 4: Combine Approaches

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)

Try It Free

GrabShot handles JavaScript rendering out of the box. 25 free screenshots per month, no credit card required.

Try the API →

Common SPA Frameworks and What Works

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.

Handling Authentication in SPAs

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}`
);

Troubleshooting Checklist

If your SPA screenshots still look wrong, work through this list:

  1. Blank white page? Switch from load to networkidle0. The JS hasn't executed yet.
  2. Loading spinner frozen? The API calls may need authentication. Check if cookies or tokens are required.
  3. Missing images? Try networkidle0 instead of networkidle2 to ensure all images finish downloading.
  4. Timeout errors? The page might have persistent connections (WebSockets, SSE). Switch to networkidle2.
  5. Wrong viewport? SPAs often have responsive breakpoints. Set width explicitly to match the layout you want.
  6. Fonts not loaded? Add a 1-2s delay. Web fonts sometimes load after network idle triggers.
  7. Cookie consent popup? Use block_ads=true or inject a cookie to dismiss it.

Building a Screenshot Service for Your SPA

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.

Performance Tips

Wrapping Up

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.