Benchmark · Comparison

Cloudflare's CORS proxy Worker, benchmarked against corsproxy

Cloudflare's Workers docs ship a CORS header proxy example that a lot of people deploy as-is. We wanted to know how it compares to the open-source corsproxy Go binary when both run on the same machine, hit the same upstream, and get the same load and the same seven abuse probes. Everything below ran in Docker, every number is from one session, and every command is in the post so you can rerun it.

· ~8 min read

Setup

Three containers on one Docker network. An upstream Go server on port 9000 that returns a 70-byte JSON body, plus an /echo route that reflects request headers, a /redir route that answers 302 to http://169.254.169.254/, and a /big?mb=N route that streams N megabytes. Then the two proxies:

# corsproxy v1.2.6, published image
docker run -d --name px --network bench \
  -e ALLOW_PRIVATE_NETWORKS=true \
  ghcr.io/melihbirim/corsproxy:1.2.6

# Cloudflare's example, unmodified JavaScript from the docs, run on workerd
# (the same runtime Cloudflare uses in production, without the dev server)
npm i workerd
npx workerd serve config.capnp

ALLOW_PRIVATE_NETWORKS=true is only there so corsproxy will talk to an upstream on a Docker-internal address. For the security probes we started a second copy with defaults. The Worker needs no equivalent flag because it has no such check.

Load came from Apache Bench in a fourth container, keep-alive on. The request through each proxy was the same: fetch http://up:9000/. The URL shapes differ because the two proxies expose different query parameters:

ab -k -n 20000 -c 50 'http://px:8080/?url=http://up:9000/'
ab -k -n 20000 -c 50 'http://cfw:8787/corsproxy/?apiurl=http://up:9000/'

The workerd config is fifteen lines. The only non-default choice is network = (allow = ["public", "private"]), so the Worker can reach the Docker network at all; on Cloudflare's edge a Worker cannot reach RFC 1918 space, which matters for one of the probes below.

Throughput and latency

We also ran the Worker under wrangler dev because that is what most people reach for first. It is a development server with a proxy layer and logging in front of workerd, and the numbers show it. Treat the bare workerd column as the fair one.

corsproxy 1.2.6 Worker on workerd Worker on wrangler dev
50 clients, req/s41,9926,4561,130
50 clients, p50 / p991 ms / 4 ms7 ms / 34 ms40 ms / 133 ms
200 clients, req/s55,4285,878593
200 clients, p50 / p993 ms / 13 ms27 ms / 167 ms258 ms / 1,224 ms
Failed requests000
Memory, idle1.5 MiB48 MiB420 MiB
Memory after 200-client run23 MiB277 MiB1.6 GiB
Image size14.6 MB~300 MB~300 MB

The upstream alone did 117,000 req/s on the same harness, so corsproxy costs roughly half the raw throughput and the Worker costs about 95 percent of it. The gap between the two proxies is 7 to 9x in requests per second and about 12x in memory.

Two caveats we want on the record. First, this measures the workerd runtime on one box. On Cloudflare's edge the same Worker runs in hundreds of locations and scales horizontally; nothing here says anything about how many requests Cloudflare can absorb, only how much one copy of the runtime costs per request. Second, the corsproxy number is new. Version 1.2.5 built a fresh http.Transport per request, which meant no upstream connection reuse and a slow socket leak; on this same harness it managed 484 req/s at 200 clients with 80 percent failures and a gigabyte of memory. One shared transport in 1.2.6 is the entire difference. Benchmark the version you deploy.

Seven security probes

Same requests, sent to both. For corsproxy, probes 1 and 2 hit a container started with no environment variables at all, because that is what someone gets from docker run with nothing else.

