· Updated · Engineering · By Simon Chiu

Load Testing a Screenshot API with k6: Scripts and Method

k6 scripts to load test any screenshot API: step the arrival rate, separate fresh renders from cache hits, count 429s, and read the latency curve. Tested.

Line illustration of the RenderScreenshot mascot adding weight on a scale to test a server

Load testing a screenshot API is different from load testing a JSON API. Each request starts a browser, loads a third-party page and encodes an image, so a single request takes seconds, costs money, and can be answered from a cache without you noticing. This post gives you a k6 script that handles those three problems, explains how to read what it prints, and lists the mistakes that make most published "throughput" numbers meaningless. The script works against any provider; the RenderScreenshot request is one adapter. It is part of our engineering notes.

Scripts tested on October 15, 2026 with k6 v1.3.0 against a local mock API with a concurrency cap. This post contains no throughput numbers for RenderScreenshot or any other vendor; the section near the end explains why and how we will publish them.

  • Every fresh render is billed. On RenderScreenshot, each uncached render uses one credit; on most other APIs it is one screenshot or one unit of browser time. A test at 8 requests per second for 2 minutes is 960 renders. Size the stages from your plan's allowance.
  • Ask the vendor before a high-rate test. A load test is indistinguishable from abuse to the provider's monitoring, and their terms may require notice. Small step tests inside your plan's documented limits are normal use.
  • Bust the cache, then test the cache on purpose. Without a unique URL per request you measure the provider's CDN, not its renderer. Both numbers matter; never mix them in one run.
  • Use your own target pages. You are sending a burst of real browser visits to the target. Point them at a page you control or one built for testing, such as https://example.com.

Pick the right load model

k6 has two families of executors, and the choice decides what your numbers mean.

Model k6 executor What happens when the API slows down Use it for
Closed (fixed users) constant-vus, ramping-vus Each user waits for its response, so the request rate drops Finding the concurrency where latency starts to climb
Open (fixed arrival rate) constant-arrival-rate, ramping-arrival-rate Requests keep arriving; slow responses pile up as in-flight requests Finding the rate the API sustains, and where 429s start

Real screenshot traffic is open: crawlers, users and cron jobs do not wait for your last render to finish. Start with the open model.

The step test

The script runs one scenario per stage, back to back: 1, 2, 4 and 8 requests per second by default, 2 minutes each. It sends the same nested POST body every vendor-neutral test needs (fixed viewport, PNG), appends a unique query parameter to the target URL so each request is a fresh render, and tags every metric with its stage.

// screenshot-load.js: step the arrival rate up and record latency, errors and 429s.
// k6 v1.x.  Usage:
//   RS_API_KEY=rs_live_... k6 run -e TARGETS=targets.txt screenshot-load.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
import { Counter, Trend, Rate } from 'k6/metrics';
import exec from 'k6/execution';

const BASE_URL = __ENV.BASE_URL || 'https://api.renderscreenshot.com';
const API_KEY = __ENV.RS_API_KEY;
const CACHE_BUST = (__ENV.CACHE_BUST || 'true') === 'true';
const STEP_RATES = (__ENV.STEPS || '1,2,4,8').split(',').map(Number); // requests per second
const STEP_SECONDS = Number(__ENV.STEP_SECONDS || 120);

const targets = new SharedArray('targets', () =>
  open(__ENV.TARGETS || 'targets.txt').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')),
);

const renderMs = new Trend('render_ms', true);
const rateLimited = new Counter('rate_limited');
const notImage = new Counter('not_image');
const success = new Rate('success');

