Benchmarks, Rate Limits, and What Eight Phases Actually Bought
Both backends are live on the same VPS now: the original Python/FastAPI app still in production, the Go rewrite on its own subdomain with its own SQLite database and no shared data. After eight phases of porting core CRUD, it was time to measure the difference instead of asserting it.
The first attempt got blocked, and that was correct
I tried to run a real load test — 2000 requests, 50 concurrent — straight against the live production Python app's public HTTPS URL. It never ran. Not because I thought better of it mid-command, but because the coding assistant's safety layer refused: hammering a live service that real users share a box with is not something a benchmark gets to authorize itself into just because the URL is reachable.
Worth sitting with that for a second rather than treating it as an annoyance to route around. "It's live and I can reach it" is not the same permission as "I can load-test it at will."
I retried with much lighter parameters — 200 requests, 10 concurrent — against the local container ports directly instead of the public domains. Enough to see the shape of the difference. Not enough to risk anything on a shared box.
Health check: the least interesting, most trustworthy number
The cheapest possible endpoint: a health check. No database query, just a JSON literal and a log line.
Go handled about 5,012 requests/second at an average 1.8ms latency. Python handled about 1,479 requests/second at an average 6.6ms latency. Roughly 3.4× more throughput, roughly 3.7× lower average latency.
That's the least interesting endpoint in the whole API — which is exactly why it's the most trustworthy number in the set. Nothing about database contention or payload size to muddy it. This is the plain HTTP-serving layer, isolated.
The realistic endpoint, and why I left the asterisks in
I tried a second comparison against a public read-only endpoint that actually queries the database: the vault's list-of-published-pages endpoint. This ran into two separate real-world snags instead of a clean number.
First: comparing it fairly against production would have meant creating a benchmark user account and test documents on the real Python app — writing real rows into the real production database just to have something to query. I skipped that for data-safety reasons, same instinct as the earlier load-test block.
Second, the funnier one: after seeding the Go side with 5 test published pages and re-querying it, the result looked suspiciously tiny — "1 page."
It turned out the earlier aggressive benchmark run had already tripped the endpoint's own rate limiter (120 requests per 5 minutes per IP, a feature ported faithfully from the Python app back in an earlier phase). And a quick one-line Python script analyzing the response had a bug of its own — it counted the keys of a {"detail": "too many requests"} error object as if it were a list of pages, silently misreporting a 429 rate-limit response as "1 result" instead of catching the actual error.
Caught by actually looking at the raw response instead of trusting the summary number. I left the honest, asterisked version of that comparison in rather than re-running it after quietly loosening the rate limit just to get a cleaner-looking number. The rate limiter working as designed is part of the result, not an obstacle to a prettier chart.
Image size, memory, binary weight
The other numbers measured cleanly:
| Metric | Go | Python |
|---|---|---|
| Docker image | 30.6 MB | 313 MB |
| Idle memory | ~24.8 MB | ~77.4 MB |
| Memory under light load | ~40.5 MB | ~94.6 MB |
| Standalone binary | 13 MB | — |
Over 10× smaller image, a direct consequence of two specific decisions — a pure-Go SQLite driver with no C dependencies, and a scratch-based final image with literally no operating system — rather than a vague "compiled languages are smaller" claim. Idle footprint about a third of Python's; under the light health-check load, still roughly half. The 13 MB binary outside any container is a nice side effect of the same stack choices.
What this is not saying
This compares an early-phase, core-CRUD-only Go rewrite against a mature, fully-featured Python app that's still running its AI integration, knowledge pipeline, Telegram bot, and live-collaboration relay — none of which the Go side has built yet. Those were deliberately deferred from day one, to keep each concurrency-heavy feature as its own dedicated lesson rather than rushing it in next to basic CRUD.
This isn't a "Go beat Python" verdict. It's a snapshot of what the plain HTTP-serving layer looks like with a dramatically smaller dependency footprint — exactly the part of the stack this whole rewrite was aimed at in the first place. The genuinely interesting follow-up, if this project continues, is whether the gap holds up once the Go side is carrying the same feature weight the Python side already does.
Eight phases, two real bugs, one deliberate empty PR
Zooming out: eight phases. Two real bugs caught by insisting on testing against something real instead of trusting code that merely looked correct — a silently-truncated SQL string from a code generator, and a scrypt key-length default that only self-consistent tests couldn't have caught. One deliberate case of writing nothing rather than writing untested code. And a live side-by-side deployment that took real HTTP requests over real HTTPS by the end.
The whole thing stayed small enough to actually finish, which was the point as much as any of the numbers were.