Skip to main content

The nest

What does the language actually change?

One product, built four times — Python, C#, Go and TypeScript — with the front end and the database held identical each time and the permission model built the same way in each: one key differs, and the caveats say which. This is the standing record of that experiment: what was measured, how, where the languages diverged, where they didn’t, and what nobody has run yet.

They’re named after birds, which is how a comparison ended up being called a nest. Each one is the same access-control console: the tool an administrator uses to decide who inside a company can reach what, see who’s signed in right now, and cut somebody off mid-session. A real product with a hard middle, not a benchmark harness written to be fast.

Everything that differs between them differs because of the language. That’s what makes this an experiment rather than four anecdotes, and it’s why the list of things held constant comes before any number does.

Nothing here is estimated. Every figure came out of a run, and the run is published.

01 · the setup

One variable, and a long list of things that aren’t.

Same product, same front end, same database engine and image digest, same endpoint, same machine, same day. The backend language and its runtime are the only things that move.

Held constant

  • the same product: one access-control console, built four times
  • the same database engine, image digest and schema
  • the same endpoint, request shape, warm-up, request count and concurrency
  • the production target of each repository's own Dockerfile, unmodified
  • one server process per implementation, as each Dockerfile's prod stage ships it

Varied

  • the backend language and its runtime, and nothing else

The measured endpoint is /api/auth/me, present at that exact path in every one of them. It verifies the session token, reads the user and the session row under a row lock, writes a presence touch and assembles the caller’s effective permission set — a read, a lock and a write, which is why it is the one worth timing. Every implementation’s version of it was read before the column was trusted, to confirm they do equivalent work on identical indexes.

