Benchmarks: what a request costs, and what made it cheaper
The only page with the numbers: CPU per request on Cloudflare, from Workers Logs. Workers Free allows 10 ms.
The two notes Workers, side by side
Measured 2026-10-02, 30 requests each, CPU time from Workers Logs:
| Operation | TypeScript | Go |
|---|---|---|
| hello | 0 ms | 0 ms |
| list (a D1 read) | 0 to 1 ms | 1 ms |
| create (a D1 write and a hub publish) | 1 ms | 1 to 2 ms |
| The first request in a new isolate | 7 to 11 ms | |
| The first list or create in a new isolate | 6 to 33 ms (on 2026-10-03: list 11, create 6) | |
| A request of a burst that starts its own Go runtime, several at once | about 100 ms | |
| An SSE stream of 15 to 60 seconds, over its life | 40 to 150 ms |
The Go conformance Worker (every Fern feature, a bearer token checked on every request), 2026-10-03: list about 1 ms, create about 1 ms, a 404 0 ms. Its TypeScript twin had no database then, so it was not compared; both keep their notes in D1 now, and have not been measured since.
The Wasm’s size
The notes Worker’s build/app.wasm, gzipped, TinyGo 0.42, 2026-10-03, when its JWT library was chosen (the limit is 3,000,000 bytes):
| Build | Gzipped |
|---|---|
| Bearer tokens only (before Access and OIDC) | 862,587 B |
| With Access and OIDC, JWTs verified by go-jose v4.1.5 (what is used) | 1,198,297 B |
| The same with golang-jwt v5.3.1, keyfunc v3.8.2 and jwkset v0.11.3 instead (tried) | 1,273,945 B |
Both libraries passed the same tests under workerd. go-jose was kept: smaller, no dependencies of its own, keys fetched only in a request (keyfunc keeps them fresh from a goroutine of its own, which a Worker cannot run between requests; without it, they are never fetched again).
Building the Worker
How long a fresh TinyGo build of the notes Worker takes (examples/notes-go, past charter’s cache, TinyGo’s own caches warm, on an Apple M2 Pro with 16 GB), and what it makes, 2026-10-15:
| TinyGo | Optimization | Build | build/app.wasm | Gzipped |
|---|---|---|---|---|
| 0.42.0 | -opt=z (what charter uses) | 21.8 s | 3.28 MB | 1.20 MB |
| 0.42.0 | -opt=s | 38.9 s | 3.53 MB | 1.26 MB |
| 0.42.0 | -opt=2 | 45.9 s | 4.17 MB | 1.48 MB |
| 0.42.0 | -opt=1 | 40.8 s | 4.39 MB | 1.50 MB |
| 0.42.0 | -opt=0 | fails | ||
| 0.43.0 | -opt=z (what charter uses) | 22.9 s, 23.0 s | 3.81 MB | 1.38 MB |
| 0.43.0 | -opt=s | 39.8 s | 4.30 MB | 1.49 MB |
| 0.43.0 | -opt=2 | 48.3 s | 5.23 MB | 1.79 MB |
| 0.43.0 | -opt=1 | 43.1 s | 5.36 MB | 1.76 MB |
| 0.43.0 | -opt=0 | 22.2 s | 17.22 MB | 7.47 MB (over the 3 MB limit) |
The smallest build is also the quickest: most of a build is wasm-opt’s asyncify pass, and it takes longer the bigger the Wasm. So there is no faster build for local checks to switch to; a build is quick when charter’s cache has it (the same sources make the same Wasm). TinyGo 0.43.0 builds in the same time as 0.42.0 and makes a Wasm 15 % bigger. Its first build after the upgrade took 38.7 s, while TinyGo filled its own caches.
The Worker as a deploy uploads it
What charter build makes of each notes Worker (charter check measures it), every module it uploads, 2026-10-29 on macOS (arm64), TinyGo 0.44.0:
| Worker | Bundle | Gzipped |
|---|---|---|
examples/notes-ts (esbuild, minified) | 1.32 MB | 0.27 MB |
examples/notes-go (the Wasm is 1.36 MB of it, gzipped) | 3.79 MB | 1.36 MB |
Workers Free allows 3 MB gzipped, Workers Paid 10 MB.
What closed the gap
The Go Worker’s median CPU, 2026-10-01 and 2026-10-02, on TinyGo 0.42 (step 3’s patch is gone: TinyGo 0.43.0 frees finished goroutines’ stacks itself; not measured again since):
| Step | hello | list | create |
|---|---|---|---|
| TinyGo and workers-go as they come | 55 ms | 71 ms | about 265 ms |
| 1. The collector no longer runs at every pause: a runtime patch, an 8 MB heap | 6 to 19 | 8 to 29 | 61 to 66 |
2. Go runtimes reused between requests (worker/go.mjs) | 3 | 7 | 10 to 13 |
| 3. Goroutine stacks reused: a second patch | 1 | 3 | 4 |
| 4. One crossing into Go and one out; D1 rows as one JSON string; the hub published to by RPC | 0 | 1 to 2 | 2 |
| 5. Two runtimes started and warmed while the module loads (a new isolate’s first request) | 7 to 11, was 90 to 170 |
Tried and dropped: -gc=boehm (hung on Cloudflare), -gc=leaking (a stream’s memory only grows), -opt=2, -opt=s (larger, no faster), a 32 MB heap (slower), unlimited runtime reuse without stack reuse (out of memory).
Left: the first use of each operation in a new isolate (#22), whose handler needs the database a starting runtime lacks.
Each difference alone, measured by anyone
charter build’s switches turn each optimisation off: -reuse=false (a Go runtime per request, as workers-go’s own entry), -patches=false (TinyGo as it ships), -heap=0 (TinyGo’s default heap). charter bench measures the CPU a local workerd spends per request, from the operating system’s count, in repeated runs. No Cloudflare account is needed, so a comparison can be checked by anyone on their own machine (Libraries).
go-htmx4 is a Go Worker that is not built from charter: htmx 4 and gsxui pages of 50 to 170 KB, 13 locales, the server’s Intl, a live board on D1 and a Durable Object. mise run bench:variants builds its current code once per switch combination, measures each, and writes the table to its docs/benchmarks.md. Its CI runs the shipped build’s half on every change.
On Cloudflare (Workers Logs, 2026-10-20, Worker Previews of one app sent the same requests in turns), the same comparison, median CPU per request in ms:
/healthz | /de/ | /board | board write | /de/formats | |
|---|---|---|---|---|---|
| A runtime per request, TinyGo as it ships | 20 | 35 | 58 | 70 | 124 |
| C runtimes reused, TinyGo as it ships | 15 | 53 | 25 | 67 | 127 |
| D runtimes reused, TinyGo patched (what charter ships) | 4 | 19 | 22 | 49 | 80 |
A runtime kept between requests saves the cost of starting Go, but without the patch it collects at every await over a heap that is no longer new, so a heavy page gains nothing (C); with the patch, it does (D). One run on Cloudflare moves 10 to 20 ms a cell; the local table is the one to check against.