// One scenario per step, run back to back. arrival-rate executors keep sending
// at the target rate even when responses slow down, which is what exposes queueing.
export const options = {
  scenarios: Object.fromEntries(
    STEP_RATES.map((rate, i) => [
      `step_${rate}rps`,
      {
        executor: 'constant-arrival-rate',
        rate,
        timeUnit: '1s',
        duration: `${STEP_SECONDS}s`,
        startTime: `${i * STEP_SECONDS}s`,
        preAllocatedVUs: rate * 15,
        maxVUs: rate * 60,
        tags: { step: String(rate) },
      },
    ]),
  ),
  thresholds: {
    success: ['rate>0.95'],
    render_ms: ['p(95)<15000'],
    // Empty threshold lists make k6 print one line per step in the summary.
    ...Object.fromEntries(STEP_RATES.flatMap((r) => [[`render_ms{step:${r}}`, []], [`rate_limited{step:${r}}`, []]])),
  },
  summaryTrendStats: ['min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
};

export function setup() {
  if (!API_KEY) throw new Error('Set RS_API_KEY');
}

export default function () {
  const target = targets[exec.scenario.iterationInTest % targets.length];
  // A unique query string forces a fresh render; without it you are measuring the cache.
  const url = CACHE_BUST ? `${target}${target.includes('?') ? '&' : '?'}k6=${exec.scenario.iterationInTest}-${Date.now()}` : target;

  const res = http.post(
    `${BASE_URL}/v1/screenshot`,
    JSON.stringify({ url, viewport: { width: 1280, height: 800 }, output: { format: 'png' } }),
    {
      headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
      timeout: '90s',
      tags: { name: 'screenshot' }, // one metric series, not one per URL
    },
  );

  if (res.status === 429) rateLimited.add(1);
  const isImage = (res.headers['Content-Type'] || '').startsWith('image/');
  if (res.status === 200 && !isImage) notImage.add(1);

  const ok = check(res, {
    'status 200': (r) => r.status === 200,
    'body is an image': () => isImage,
  });
  success.add(ok);
  if (ok) renderMs.add(res.timings.duration);
}

targets.txt holds one URL per line. Run a short dry run first, then the real one:

# 3 stages x 10 s: checks the wiring for about 70 renders
k6 run -e STEPS=1,2,4 -e STEP_SECONDS=10 -e TARGETS=targets.txt screenshot-load.js

# the full step test: 1, 2, 4, 8 rps for 2 minutes each (1,800 renders)
k6 run -e TARGETS=targets.txt screenshot-load.js

Design choices in the script

  • preAllocatedVUs: rate * 15. In an open model, each in-flight request holds a virtual user. At 4 requests per second and 10-second renders, 40 requests are in flight. If k6 runs out of VUs it drops iterations and prints dropped_iterations; that is a client limit, not the API's. Raise maxVUs if you see it.
  • timeout: '90s'. Longer than any vendor's render timeout, so slow renders show up as slow, not as client-side errors.
  • tags: { name: 'screenshot' }. Without it, the cache-busted URLs would create one metric series per request.
  • The body check. Some APIs return HTTP 200 with an HTML error page. not_image counts those separately from real failures.
  • Latency only for successes. render_ms records successful renders. Failed requests are often fast (a 429 returns in milliseconds) and would make a struggling API look quicker.

Adapting it to another provider

Only the http.post call is provider-specific. For a GET-style API, replace it with:

const res = http.get(
  `https://api.vendor.example/take?access_key=${API_KEY}&url=${encodeURIComponent(url)}&viewport_width=1280&viewport_height=800&format=png`,
  { timeout: '90s', tags: { name: 'screenshot' } },
);

Map each provider's parameters to the same viewport, format and device scale factor before you compare. One vendor rendering at 2x by default will look slower and heavier for reasons that have nothing to do with capacity.

Reading the result

The tested run below used a local mock API that sleeps 400 to 900 ms per request and allows 5 requests in flight, returning HTTP 429 above that. Its numbers describe the mock, not any real service. They show what the summary looks like when a limit is reached:

rate_limited...................: 12
  { step:2 }...................: 0
  { step:4 }...................: 0
  { step:8 }...................: 12
render_ms......................: med=658.23ms p(95)=852.87ms
  { step:2 }...................: med=648.5ms  p(95)=821.65ms
  { step:4 }...................: med=671.38ms p(95)=852.83ms
  { step:8 }...................: med=653.97ms p(95)=861.75ms
success........................: 89.56% 103 out of 115

Read it per stage:

  1. Latency flat, no 429s (2 and 4 rps): the provider has headroom at this rate.
  2. Latency flat, 429s appear (8 rps): you hit a limit that rejects rather than queues. With a 5-in-flight cap and roughly 0.65-second requests, the mock allows about 7.7 requests per second; 8 crosses it. The success threshold fails, which is the signal you want.
  3. Latency climbs, no 429s: the provider queues instead of rejecting. Your p95 grows with every stage until requests time out. This is the more dangerous pattern, because nothing tells your client to slow down.
  4. Errors that are not 429: 5xx or timeouts mean the renderer itself is saturated or the target pages are failing. Check the same targets at 1 rps before blaming the API.

The concurrency rule of thumb is Little's law: requests in flight = arrival rate x average latency. If a provider documents a concurrent-request limit, divide it by your median render time to get the arrival rate where 429s should start. If they start much earlier, there is another limit, often per minute.

Measuring the cache separately

Run the same script with -e CACHE_BUST=false and a short target list. After the first request for each URL, a provider with a CDN cache answers from it. On RenderScreenshot, cached results come back only when the request asks for them, with cache.available: true in the body, and they cost zero credits; the caching reference has the rules. Add that field to the body for a cache run:

JSON.stringify({ url, viewport: { width: 1280, height: 800 }, output: { format: 'png' }, cache: { available: true } })

Report cache runs and render runs as separate rows. A "fastest screenshot API" claim that mixes them is measuring a CDN.

Finding the concurrency knee

The arrival-rate test tells you the sustainable rate. To find where latency starts to bend, run a closed-model test with a slow ramp of virtual users:

export const options = {
  scenarios: {
    knee: {
      executor: 'ramping-vus',
      startVUs: 1,
      stages: [
        { duration: '2m', target: 2 },
        { duration: '2m', target: 4 },
        { duration: '2m', target: 8 },
      ],
    },
  },
};

This is a fragment: it replaces the options export in the script above, which keeps the same request function. Plot median latency against VUs. The knee is the last stage before the median rises by more than a third; your production concurrency should sit below it.

Mistakes that invalidate a test

Mistake What it does to the numbers Fix
Same URL for every request Measures the cache, not the renderer Unique query parameter per request
Closed model only A slow API receives less load and looks stable Open model for the main test
Averages instead of percentiles One 30-second render hides behind 99 fast ones Report p50, p95 and p99
Counting failures in latency 429s in milliseconds make overload look fast Latency of successes, error rate separately
Heavy targets on a weak target server You load test the target site Use a page you control, measure it at 1 rps first
One run Network and target noise dominate Repeat each test at least 3 times, report the spread
Comparing different defaults 2x scale or full-page defaults inflate time and bytes Pin viewport, scale, format and wait condition

What we have not published yet

We have not published load test results for RenderScreenshot. Our renders are bounded by the browser concurrency of our Cloudflare account and by the web threads that hold each request open while the render runs (the architecture post explains why), and the honest number depends on both. When we publish it, it will be in the screenshot API benchmark with a method box, the raw CSV and this exact script, alongside other providers measured the same way.

If your workload is many URLs at once rather than a sustained rate, the batch endpoint is usually a better fit than parallel single requests. For what happens inside each request, see how long a screenshot takes.

Run it against us

Create a free account, put the key in RS_API_KEY, and run the dry-run command above. It uses about 70 renders, inside the free allowance. Check your plan's documented limits on the rate limits page before you run the full test.

Related in this topic