What each one is, and what it ships as. Language and toolkit are written down rather than measured — what each repository builds against, as I record it. The base image and the process come from the measured run: whatever that repository’s own production Dockerfile builds and runs, unmodified. An implementation that is on the roster but has not been built yet says so in the last two columns rather than being left out of the list.
ImplementationLanguageToolkitBase imageProcess
KingfisherPython 3.13FastAPI 0.139Chainguard Wolfi + CPython 3.13uvicorn, one worker (the prod Dockerfile's own CMD)
SharpbillC# / .NET 10ASP.NET Coremcr.microsoft.com/dotnet/aspnet:10.0 noble-chiseled-extraone Kestrel process
GoldfinchGo 1.26Chi 5.2, database/sqlgcr.io/distroless/static-debian12:nonrootone static binary
MerlinTypeScriptNestJS 11, published OpenAPI v1node:24-alpineone Node process (the prod Dockerfile's own CMD)

02 · the measurements

The results.

Taken on 2026-07-27. Every figure came out of a script; none was typed by hand, and none was estimated to fill a column.

One machine, one shared MySQL container, each repository’s own production Dockerfile unmodified, one server process each. Lines of code counts hand-written application source and excludes tests, migration content and generated files. Cold start is the median of ten runs, container start to first successful health response, with the container tooling’s own overhead excluded. Both latency columns are the 95th percentile of the same endpoint over the same request count, at one caller and at four — published as two columns because a p95 with no stated concurrency is decoration. The full method, the environment, the caveats and every per-run figure are in four-builds.json.
ImplementationLanguageLines of codeImage sizeCold startp95, one callerp95, four callersThroughput, four callers
KingfisherPython7,200664MB518.3 ms9.9 ms52.3 ms103 req/s
SharpbillC#20,226263MB276.5 ms4.4 ms4.7 ms1,019 req/s
GoldfinchGo14,94262.4MB84.1 ms2.8 ms2.6 ms1,807 req/s
MerlinTypeScript12,775490MB581.5 ms3.0 ms9.7 ms545 req/s

The language column deliberately carries no version — the versions are in the table above this one, where they are not sitting next to a digit. Every cell has the full method behind it on hover, and in text further down, so a reader can argue with any figure here.

What an empty cell means here — that the measurement has not been taken, and nothing else. A row stays on this page from the moment the implementation exists, with the columns nobody has run reading as gaps, because dropping the row until it is complete would make the experiment look smaller than it is and this table look more finished than it is. There is no arrangement of this page in which a number appears without a run behind it.

03 · the differences

What the language changed.

Each number is how many times the largest is the smallest, across everything measured on that axis. The ratios are the transferable part; the absolute figures belong to one machine on one day.

2.8×Lines of code

7,200 (Kingfisher, the fewest lines) to 20,226 (Sharpbill, the most lines). Measured as hand-written application source, tests and generated files excluded.

14×Image size

13 MB (Goldfinch, the smallest image) to 182.6 MB (Kingfisher, the largest image). Measured as compressed, which is what a registry transfers — the cell above shows the uncompressed size on disk.

6.9×Cold start

84.1 ms (Goldfinch, the fastest start) to 581.5 ms (Merlin, the slowest start). Measured as median of ten container starts to the first successful health response, with the container tooling’s own overhead excluded.

3.5×p95, one caller

2.8 ms (Goldfinch, the fastest alone) to 9.9 ms (Kingfisher, the slowest alone). Measured as one caller at a time, so nothing contends with itself.

20×p95, four callers

2.6 ms (Goldfinch, the fastest under four) to 52.3 ms (Kingfisher, the slowest under four). Measured as four concurrent callers on the identical query plan, contending on the same row.

17×Throughput, four callers

103 req/s (Kingfisher, the lowest throughput) to 1,807 req/s (Goldfinch, the highest throughput). Measured as sustained across the measured phase at four callers, sized under the rate limit all of them enforce.

The two latency columns are the finding. The permission check takes its row locks on the same row and writes a presence touch, in all of them, by design — so concurrent callers contend with each other on purpose. Measured one caller at a time, these runtimes are much closer together than the argument about them suggests. Give each of them four callers on the identical query plan and the spread widens to more than an order of magnitude. That is not a fact about languages; it is a fact about what contention does to each of these runtimes, and it is invisible to any table that publishes a single p95 and doesn’t say how many callers produced it.

What four callers did to each one’s p95, as a multiple of its own single-caller figure: Kingfisher 5.3×, Sharpbill 1.1×, Goldfinch 0.9×, Merlin 3.2×.

And not everything speeds up when you add callers. Kingfisher served fewer requests per second at four callers than at one: more concurrency, less work done. The others all went faster. That is the single most useful thing on this page for anyone sizing one of these, and a benchmark run at one concurrency level would have reported the opposite.

What a ratio here is worth, and what it isn’t. The image-size spread is a real cost paid on every deploy and a real saving in storage. The latency spread, measured inside a container network, is for most of these systems still far smaller than the network between a browser and the server. Both are true at once, and a comparison that reports the second without the first is selling something.

The thing that differed most isn’t in any column. It was how much each ecosystem was willing to do on my behalf without telling me. That is a preference worth holding consciously rather than by accident, and it is the one comparison here that no benchmark was ever going to settle.

04 · the sameness

Where it changed nothing.

The uninteresting results, published for the same reason the interesting ones are: leaving them out is how a comparison turns into an argument for whatever the author already preferred.

Every request behaved

4,000 measured requests across every implementation and both concurrency passes. Every single one returned HTTP 200 with the caller’s permission set resolved — no errors, no rejected requests, nothing retried. Whatever the runtimes did differently, they did the same job to the same standard, and the load generator never once had to decide what to do about a failure.

The same answer, down to the key

The endpoint assembles the caller’s effective permission set, and every implementation assembled it on every request: 10 keys in Kingfisher, Sharpbill and Goldfinch; 11 keys in Merlin.

The odd one out is a schema difference rather than a runtime one — one of them carries a permission key the others don’t, so it resolves one extra row in a join and one extra string in the payload. It is recorded in the caveats rather than corrected, because correcting it would have meant editing one of the repositories to flatter the measurement.

The hard part didn’t move

The finding that needs no number, and the one I’d put first if I could only keep one: modelling who may do what to whom was the hardest part every single time, and it was hard for the same reasons every time. None of those reasons were language reasons. Where the bugs were, and which part took the longest, did not move when the runtime did.

That is also the most useful thing here for anyone deciding what to hire for. If the difficulty lives in the domain rather than in the runtime, then what transfers between these stacks is most of the job — which was not what I expected going in, and it is the conclusion I would have been least likely to reach by reading arguments about it online.

Closer than the argument

Asked to serve one caller at a time, Goldfinch, Merlin and Sharpbill land within 1.6 ms of each other at the 95th percentile. On that axis, on this endpoint, the choice between them is not a performance decision. It becomes one under contention, which is why both passes are published rather than the flattering one.

05 · the judgement

What each one was like to build.

Opinions, written before anything was measured, and kept in a separate table for exactly that reason. No benchmark was ever going to settle either column.

The judgement half: what each implementation was best at, and what it cost to build and live with. These are my own verdicts and they are not measurements — the measured comparison is the table further up this page.
ImplementationLanguageToolkitWhat it was best atWhat it cost
KingfisherPython 3.13FastAPI 0.139Fastest to a working endpoint. Async throughout, durable security events, a terminal-styled event stream I regret nothing about.The original, and therefore the one carrying every early design mistake. The one where I leaned hardest on tests to replace what a compiler wasn't telling me.
SharpbillC# / .NET 10ASP.NET CoreEasiest to change six months later. Cleanly layered — contracts, domain, application, infrastructure, workers, middleware — plus Playwright end-to-end tests, SBOM generation, Trivy scanning and CODEOWNERS wired into CI. The one I'd hand to a team of five without worrying.The most ceremony of the four, and the slowest to first light.
GoldfinchGo 1.26Chi 5.2, database/sqlSmallest image, fastest start, fewest surprises in production. No ORM, no framework acting on my behalf. Google and Microsoft OIDC verified server-side, presence over WebSockets with a polling fallback, retention and erasure controls, one small container.No framework acting on my behalf means every mechanism is mine to write and mine to keep working. It felt like the most code of the four. It was not.
MerlinTypeScriptNestJS 11, published OpenAPI v1TypeScript from the database driver to the browser, the same RBAC on every protected request, and the identical React SPA on top. Genuinely pleasant.Right up until you want a guarantee the type system can't give you.
Where the measurement changed my mind My verdict on Goldfinch used to say “Most lines of my own code, by a distance.” The count disagreed: on lines of code, Sharpbill leads with 20,226 against Goldfinch’s 14,942 — 5,284 lines between them, putting Goldfinch second of four rather than first. I rewrote the verdict on 27 July 2026. The old one is quoted here because a corrected page that hides what it corrected is just a page claiming it was never wrong.

The thing worth saying about that one: it was wrong in the direction vanity would predict. Writing every mechanism by hand feels like volume, because you see each line arrive. A framework writes just as much and shows you none of it. I would have argued for that sentence in a room, confidently, and I would have been wrong — which is the entire argument for measuring instead of remembering.

The rest of the verdicts stand unmeasured, and I would rather you read them knowing that one of them already fell over.

06 · the method

How each column was measured.

Published beside the results, because a figure nobody can check is not evidence.

Lines of code

Hand-written source in the implementation's own language under its backend source roots. Counted: application code the server runs. Excluded: tests, database migration revisions and DDL, generated files (OpenAPI documents, build output), dependency manifests, lockfiles, vendored dependencies, and the React front end, which is identical in all four and held constant. Migration RUNNERS are counted, because how much of one each language forced its author to write is a real difference; migration CONTENT is not, because it is the same schema restated four times and Sharpbill's is written in Python, which would put Python lines in the C# row. The headline figure is code lines: physical lines minus blank lines minus comment-only lines. Blank and comment totals are published alongside it.

Image size

docker build --target prod against each repository's own backend Dockerfile, unmodified. On-disk is the uncompressed size Docker reports; compressed is the content-store size a registry would transfer.

Cold start

The container is created once, which is not timed. Then ten times: start it, poll GET /api/health/live every 5 ms over the published port, stop it. Two figures come out of the same ten runs. medianApplicationOnly is measured from docker start returning — the application's own share, and the figure the pages print. median is measured from immediately before docker start is invoked, so it also carries the Docker CLI and daemon overhead, which is common to all four. /api/health/live is exempt from rate limiting in all four, so polling cannot distort it. The database was already running and already migrated.

p95, one caller · p95, four callers · Throughput, four callers

GET /api/auth/me — the permission-check endpoint, present under that exact path in all four. It verifies the session JWT, reads the user and the session row under SELECT ... FOR UPDATE, writes the presence touch, and assembles the caller's effective permission set. Verified by reading all four: they do equivalent work, and the user_sessions table on that path carries identical indexes in all four schemas. 100 warm-up requests, then 500 measured requests at concurrency 4, closed loop, keep-alive per worker. Every implementation caps /api at 600 requests per minute per IP, so the measured phase is sized under that and begins after the window rolls over. All 500 returned HTTP 200 with the permission set resolved in every run; a concurrency-1 pass is published beside it because those FOR UPDATE locks are taken on the same row, so concurrency-4 alone cannot separate per-request cost from lock contention.

Where it ran

Host
Windows 11, Docker Desktop, containerd image store, Linux containers
Database
one MySQL 8.4.10 container shared by all four, the same digest-pinned image every repository's compose file specifies, one schema per implementation
Network
all four on one user-defined bridge network
Load generator
python:3.13-alpine running inside that same network, so Docker Desktop's host port-forwarding is not in the measured path

The load generator runs inside the same network as the servers, so the desktop container runtime’s host port-forwarding is not in the measured path. The database was already running and already migrated before any timing started.

07 · the gaps

What has not been run.

Stated as absences rather than as a plan. Nothing here has been promised, and a roadmap dressed up as a caveat is just another way of overclaiming.

Memory, at rest or under load
Nothing in the run recorded a resident set size, so the page has no business ranking these implementations on the resource engineers argue about second-most. It is the most obvious next column and it needs its own pass.
Anything that writes
The measured endpoint reads a user and a session and writes one presence touch. Creating a user, editing a role, revoking a session — the paths where a language’s concurrency model would actually show — were not load-tested at all.
A saturation point
Every one of them caps /api at 600 requests per minute per IP, and the run was deliberately sized under that cap so no request would be rejected. So none of these figures is a ceiling: they say what each one did at four callers, not what it would do at four hundred.
More than one process
Each implementation ran exactly one server process, as its own production Dockerfile ships it. Several of these runtimes are designed to be scaled by running more of them, and that is precisely the thing this run holds constant and therefore cannot see.
Sustained behaviour
Five hundred requests is a burst, not a soak. Garbage-collection pauses, connection-pool churn and memory growth all live past the end of this run, and the p99 column would be the first place they showed up.
A second machine
One host, one day. The ratios are the argument and they would survive a move; the absolute figures would not, and neither would any conclusion that leaned on a few milliseconds.
Another language
Nothing beyond the roster above has been built yet. When something is, it becomes a row here — measured by the same script under the same method, or it does not appear at all. A row with an estimate in it would falsify the only claim this page makes about itself.

And what limits the figures that are here

  • One machine, one run each, not a multi-host average. Absolute numbers would move on other hardware; the ratios are the point.
  • Kingfisher's schema head predates the other three: it is missing the secure-signup and query-index revisions they carry. Those indexes are on the user-directory queries, not on the measured endpoint, and the table it does use is index-identical in all four.
  • The four builds are not perfectly identical products. Merlin's schema carries one permission key the other three do not (users.sensitive.read), so its response resolves 11 permissions where theirs resolve 10 — one extra row in a join and one extra string in the payload. It is recorded rather than corrected because correcting it would mean editing one of the four repositories to flatter the measurement.
  • Latency was measured from inside the container network. A browser on the internet would add far more than the differences shown here.
  • The four repositories are private, so these numbers cannot be reproduced by a reader from source. The method is published so the reasoning can be argued with even when the code cannot be read.

How the next one lands here

It is one entry in a list, and then a run. A new implementation appears in every table on this page the moment it exists, with each column it hasn’t been measured on saying so, and each of those cells fills in as its measurement arrives. Nothing about this page has to be redesigned to hold it, and no figure will ever appear in it that a script did not produce — an estimated row would falsify the only claim this page makes about itself, which is that none of its numbers are estimated.

Argue with the method

Tell me where this is wrong.

The numbers are only as good as the method, and the method is on this page so it can be attacked. If you think the endpoint is the wrong one to time, that the concurrency is unrepresentative, or that a column is measuring something other than what it says — that is a better conversation than the table itself, and the record is published so you can have it with the evidence rather than with me.

And if there is a language you’d want to see in here, say which one and which of these you’d put it against. This started because everyone has opinions about programming languages and almost nobody has paid for one; I’d still rather settle an argument by building the thing than by having it.