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.
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.
Before you start: cost, consent and cache
- 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 printsdropped_iterations; that is a client limit, not the API's. RaisemaxVUsif 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_imagecounts those separately from real failures. - Latency only for successes.
render_msrecords 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:
- Latency flat, no 429s (2 and 4 rps): the provider has headroom at this rate.
- 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.
- 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.
- 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
- How We Built a Screenshot API: Rails, Workers and R2 The architecture of RenderScreenshot: a Rails 8 API, a Cloudflare Worker that drives the browser, R2 storage, and the bugs and fixes that shaped it.
- Cloudflare Browser Rendering API: A Tested Tutorial (2026) Screenshots with Cloudflare Browser Rendering (now Browser Run): REST Quick Actions, the Workers binding, session reuse, limits and cost. Tested code.
- Screenshot API vs Self-Hosted Puppeteer: Real Cost A build-vs-buy cost model for screenshots: servers, Cloudflare browser time, engineering hours and API plans, with a calculator and a capacity script.
- Which Websites Block Headless Chrome? A 200-Site Study We loaded the top 200 Tranco domains in three headless Chrome profiles. 15% of sites blocked or challenged default Puppeteer. Raw data and script.
- How Long Does a Screenshot Take? Latency, Phase by Phase Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.
- Blocking Cookie Banners Automatically: How It Fails How our block.cookie_banners option clicks or hides consent banners in headless Chrome, the code behind it, and eight fixtures that show where it breaks.