← Back to DevBytes

Server-Side Rendering with Playwright: SSR, SSG, ISR

Server-Side Rendering with Playwright: SSR, SSG, ISR Explained

Server-side rendering has evolved dramatically over the past few years. While frameworks like Next.js, Nuxt, and SvelteKit have popularized patterns like SSR (Server-Side Rendering), SSG (Static Site Generation), and ISR (Incremental Static Regeneration), there is a growing interest in using headless browsers — specifically Playwright — to perform rendering on the server. This approach, sometimes called "prerendering" or "browser-based SSR," lets you render any client-side application into fully formed HTML without rewriting your frontend in a server-friendly framework.

In this tutorial,'ll we'll explore how to use Playwright to implement SSR, SSG, and ISR patterns from scratch. We'll cover what each pattern means, why you might choose Playwright over traditional SSR frameworks, how to build a small rendering service, and best practices for production deployments.

What Is Playwright-Based Rendering?

Playwright is a Node.js (and Python/Java/.NET) library for automating browsers. It launches a real Chromium, Firefox, or WebKit instance, navigates to URLs, and lets you inspect or manipulate the DOM. When used for server-side rendering, the workflow is straightforward: launch a browser, navigate to a route, wait for the page to be ready, then extract the serialized HTML and return it to the client.

This differs from traditional SSR, where your UI framework runs directly on the server (e.g., React's renderToString). With Playwright, you run the exact same JavaScript bundle the browser would run, but you capture the resulting DOM as HTML. This means any framework — React, Vue, Angular, Svelte, vanilla JS, or even legacy jQuery apps — can be server-rendered without changes.

Why Use Playwright for Rendering?

Trade-offs to Consider

Playwright-based rendering is not free. Launching and controlling a browser is heavier than calling renderToString. Memory usage, cold-start time, and concurrency limits all matter. For high-traffic sites, you'll want caching (SSG or ISR) rather than rendering every request on the fly. We'll address these concerns throughout the tutorial.

Setting Up the Project

Let's build a small rendering service using Node.js, Express, and Playwright. Start by initializing a project and installing dependencies.

mkdir playwright-renderer && cd playwright-renderer
npm init -y
npm install express playwright playwright-core
npm install --save-dev nodemon

After installing, run npx playwright install chromium to download the Chromium browser binary. We'll use Chromium exclusively because it's the fastest and most widely supported option for headless rendering.

Create a basic project structure:

playwright-renderer/
├── server.js
├── renderer.js
├── cache.js
├── package.json
└── public/
    └── app.html

For demonstration, public/app.html will be a simple SPA that fetches data and renders it client-side:

<!DOCTYPE html>
<html>
<head>
  <title>My SPA</title>
  <meta name="description" content="A demo SPA rendered with Playwright">
</head>
<body>
  <div id="app">Loading...</div>
  <script>
    async function render() {
      const res = await fetch('https://jsonplaceholder.typicode.com/posts/1');
      const post = await res.json();
      document.getElementById('app').innerHTML = `
        <h1>${post.title}</h1>
        <p>${post.body}</p>
      `;
      document.title = post.title;
    }
    render();
  </script>
</body>
</html>

Building the Core Renderer

The renderer is the heart of the system. It manages a browser instance, opens pages, navigates to URLs, waits for content, and returns HTML. Let's implement it in renderer.js.

const { chromium } = require('playwright');

let browser = null;

async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
  }
  return browser;
}

async function renderPage(url, options = {}) {
  const {
    waitForSelector = '#app',
    timeout = 10000,
    waitUntil = 'networkidle',
  } = options;

  const browser = await getBrowser();
  const context = await browser.newContext({
    userAgent: 'Playwright-Renderer/1.0',
  });
  const page = await context.newPage();

  try {
    await page.goto(url, { waitUntil, timeout });
    await page.waitForSelector(waitForSelector, { timeout });
    // Give async scripts a moment to settle
    await page.waitForTimeout(500);
    const html = await page.content();
    return html;
  } finally {
    await context.close();
  }
}

