· Updated · Engineering · By Simon Chiu

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.

Line illustration of the RenderScreenshot mascot in a hard hat stacking blocks for a gear, a cloud and a storage bucket

RenderScreenshot is a screenshot API built from three parts: a Ruby on Rails 8 application that owns every business decision, a Cloudflare Worker that drives a headless Chromium browser, and a Cloudflare R2 bucket that holds the images. This post walks through one request from end to end, shows the code at each step, and lists the real bugs we shipped and fixed along the way. If you are deciding whether to build a screenshot service yourself, the sections on SSRF and caching are the ones to read first.

Code excerpts are from our Rails and Worker repositories as of October 15, 2026. I build and run RenderScreenshot.

The architecture in one picture

flowchart LR
  C[Client<br/>SDK, curl, img tag] -->|Bearer key, query key<br/>or signed URL| R[Rails API<br/>auth, validation, presets,<br/>cache lookup, credits, logs]
  R -->|cache hit| H[(PostgreSQL<br/>request log = cache index)]
  R -->|cache miss: POST JSON<br/>X-Worker-Secret| W[Cloudflare Worker<br/>SSRF checks, blocking,<br/>page options, capture]
  W --> B[Browser Run<br/>headless Chromium]
  W -->|image bytes| S[(R2 bucket)]
  S --> CDN[cdn.renderscreenshot.com]
  R -->|image + X-Cache-* headers| C
  R -->|webhooks, batches,<br/>your bucket| Q[Solid Queue jobs]

The split is deliberate. Rails decides; the Worker renders. The Worker never sees an API key, a plan, a credit balance or a customer. It gets a JSON description of one capture and a shared secret, and returns a JSON description of the result. Everything that can be wrong about a request (bad key, unknown preset, out-of-range TTL, empty balance) is rejected in Rails before a browser starts.

Layer Runs on Owns
Rails 8.1 API One VPS, deployed with Kamal: a web container and a bin/jobs container Keys, signatures, validation, presets and devices, cache lookup, credits, request logs, batches, webhooks, uploads to customer buckets
PostgreSQL 17 Same VPS, as a Kamal accessory Accounts, keys, the request log (which doubles as the cache index), Solid Queue, Solid Cache, Solid Cable
Rendering Worker Cloudflare Workers with a Browser Run binding URL safety checks, browser emulation, request blocking, page modifications, capture, upload to R2
R2 Cloudflare The image files, served from cdn.renderscreenshot.com

There is no Redis, no separate queue service and no browser fleet to patch. That was the point.

Step 1: authenticate the request

Every request carries one of three credentials. Rails tries them in order:

def authenticate_api_request!
  @current_api_key = authenticate_from_bearer_token ||
                     authenticate_from_query_param ||
                     authenticate_from_signed_url
  # ...
end

API keys are stored as a hash plus the last four characters. A raw rs_live_ key exists only at creation, when the dashboard shows it once. Lookup hashes the presented key and finds the row by hash.

Signed URLs need more. The verifier has to recompute an HMAC with the account's secret, so it needs the secret itself, not a hash of it. Secret keys (rs_secret_) are therefore the one key type we store encrypted at rest with Rails' attribute encryption, and the signed URL verifier is what reads them. The verifier rebuilds a canonical query string from every parameter except signature, computes HMAC-SHA256 and compares in constant time:

def generate_signature(secret_key, request)
  params_to_sign = request.query_parameters.except('signature').to_query
  OpenSSL::HMAC.hexdigest('SHA256', secret_key, params_to_sign)
end

to_query sorts the pairs and form-encodes them, which is what every client signer has to reproduce byte for byte. The full design, including what we would change, is in signed URL design. Whether this check could run at the edge instead is the subject of our Workers gateway notes.

Step 2: validate before you render

A browser session is the expensive resource, so the cheap checks run first, as before_action filters:

  • credits remaining (a request needs at least one, even if it ends as a free cache hit);
  • preset and device IDs exist;
  • cache.ttl is between 3,600 and 2,592,000 seconds;
  • the URL parses as HTTP or HTTPS;
  • browser.timezone is a real IANA zone, browser.locale looks like BCP 47, geolocation is in range;
  • PDF options are valid.

Then parameters are normalized. GET requests use flat names (width, full_page, block_ads) and are mapped onto the nested structure that POST uses. wait.delay is capped at 10 seconds. Finally presets and devices are merged under the explicit parameters:

def deep_merge_params(preset_config, explicit_params)
  result = explicit_params.dup
  preset_config.each do |key, preset_value|
    if explicit_params[key].is_a?(Hash) && preset_value.is_a?(Hash)
      result[key] = preset_value.merge(explicit_params[key]) # explicit wins
    elsif explicit_params[key].nil?
      result[key] = preset_value
    end
  end
  result
end

The rule is simple to explain in docs: a preset sets defaults, a device overrides the preset's viewport, and anything you send explicitly overrides both. preset: 'og_card' with output: { format: 'webp' } gives a 1200x630 WebP.

Step 3: the cache is a query, not a key-value store

