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.
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/s | 41,992 | 6,456 | 1,130 |
| 50 clients, p50 / p99 | 1 ms / 4 ms | 7 ms / 34 ms | 40 ms / 133 ms |
| 200 clients, req/s | 55,428 | 5,878 | 593 |
| 200 clients, p50 / p99 | 3 ms / 13 ms | 27 ms / 167 ms | 258 ms / 1,224 ms |
| Failed requests | 0 | 0 | 0 |
| Memory, idle | 1.5 MiB | 48 MiB | 420 MiB |
| Memory after 200-client run | 23 MiB | 277 MiB | 1.6 GiB |
| Image size | 14.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 config | 403 | 200, body returned |
| 2. Upstream answers 302 to 169.254.169.254 | 403, redirect refused | 302 passed to the client, metadata URL in Location |
| 3. Client sends Authorization and Cookie | neither reaches the target | both forwarded |
| 4. Origin: https://evil.example, allowlist set | 200, Allow-Origin is the allowlisted origin | 200, Allow-Origin is the Worker's own origin |
| 5. DELETE | 200, forwarded | 405 |
| 6. Upstream body of 20 MB | 502, 10 MB cap | 200, all 20 MB streamed |
| 7. Seven requests, limit set to 5 per minute | 200 x5, then 429 | 200 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.