async function shutdown() {
  if (browser) {
    await browser.close();
    browser = null;
  }
}

module.exports = { renderPage, shutdown };

Key points in this implementation:

Implementing SSR (Server-Side Rendering on Demand)

SSR means the server renders HTML for each incoming request. This is the simplest pattern to implement with Playwright but also the most expensive because every request triggers a browser navigation. It's best suited for highly dynamic, personalized content where caching is not viable.

Create server.js with an SSR endpoint:

const express = require('express');
const path = require('path');
const { renderPage, shutdown } = require('./renderer');

const app = express();
const PORT = process.env.PORT || 3000;

// Serve static SPA files
app.use(express.static(path.join(__dirname, 'public')));

app.get('/ssr/*', async (req, res) => {
  const route = req.params[0] || '';
  const targetUrl = `http://localhost:${PORT}/app.html#/${route}`;

  try {
    const html = await renderPage(targetUrl, {
      waitForSelector: '#app',
      timeout: 10000,
    });
    res.set('Content-Type', 'text/html');
    res.set('Cache-Control', 'no-store');
    res.send(html);
  } catch (err) {
    console.error('SSR error:', err.message);
    res.status(500).send('Rendering failed');
  }
});

const server = app.listen(PORT, () => {
  console.log(`Renderer listening on http://localhost:${PORT}`);
});

process.on('SIGTERM', async () => {
  await shutdown();
  server.close();
});

When a request hits /ssr/posts/1, the server launches a headless page, navigates to the SPA, waits for the #app element to populate, and returns the resulting HTML. A crawler receives fully rendered content with the correct <title> and body.

Detecting Crawlers vs. Browsers

In practice, you don't want to SSR every request — real users can run the SPA themselves. A common optimization is to SSR only for crawlers and bots. Use a user-agent check middleware:

const BOT_PATTERN = /bot|crawl|spider|slurp|facebookexternalhit|twitterbot|linkedinbot/i;

function isBot(req) {
  return BOT_PATTERN.test(req.headers['user-agent'] || '');
}

app.get('/*', async (req, res, next) => {
  if (!isBot(req)) return next();

  const targetUrl = `http://localhost:${PORT}${req.path}`;
  try {
    const html = await renderPage(targetUrl);
    return res.set('Content-Type', 'text/html').send(html);
  } catch (err) {
    return next();
  }
});

This way, regular visitors get the fast static HTML and run JavaScript themselves, while crawlers receive pre-rendered content optimized for SEO and social previews.

Implementing SSG (Static Site Generation)

SSG renders pages once at build time and serves the resulting HTML files statically. This is the most performant pattern: there's no browser involved at request time, and you can deploy the output to any CDN. The downside is that content becomes stale until the next build.

Let's create a build script called build.js that crawls a list of routes and writes HTML files to disk.

const fs = require('fs');
const path = require('path');
const { renderPage, shutdown } = require('./renderer');

const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const OUTPUT_DIR = path.join(__dirname, 'dist');

const routes = [
  '/',
  '/posts/1',
  '/posts/2',
  '/posts/3',
  '/about',
];

async function build() {
  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  for (const route of routes) {
    const url = `${BASE_URL}/app.html#${route}`;
    console.log(`Rendering ${route}...`);

    try {
      const html = await renderPage(url, {
        waitForSelector: '#app',
        timeout: 15000,
      });

      const filePath = path.join(
        OUTPUT_DIR,
        route === '/' ? 'index.html' : `${route.replace(/^\//, '')}.html`
      );

      fs.mkdirSync(path.dirname(filePath), { recursive: true });
      fs.writeFileSync(filePath, html);
      console.log(`  ✓ Wrote ${filePath}`);
    } catch (err) {
      console.error(`  ✗ Failed to render ${route}:`, err.message);
    }
  }

  await shutdown();
  console.log('Build complete.');
}

build();

Run your server first, then execute the build:

node server.js &
node build.js