The cache lookup is the decision we would make again first. There is no separate cache service. Every request is logged to a screenshot_requests table with a hash of its parameters, and a cache hit is "the most recent successful request from this account with the same hash, inside the TTL":

def self.compute_params_hash(params)
  hashable = params.deep_dup
  hashable.delete(:cache)
  hashable.delete(:storage)
  Digest::SHA256.hexdigest(deep_sort_hash(hashable).to_json)
end

def self.find_cached(account:, params_hash:, ttl:)
  successful
    .where(account: account, params_hash: params_hash)
    .where('created_at > ?', ttl.seconds.ago)
    .order(created_at: :desc)
    .first
end

Three properties fall out of this design for free:

  1. Cache entries are scoped to an account. One customer's render never answers another customer's request, so a page rendered with one account's cookies cannot leak to another account.
  2. The request log and the cache can never disagree. The dashboard's request history is the cache.
  3. The cache options are excluded from the hash, so a request that asks for a cached copy matches the request that rendered it.

The trade-off: the lookup is opt-in. A client sends cache.available: true (or cache_available=true on GET) to get a stored render back for zero credits. We made it explicit because returning a 20-hour-old image to someone who expected a fresh render is a worse surprise than paying for a render. The caching reference documents the options.

Step 4: call the Worker

On a miss, Rails posts the normalized parameters to the Worker over HTTPS, with a shared secret in a header:

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.read_timeout = 60
http.open_timeout = 10

request = Net::HTTP::Post.new(uri.request_uri)
request['Content-Type'] = 'application/json'
request['X-Worker-Secret'] = @worker_secret
request.body = params.to_json

The 60-second read timeout matches the API's maximum wait.timeout of 60 seconds. A Puma thread is held for the duration of the render, which is the main scaling limit of this design: concurrent renders are bounded by web threads, not by browsers. For bulk work that is what the batch endpoint is for: up to 10 items without a webhook are processed inside the request, and anything larger goes to a Solid Queue job that renders in slices of the batch's concurrency.

The Worker rejects any request without the right secret before it parses the body:

const workerSecret = request.headers.get('X-Worker-Secret');
if (workerSecret !== env.WORKER_SECRET) {
  return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}

Step 5: make the URL safe to visit

A screenshot API is, by definition, a service that fetches any URL a stranger gives it. That is server-side request forgery (SSRF) as a product feature. If the renderer can reach 169.254.169.254, localhost or a private network, a customer can screenshot your cloud metadata or internal dashboards.

Our first version checked only that the URL was HTTP or HTTPS. In June 2026 we moved the real checks into the Worker, next to the browser, in two layers.

Before navigation, validateTargetUrl rejects:

  • schemes other than http: and https:, URLs with embedded credentials, and ports other than 80 and 443;
  • localhost, metadata.google.internal and hostnames ending in .local, .internal, .lan, .home.arpa and similar suffixes;
  • IPv4 and IPv6 literals in private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges, including IPv4-mapped IPv6;
  • hostnames whose A or AAAA records point into those ranges, resolved over DNS-over-HTTPS.

