Express Screenshot API: A Streaming Proxy in 60 Lines

Build an Express 5 screenshot service: a validated proxy that streams images from the API with cache headers, signed gallery URLs and supertest tests.

By Simon Chiu · Updated

Many "screenshot server" repos on GitHub are an Express app that drives PhantomJS or an old Puppeteer, with a /screenshot?url= route and no checks. PhantomJS is unmaintained, and a headless Chrome inside your web process brings memory spikes, zombie processes and font packages to your container. This page replaces that pattern with a small Express 5 service. Chrome runs on our side; your process only checks input, forwards one request and streams the bytes back.

The page covers four parts: the proxy route, a gallery page with signed URLs, the error and cache rules, and tests with supertest. The SDK basics and a slug-based og:image route are on the Node.js screenshot API page, so this page does not repeat them.

Last tested: 2026-10-18 with Node 22.18, Express 5.3.0, renderscreenshot 1.1.2, TypeScript 7.0.2 (tsc --strict passed), vitest 5.0.5 and supertest 7.3.1. The tests ran against a local HTTP stub that returns the documented image and 429 responses. We did not call the live API for this page.

How the request flows

sequenceDiagram
  participant B as Browser or CDN
  participant E as Express /shot
  participant A as RenderScreenshot API
  B->>E: GET /shot?url=https://example.com/&width=800
  E->>E: allowlist, width and format checks
  E->>A: POST /v1/screenshot (Bearer rs_live_...)
  A-->>E: image/webp stream
  E-->>B: same stream + Cache-Control

Put a CDN in front of /shot and most requests never reach Express. The Cache-Control header that the route sets is what makes that work.

Install

npm install express@5 renderscreenshot
npm install -D typescript @types/express @types/node vitest supertest @types/supertest

The proxy uses the global fetch and needs no SDK. The SDK is only for signing URLs in the gallery. Node 22.18 runs the .ts files directly (node server.ts), because type stripping is on by default. Set "type": "module" in package.json, or Node prints a warning and reparses each file as ESM.

The proxy route

// server.ts
import express from 'express';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';

const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com';
const KEY = process.env.RS_API_KEY ?? '';
const ALLOWED_HOSTS = new Set(['example.com', 'www.example.com']);
const FORMATS = new Set(['png', 'jpeg', 'webp']);

export const app = express();
app.disable('x-powered-by');

app.get('/shot', async (req, res) => {
  let target: URL;
  try {
    target = new URL(String(req.query.url ?? ''));
  } catch {
    return res.status(400).json({ error: 'url must be an absolute URL' });
  }
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
    return res.status(400).json({ error: 'host not allowed' });
  }
  const width = Number(req.query.width ?? 1200);
  if (!Number.isInteger(width) || width < 320 || width > 1920) {
    return res.status(400).json({ error: 'width must be 320 to 1920' });
  }
  const format = String(req.query.format ?? 'webp');
  if (!FORMATS.has(format)) return res.status(400).json({ error: 'format must be png, jpeg or webp' });

  const upstream = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: target.href,
      viewport: { width, height: Math.round(width * 0.525) },
      output: { format },
    }),
    signal: AbortSignal.timeout(75_000),
  });

  if (!upstream.ok || !upstream.body) {
    const body = await upstream.json().catch(() => null);
    console.error('render failed', upstream.status, body?.error?.code, body?.request_id);
    if (upstream.status === 429) res.set('Retry-After', upstream.headers.get('retry-after') ?? '30');
    return res.sendStatus(upstream.status === 429 ? 503 : 502);
  }

  res.set({
    'Content-Type': upstream.headers.get('content-type') ?? `image/${format}`,
    'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
  });
  const title = upstream.headers.get('x-screenshot-title');
  if (title) res.set('X-Page-Title', title);
  await pipeline(Readable.fromWeb(upstream.body as NodeReadableStream), res);
});

if (process.argv[1]?.endsWith('server.ts')) app.listen(Number(process.env.PORT ?? 3000));