The dist/ directory now contains static HTML files you can upload to S3, Netlify, Vercel, GitHub Pages, or any static host. Because each file already contains rendered content, crawlers and users alike get instant HTML.

Parallelizing the Build

Rendering routes sequentially is slow for large sites. You can parallelize with a concurrency limiter:

async function runWithConcurrency(tasks, limit = 4) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = task().then((r) => {
      executing.delete(p);
      return r;
    });
    executing.add(p);
    results.push(p);
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

await runWithConcurrency(
  routes.map((route) => () => renderAndSave(route)),
  4
);

Keep concurrency modest (4–8) to avoid overwhelming the browser process or running out of memory.

Implementing ISR (Incremental Static Regeneration)

ISR combines the speed of SSG with the freshness of SSR. Pages are generated on the first request and cached; subsequent requests serve the cache. A background process periodically regenerates the page so content stays fresh without rebuilding the entire site. Next.js popularized this pattern, but we can replicate it with Playwright and a simple cache layer.

Create cache.js with a time-to-live (TTL) based store:

const NodeCache = require('node-cache');

const cache = new NodeCache({
  stdTTL: 60,         // default 60 seconds
  checkperiod: 30,
  useClones: false,
});

function get(key) {
  return cache.get(key);
}

function set(key, value, ttl) {
  if (ttl) {
    cache.set(key, value, ttl);
  } else {
    cache.set(key, value);
  }
}

module.exports = { get, set };

Install the dependency: npm install node-cache.

Now add an ISR endpoint to server.js. The first request triggers a render and caches it. Subsequent requests serve the cache. A stale-while-revalidate strategy serves stale content immediately while regenerating in the background.

const cache = require('./cache');
const { renderPage } = require('./renderer');

const ISR_TTL = 60;          // seconds before cache goes stale
const ISR_STALE_TTL = 3600;  // seconds before stale content is purged

app.get('/isr/*', async (req, res) => {
  const route = req.params[0] || '';
  const cacheKey = `isr:${route}`;
  const targetUrl = `http://localhost:${PORT}/app.html#/${route}`;

  const cached = cache.get(cacheKey);

  if (cached) {
    // Serve cached content immediately
    res.set('Content-Type', 'text/html');
    res.set('Cache-Control', `s-maxage=${ISR_TTL}, stale-while-revalidate=${ISR_STALE_TTL}`);
    res.send(cached.html);

    // Regenerate in background if stale
    if (Date.now() - cached.generatedAt > ISR_TTL * 1000) {
      renderPage(targetUrl)
        .then((html) => {
          cache.set(cacheKey, { html, generatedAt: Date.now() });
          console.log(`Regenerated ISR cache for ${route}`);
        })
        .catch((err) => console.error(`ISR regeneration failed for ${route}:`, err.message));
    }
    return;
  }

  // Cache miss: render synchronously
  try {
    const html = await renderPage(targetUrl);
    cache.set(cacheKey, { html, generatedAt: Date.now() });
    res.set('Content-Type', 'text/html');
    res.set('Cache-Control', `s-maxage=${ISR_TTL}, stale-while-revalidate=${ISR_STALE_TTL}`);
    res.send(html);
  } catch (err) {
    console.error('ISR render error:', err.message);
    res.status(500).send('Rendering failed');
  }
});

This implementation gives you the best of both worlds: the first visitor pays the rendering cost, but everyone else gets instant cached HTML. Stale content is served while a fresh version is generated in the background, so users never wait for regeneration to complete.

On-Demand Revalidation

Sometimes you want to regenerate a page immediately — for example, when content is updated in your CMS. Add a webhook endpoint that purges specific cache entries:

app.post('/api/revalidate', express.json(), async (req, res) => {
  const { secret, routes } = req.body;
  if (secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const purged = [];
  for (const route of routes || []) {
    const key = `isr:${route}`;
    if (cache.get(key)) {
      cache.del(key);
      purged.push(route);
    }
  }

  res.json({ purged, timestamp: Date.now() });
});

Your CMS can call POST /api/revalidate with a list of routes whenever content changes, ensuring the next request regenerates those pages.

Best Practices for Production

1. Keep the Browser Warm

Launching a browser takes 500ms–2s. Always reuse a single browser instance and create new contexts per request. Never call chromium.launch() inside a request handler.

2. Constrain Resources

Headless Chromium can consume significant memory. Limit concurrent pages, set navigation timeouts, and consider running the renderer in a container with memory limits:

const browser = await chromium.launch({
  headless: true,
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
    '--single-process',
    '--max-old-space-size=512',
  ],
});

3. Block Unnecessary Resources

For SEO rendering, you usually don't need images, fonts, stylesheets, or analytics scripts. Block them to speed up rendering dramatically:

await page.route('**/*', (route) => {
  const type = route.request().resourceType();
  if (['image', 'font', 'media', 'stylesheet'].includes(type)) {
    return route.abort();
  }
  // Allow analytics but consider blocking them too
  if (route.request().url().includes('google-analytics')) {
    return route.abort();
  }
  return route.continue();
});

Blocking resources can cut render time by 50% or more, especially on image-heavy pages.

4. Use Smart Wait Strategies

Avoid waitForTimeout in production — it's brittle. Instead, wait for specific signals that your app is ready:

// Wait for a custom event
await page.waitForFunction(() => window.__APP_READY__ === true);

// Wait for network to settle
await page.waitForLoadState('networkidle');

// Wait for a specific element
await page.waitForSelector('[data-rendered="true"]');

You can have your SPA set window.__APP_READY__ = true once all data has loaded and the DOM is stable. The renderer then waits for that flag, guaranteeing complete content.

5. Add Health Checks and Auto-Restart

Browsers can crash or leak memory. Add a health endpoint and restart the browser periodically:

let renderCount = 0;
const MAX_RENDERS_BEFORE_RESTART = 500;

async function renderPage(url, options) {
  renderCount++;
  if (renderCount >= MAX_RENDERS_BEFORE_RESTART) {
    await shutdown();
    renderCount = 0;
  }
  // ... rest of rendering logic
}

app.get('/health', (req, res) => {
  const healthy = browser && browser.isConnected();
  res.status(healthy ? 200 : 503).json({ healthy });
});

6. Cache Aggressively

Even with ISR, add a CDN or reverse proxy (Cloudflare, Fastly, Varnish) in front of your renderer. Set appropriate Cache-Control headers so the CDN caches responses and only forwards misses to your origin. This protects the browser process from traffic spikes.

7. Handle Errors Gracefully

If rendering fails, fall back to serving the unrendered SPA rather than returning a 500. Users still get a working page; only crawlers miss the pre-rendered content:

try {
  const html = await renderPage(targetUrl);
  res.send(html);
} catch (err) {
  console.error('Render failed, falling back to SPA:', err.message);
  res.sendFile(path.join(__dirname, 'public', 'app.html'));
}

8. Monitor Performance

Track render times, cache hit rates, and error rates. A simple logging middleware can surface performance issues before they affect users:

app.use('/isr', (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`[ISR] ${req.path} ${res.statusCode} ${duration}ms`);
  });
  next();
});

Comparing the Three Patterns

Many production sites use all three together: SSG for static marketing pages, ISR for product pages, and SSR for user-specific account pages.

Conclusion

Playwright-based rendering is a powerful, framework-agnostic approach to server-side rendering that lets you serve fully rendered HTML without rewriting your frontend. By implementing SSR for dynamic content, SSG for build-time generation, and ISR for the best balance of freshness and performance, you can cover virtually any rendering need. The key to success in production is treating the headless browser as a precious resource: keep it warm, constrain its memory, block unnecessary resources, cache aggressively, and always have a fallback. With these patterns and best practices in place, you can deliver fast, SEO-friendly pages from any JavaScript application, regardless of the framework you've chosen.

— Ad —

Google AdSense will appear here after approval

← Back to all articles