Probe corsproxy Cloudflare example Worker
1. Target on a private network, default config403200, body returned
2. Upstream answers 302 to 169.254.169.254403, redirect refused302 passed to the client, metadata URL in Location
3. Client sends Authorization and Cookieneither reaches the targetboth forwarded
4. Origin: https://evil.example, allowlist set200, Allow-Origin is the allowlisted origin200, Allow-Origin is the Worker's own origin
5. DELETE200, forwarded405
6. Upstream body of 20 MB502, 10 MB cap200, all 20 MB streamed
7. Seven requests, limit set to 5 per minute200 x5, then 429200 x7, no limiter exists

Reading the table

Rows 1, 2, 3, 6 and 7 are not close. As shipped, the example Worker fetches whatever URL is in apiurl, hands the response to whoever asked, and forwards the caller's bearer token and session cookie to that URL on the way. The docs page is clear that it is an example, and the code is good at what it demonstrates, which is how to rewrite CORS headers. It just does nothing else. Row 1 is softened in production because Cloudflare's edge cannot reach private address space, but any public host is fair game, including ones that look internal to the person running it. Row 3 is the one we would fix first: a page that calls the proxy with credentials: "include" has just sent its cookies to a URL chosen by the query string. How a CORS proxy works, and how it can be abused covers why that matters.

Row 4 is a tie in a browser. Both proxies answered the evil origin with an Access-Control-Allow-Origin value that does not match it, so a browser would block the read either way. Note what the Worker does, though: it sets the header to its own origin, not to the caller's. That means the example only works for pages served from the Worker itself, which is the demo page in the same script. It is less an open CORS proxy than a same-origin fetch helper with CORS headers attached. And neither proxy refused the request at the HTTP level; both returned the body to curl. Origin checks are a browser contract, not authentication. If you need to stop non-browser clients, that is what corsproxy's ALLOWED_HOSTS and REQUIRE_API_KEY are for.

Row 5 goes to the Worker. It allows GET, HEAD, POST and OPTIONS and returns 405 for everything else. The open-source Go proxy forwards any HTTP method by design, because self-hosters pointed it at their own REST APIs and asked for PUT and DELETE. If you only ever need reads, the Worker's stricter default is the better one, and on corsproxy you would get the same effect by putting the proxy behind nginx with a method check.

What this means for picking one

If you deploy on Cloudflare because your users are everywhere and you do not want to run a server, the Worker example is a fine starting point and a bad finishing point. Budget an afternoon to add the five missing rows: a host allowlist, a private-address check on the resolved target, stripping of Authorization and Cookie, a body cap, and rate limiting (Cloudflare's own rate limiting rules or a Durable Object both work). Each is a few lines. Not adding them is how a tutorial becomes an open relay with your account's name on it.

If you run your own box, corsproxy does those five things out of the container and tells you at startup which protections you have left off. It is also, on this harness, an order of magnitude cheaper per request. The limit is the obvious one: it lives in one place, so a user on the other side of the planet pays the round trip to your region on every call. Self-host vs managed CORS proxy walks through when that trade is fine and when it is not.

The managed service at corsproxy.dev is the third option: the edge deployment model with the security table already filled in, an origin lock per API key, and a documented API. When not to use a CORS proxy is worth reading before choosing any of the three.

Reproduce it

The upstream server, the workerd config, and the probe script total under 100 lines and live in the bench directory of the corsproxy repository, with the Worker source copied in unmodified. The short version:

docker network create bench
docker run -d --name up  --network bench bench-upstream
docker run -d --name px  --network bench ghcr.io/melihbirim/corsproxy:1.2.6
docker run -d --name cfw --network bench cf-workerd

# load
docker run --rm --network bench httpd:2.4-alpine \
  ab -k -n 40000 -c 200 'http://px:8080/?url=http://up:9000/'

# probe 3: do credentials leak to the target?
docker run --rm --network bench curlimages/curl \
  -s -H 'Authorization: Bearer SECRET' -H 'Cookie: s=SECRET' \
  'http://cfw:8787/corsproxy/?apiurl=http://up:9000/echo'

If you get different numbers, tell us. Different hardware will move the absolute figures; it should not move the ratios much.