The WHATWG URL parser normalizes decimal, hex and octal IPv4 forms (http://2130706433/) to dotted decimal before we look at the host, which closes the classic encoding tricks without extra code.

After navigation, the browser resolves the hostname again when it connects. An attacker can return a public IP to our DNS check and a private one to the browser a moment later (DNS rebinding), or redirect to an internal address. So the Worker also inspects the peer address of every response the page receives:

let privatePeerIp: string | null = null;
page.on('response', (response: any) => {
  const ip = response?.remoteAddress?.()?.ip;
  if (ip && isPrivateIP(ip)) privatePeerIp ??= ip;
});
// ... after goto and the wait:
if (privatePeerIp) {
  return { success: false, error: `blocked_url: a resource resolved to private address ${privatePeerIp}` };
}

If anything on the page, including a sub-resource, was served from private space, no image is produced. Two honest caveats: the DNS-over-HTTPS check fails open when the resolver is unreachable (the literal and hostname checks still apply), and the peer check depends on the runtime exposing remoteAddress(). Layering is the point; neither check alone is enough.

If you build your own renderer, do this on day one. It is the part of a screenshot service that turns a bug into an incident.

Step 6: drive the browser

The Worker launches a browser through the Browser Run binding for every request and closes it in a finally block:

const browser: Browser = await puppeteer.launch(env.BROWSER);
const page = await browser.newPage();
try {
  await page.setViewport(viewport);
  await applyBrowserEmulation(page, params.browser, params.url); // UA, dark mode, timezone, locale, geolocation
  if (params.block && hasBlockingEnabled(params.block)) {
    await page.setRequestInterception(true);
    page.on('request', (request: any) => { /* abort ads, trackers, chat widgets, patterns, resource types */ });
  }
  await page.goto(params.url, { waitUntil, timeout });
  // delay, ad containers, cookie banners, page options (click, scripts, styles, hide, remove)
  // capture, upload to R2
} finally {
  await browser.close();
}

A fresh browser per capture costs launch time on every request. We chose it anyway because it is the only model where one customer's cookies, local storage or HTTP cache cannot affect another's capture, and because Browser Run bills browser time, so an idle pooled browser is not free either. Session reuse with a new browser context per request is the obvious next optimization; the Cloudflare Browser Rendering tutorial shows the pattern and its failure modes.

The order of operations matters and is documented because customers depend on it: navigation, wait, wait.delay, ad container hiding, the cookie banner handler, then the page options in a fixed order (click, scripts, styles, hide, remove). The cookie banner handler, and the eight banner shapes it does not handle, are covered in blocking cookie banners at scale.

Blocking is request interception against short domain lists: 20 ad networks, 20 analytics and tracking hosts, 15 chat widget hosts, plus customer glob patterns and resource types. Short lists are a choice: they are easy to audit, and a wrong entry is easy to find and remove.

Step 7: store the image in R2, not in Rails

The capture goes straight from the Worker to R2:

await env.SCREENSHOTS.put(r2Key, output, {
  httpMetadata: { contentType },
  customMetadata: { url: params.url ?? 'html', width: String(viewport.width), height: String(viewport.height), capturedAt: new Date().toISOString() },
});

The key looks like screenshots/ab/cd/<hash>_<timestamp>.png. The Worker's hash is a cheap 32-bit string hash of the capture options, used only to spread keys across prefixes; the timestamp makes each render's key unique. Deduplication is Rails' job (step 3), so the storage layer never has to decide whether two renders are "the same".

The Worker returns the key, the CDN URL on cdn.renderscreenshot.com, the dimensions and the byte size. That CDN URL becomes the X-Cache-URL response header: a public link to the image that needs no key, which is what most customers embed. R2 has no egress fees, which is why serving every image from it, many times, is affordable.

When a client asked for the image itself rather than JSON, Rails fetches the object from that URL and returns it with the X-Cache-*, dimension and credit headers. That second hop is a known cost. It keeps the response format identical for hits and misses, and it is the first thing we would move to the edge.

Step 8: bill, log and fan out

After a successful render, Rails increments the account's credits_used by one, writes the request log row (which is also the cache entry), and enqueues follow-up work on Solid Queue:

  • a webhook delivery if the account has a webhook URL;
  • an upload to the customer's own S3-compatible bucket if they configured storage.

Failed renders are logged with the Worker's error message and cost nothing. A cache hit is logged with zero credits. Because all of this is rows in one PostgreSQL database, "why was I charged for this?" is always one query.

Bugs we shipped, and what fixed them

These are real, from our commit history. None of them is exotic, which is the lesson.

Date Symptom Cause Fix
March 2026 PDF requests returned a file named .pdf that was really a PNG The Worker always called page.screenshot(), whatever the format Branch on format, call page.pdf() with paper, margins and header/footer options, upload with application/pdf
February 2026 The Rails app segfaulted after the upgrade to Ruby 4.0.1 An incompatibility between the pg gem's GSSAPI support and the new Ruby gssencmode: disable in the database config
June 2026 Renders in the dev Worker environment failed Wrangler named environments do not inherit bindings, so env.dev had no browser binding Repeat the browser binding under [env.dev], with a comment so nobody removes it
June 2026 Our URL validation would have let a crafted hostname reach private addresses Only the scheme was checked; DNS and redirects were trusted The two-layer SSRF checks in step 5
October 2026 SDK batch calls returned 404 The SDKs called the documented /v1/batch; the router only had /v1/batches Route both paths, add controller tests for the documented one

The batch bug is the one I think about most. The docs, six SDKs and the router were each correct on their own terms and wrong together. The fix added controller tests that call the documented path rather than the implemented one. The same class of drift exists between the Node SDK's option builder and the POST body; the Node SDK post lists what is affected and the workaround.

What we did not measure here

This post has no latency figures, on purpose. We have not yet published a measured breakdown of production render times with a method box, and a number without one is marketing. The method we use, the phases we time (DNS, connect, TTFB, load, wait, capture, encode, upload) and the script are in how long does a screenshot take. Cross-vendor numbers will appear in the screenshot API benchmark when the run is done, including the cases where we lose.

If you are building one yourself

In order of how much each would have saved us:

  1. Put SSRF checks next to the browser, before and after navigation. Validate in your API too, but the renderer is the last line.
  2. Keep the renderer stateless and secret-gated. It should not know about customers. That makes it easy to replace, scale and reason about.
  3. Make the cache an index over your request log. Scoped per customer, impossible to drift from billing.
  4. Write the order of page operations down and keep it stable. Customers build on it.
  5. Test the documented paths, not the implemented ones. Docs, SDKs and routes drift apart one reasonable commit at a time.
  6. Decide on browser reuse deliberately. Fresh browsers are slower; shared ones need context isolation. Both are defensible; drifting between them is not.

The cost side of build versus buy, with a calculator, is in screenshot API vs self-hosted cost.

Try the result

Everything above sits behind one request. Create a free account and run:

curl -H "Authorization: Bearer $RS_API_KEY" \
  "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&preset=og_card" \
  -o og.png -D headers.txt

headers.txt will show the X-Cache-URL, dimension and credit headers described in step 7.

Related in this topic