Why each part is there:

  • The allowlist is the security boundary. Without it, anyone who finds /shot can spend your credits on any page. The API already refuses private and internal addresses with blocked, so the allowlist is about cost and abuse, not SSRF. Replace the two hosts with your own.
  • Every number has a range. width comes from the query string, so Number() can give NaN, a float or 1e9. The checks run before fetch, and a rejected request costs nothing.
  • The body is nested JSON. POST /v1/screenshot takes viewport.width and output.format, not the flat width and format of the GET endpoint. The raw HTTP guide has the full mapping.
  • pipeline streams, it does not buffer. A full-page PNG can be several megabytes. Readable.fromWeb turns the fetch body into a Node stream, and pipeline ends the response and frees the socket if the client goes away. The as NodeReadableStream cast is only for TypeScript: the DOM and Node stream types do not match in @types/node, but the object at runtime is the same.
  • The page title comes along. Binary responses carry X-Screenshot-Title, X-Screenshot-Description and X-Screenshot-Favicon from the real page (response headers). The route passes the title on, so a link-preview client gets both from one request.
  • The timeout is above the API's. The longest render wait is 60 seconds (wait options). AbortSignal.timeout(75_000) leaves room for the network. If it fires, fetch rejects, and Express 5 sends the rejection to the error middleware, so the route needs no try around it.

Cache headers, and who pays for a miss

Each request that reaches the API is a render. max-age=86400 lets a CDN keep the image for a day, and stale-while-revalidate lets it serve the old image while it fetches a new one. The API also has its own cache with a default TTL of one day, but it reuses an earlier render only when the request asks for it with cache.available. Without that field, two CDN misses for the same URL are two renders and two credits. To share one render, add cache: { available: true } to the JSON body; a reused render costs 0 credits but still counts as a request. Use the CDN as the first layer: a CDN hit never touches your process or your rate limit.

What a 429 means here

The rate limit is per account, and every key shares it (rate limits). If the proxy retried on its own, one busy page would slow down your other services too. So the route turns a 429 into a 503 with the API's Retry-After, and lets the CDN or the client wait. Other failures become 502. The log line keeps the API's error.code and request_id for support. The error codes index lists every code.

For pages that you render yourself, you do not need the proxy at all. Sign a GET URL per image and let the browser fetch it from the API. Your API key never leaves the server, and the image request never reaches Express:

// gallery.ts
import express from 'express';
import { Client, TakeOptions } from 'renderscreenshot';

const signer = new Client(process.env.RS_API_KEY ?? '', {
  signingKey: process.env.RS_SIGNING_KEY,    // rs_secret_...
  publicKeyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_...
});

const DAY = 86_400_000;
const escapeHtml = (s: string) =>
  s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

export function thumb(pageUrl: string, now = Date.now()): string {
  // Midnight UTC + 8 days: one URL per page per day, so browsers and CDNs can cache it.
  const expires = new Date(Math.floor(now / DAY) * DAY + 8 * DAY);
  return signer.generateUrl(
    TakeOptions.url(pageUrl).width(1280).height(800).format('webp').quality(70),
    expires,
  );
}

export const gallery = express.Router();
gallery.get('/gallery', (_req, res) => {
  const sites = ['https://example.com/', 'https://example.org/'];
  const items = sites
    .map((s) => `<li><img src="${escapeHtml(thumb(s))}" width="640" height="400" loading="lazy" alt="${escapeHtml(s)}"></li>`)
    .join('');
  res.type('html').send(`<!doctype html><ul>${items}</ul>`);
});

Mount it with app.use(gallery). Two details matter:

  1. The HTML is escaped. This page builds markup with a template string, so the & in the signed URL and any character in a site URL must be escaped. In a real app the site list often comes from users, and an unescaped " would let one of them inject attributes. If you use a view engine such as EJS or Pug, its default escaping does the same job.
  2. The expiry is aligned to the day. Date.now() + 7 days would give a new URL on every page view, and no cache could reuse the image. Rounding down to midnight UTC keeps the URL the same all day. The expiry is always 7 to 8 days ahead, inside the 30-day maximum. Signed URLs work only for GET and HEAD on /v1/screenshot.

Test with supertest and a stub API

Do not mock fetch for the proxy. Start a real HTTP server on a random port that answers like the API, and point RS_API_BASE at it. The test then covers the stream, the headers and the JSON body that the route sends:

// test/server.test.ts
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, expect, it, vi } from 'vitest';

let stub: http.Server;
let calls: { auth?: string; body: unknown }[] = [];
let mode: 'ok' | 'limited' = 'ok';

