Single Page Application (SPA): Rendering and Capture

A single page application builds its UI with JavaScript after the page loads. See why load-based captures come back empty and the wait that works.

By Simon Chiu · Updated

A single page application (SPA) is a web app that loads one HTML shell and then builds every view in the browser with JavaScript, fetching data and changing the URL without a full page reload.

For a person, an SPA feels fast: clicks swap views instantly. For a headless browser, it is the most common reason for an empty screenshot. The browser's load event fires when the shell is ready, not when the content is. This entry is part of the screenshot and rendering glossary.

A plain explanation

A classic server-rendered page arrives as finished HTML. The text you see is in the first response. An SPA sends something close to <div id="app"></div> plus a script bundle. The bundle starts, asks an API for data, waits for the answer and then writes the view into the page. React, Vue, Angular and Svelte apps without server rendering all work this way.

Navigation inside the app does not load a new document either. The router changes the URL with the History API (/orders) or a fragment (/#/orders) and redraws part of the page.

What a capture sees, measured

We built a minimal SPA: a shell that fetches /api/orders, where the server answers after 1,200 ms, then renders three order cards. We loaded /orders with each wait condition and counted the cards present when the wait returned:

Two captures of the same orders page. Left, taken when load fired after 7 ms, shows only the text Loading. Right, taken after waiting for the first order card, shows the Orders heading and three order cards.
The test SPA captured with Puppeteer at waitUntil: 'load' and after page.waitForSelector('.card[data-ready]'), 2026-10-15.
Wait condition Returned after Cards rendered
Puppeteer domcontentloaded 21 ms 0 of 3
Puppeteer load 7 ms 0 of 3
Puppeteer networkidle2 1,006 ms 0 of 3
Puppeteer networkidle0 2,010 ms 3 of 3
Puppeteer load + waitForSelector 1,227 ms 3 of 3
Playwright networkidle 1,714 ms 3 of 3
Playwright locator.waitFor() 1,319 ms 3 of 3

Two results matter. networkidle2 returned while the data request was still open, because it tolerates two connections, so it captured the spinner. And the selector wait was the fastest correct option: it returned about 30 ms after the data arrived, while networkidle0 added its 500 ms quiet window on top. Times are from one run on a local server, so read them as relative, not absolute.

How it works technically

The browser fires DOMContentLoaded when the shell HTML is parsed and load when its scripts, styles and images have arrived. Neither event knows about requests the app makes afterwards with fetch. Network-idle waits are a guess made by the automation library: no connections for 500 ms. A selector wait checks for the content itself, which is why it is the only condition that knows the page is done.

Client-side routing adds a second trap. A pushState navigation does not fire load again, so code that clicks a link and then waits for a load event waits forever. Wait for an element of the new view instead.

How to wait for it in Puppeteer, Playwright and RenderScreenshot

Puppeteer. Navigate, then wait for an element that only the rendered view contains:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.goto('https://app.example.com/orders', { waitUntil: 'load' });
  await page.waitForSelector('.card[data-ready]', { visible: true, timeout: 15_000 });
  await page.screenshot({ path: 'orders.png' });
} finally {
  await browser.close();
}

Playwright. Locators wait on their own; waitFor() makes the intent explicit:

import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto('https://app.example.com/orders');
  await page.locator('.card').first().waitFor({ state: 'visible', timeout: 15_000 });
  await page.screenshot({ path: 'orders.png' });
} finally {
  await browser.close();
}

RenderScreenshot. Put the selector in wait.for_selector. The request fails with an error instead of returning a spinner if the element never appears within wait.timeout seconds. See the wait parameters.

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/orders",
    "wait": { "for_selector": ".card[data-ready]", "timeout": 20 }
  }' \
  --output orders.png
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const image = await client.take(
  TakeOptions.url('https://app.example.com/orders').waitForSelector('.card[data-ready]'),
);

The measurement and the Puppeteer and Playwright patterns were tested on 2026-10-15 with Node 22.18, Puppeteer 24.43.1 (headless shell mode, Chrome 148) and Playwright 1.64.1 (Chromium 156) against the local test SPA. app.example.com is a placeholder. The RenderScreenshot snippets follow the API docs and were not run for this page.

Common mistakes

  • A fixed delay. delay: 3000 works until the API is slow once. It also costs three seconds on every fast capture.
  • Waiting for the container. #app exists in the shell, so it is ready at once. Pick an element the data creates, such as a list item, or a data-ready attribute your app sets.
  • Porting networkidle2. As measured above, it can return before the data. Playwright and RenderScreenshot have no equivalent; their networkidle means zero connections.
  • Deep links that 404. With history routing, a direct request for /orders needs a server fallback to the shell. Test the URL in a fresh browser before you blame the capture.
  • Wait strategies: every ready condition, compared across tools.
  • Hydration: server-rendered HTML that the SPA bundle then takes over.
  • Lazy loading: images that only load when scrolled into view, a second cause of incomplete captures.

The SPA screenshot tutorial covers framework-specific readiness checks and routing in depth. To try a selector wait on your own app, get a free API key.

Frequently asked questions

Why is my screenshot of a React or Vue app blank?
The capture ran when the browser fired load, which only means the shell HTML, scripts and styles arrived. The app was still fetching its data. Wait for an element that only exists once the content has rendered.
Does networkidle fix SPA screenshots?
Sometimes. A quiet network for 500 ms usually means the data arrived, but Puppeteer's networkidle2 allows two open requests and can fire while the data request is still pending. Pages that poll or stream never go quiet at all.
Can I screenshot a route like /dashboard/settings directly?
Only if the server returns the app shell for that path. With history routing, the server needs a fallback that serves index.html for every app route; hash routes such as /#/settings always work because the fragment never reaches the server.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.