beforeAll(async () => {
  stub = http.createServer((req, res) => {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      calls.push({ auth: req.headers.authorization, body: JSON.parse(body) });
      if (mode === 'limited') {
        res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '7' });
        return res.end(JSON.stringify({
          error: { type: 'rate_limit_error', code: 'too_many_requests', message: 'slow down', retryable: true },
          request_id: 'r1',
        }));
      }
      res.writeHead(200, { 'Content-Type': 'image/webp', 'X-Screenshot-Title': 'Example Domain' });
      res.end(Buffer.from('RIFFfakewebp'));
    });
  });
  await new Promise<void>((r) => stub.listen(0, r));
  process.env.RS_API_BASE = `http://127.0.0.1:${(stub.address() as AddressInfo).port}`;
  process.env.RS_API_KEY = 'rs_live_test';
  process.env.RS_SIGNING_KEY = 'rs_secret_test';
  process.env.RS_PUBLIC_KEY_ID = 'rs_pub_test';
});
afterAll(() => stub.close());

const load = async () => (await import('../server.ts')).app;

it('streams the image with cache headers', async () => {
  mode = 'ok'; calls = [];
  const res = await request(await load()).get('/shot?url=https://example.com/&width=800');
  expect(res.status).toBe(200);
  expect(res.headers['content-type']).toBe('image/webp');
  expect(res.headers['cache-control']).toContain('max-age=86400');
  expect(res.headers['x-page-title']).toBe('Example Domain');
  expect(calls[0]).toEqual({
    auth: 'Bearer rs_live_test',
    body: { url: 'https://example.com/', viewport: { width: 800, height: 420 }, output: { format: 'webp' } },
  });
});

it('refuses other hosts before spending a credit', async () => {
  calls = [];
  for (const url of ['http://example.com/', 'https://evil.test/', 'javascript:alert(1)']) {
    expect((await request(await load()).get('/shot').query({ url })).status).toBe(400);
  }
  expect((await request(await load()).get('/shot?url=https://example.com/&width=5000')).status).toBe(400);
  expect(calls).toHaveLength(0);
});

it('maps a 429 to 503 with Retry-After', async () => {
  mode = 'limited';
  vi.spyOn(console, 'error').mockImplementation(() => {});
  const res = await request(await load()).get('/shot?url=https://example.com/');
  expect(res.status).toBe(503);
  expect(res.headers['retry-after']).toBe('7');
});

it('signs one stable URL per day', async () => {
  const { thumb } = await import('../gallery.ts');
  const a = thumb('https://example.com/', Date.UTC(2026, 9, 18, 1));
  expect(thumb('https://example.com/', Date.UTC(2026, 9, 18, 23))).toBe(a);
  expect(new URL(a).searchParams.get('expires')).toBe(String(Date.UTC(2026, 9, 26) / 1000));
  expect(a).not.toMatch(/rs_secret|rs_live/);
});

The environment variables are set in beforeAll, and the modules are imported after it, because server.ts and gallery.ts read them at import time. The last test needs no network: generateUrl() only computes an HMAC. In our run, the "refuses" test also proved that no request reached the stub, which is the property that protects your credits. For contract tests in other languages, see the SDK testing guide.

When to keep Puppeteer instead

Run Chrome yourself if every capture needs a logged-in browser session that you cannot pass as cookies or headers, or if you must not send page content to a third party. If you go that way, read scaling Puppeteer screenshots first: a pool, a queue and crash recovery are the parts that the 60 lines above leave out.

Get a free API key, set it as RS_API_KEY, add your hosts to ALLOWED_HOSTS and run node server.ts. The free plan includes 150 credits every month.

Frequently asked questions

How do I build a screenshot server with Express?
Add a GET route that validates the target URL against an allowlist, sends a POST to /v1/screenshot with your API key in a Bearer header, and pipes the response body into the Express response with stream.pipeline. Chrome runs on the API side, so the Express process needs no browser.
Should an Express screenshot proxy accept any URL?
No. An open proxy lets anyone spend your credits and capture any page under your name. Accept only https URLs on hosts you list, and check numeric parameters such as width before you call the API.
What should my proxy return when the screenshot API rate-limits it?
Return 503 with the Retry-After value from the API. The rate limit is per account and all keys share it, so a 503 tells your CDN or client to back off instead of adding load.
Can I show screenshots in an Express-rendered page without proxying them?
Yes. Sign a GET URL on the server with the SDK's generateUrl() and put it in the img src. The browser then loads the image from the API directly, and the URL holds only your public key ID and a signature.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.