Ballerina is a language built by WSO2 for building microservices and integrations. It has a standard library with HTTP and JSON binding, and a concurrency model that reads like normal code. On paper that sounds great. I wanted numbers though.
I used Claude Code to plan and build the same backend in 7 different stacks.
- Go
- Java with Javalin
- Rust
- Ballerina
- Python with FastAPI
- Node with Express
- Bun with Elysia.
Ballerina runs on the JVM, and I kept wondering how much of its memory and startup cost is just JVM overhead versus something Ballerina itself does. A plain Java service on the same JVM, with no Ballerina-specific runtime on top, is the closest thing to a control group I can build.
All the code and raw measurements are public in sahithyandev/ballerina-comparison. If you want to run the measurements yourself, clone the repo and follow the instructions.
# The backend
Every stack implements one API. A small blogging platform with users, posts, and comments. Register, log in, CRUD on posts, comments under posts, JWT bearer auth with ownership checks on writes, pagination, structured 400s on bad input, and a consistent error envelope.
There is one shared openapi.yaml and one shared schema.sql. Persistence is
file-based SQLite, and every stack uses the same schema and the same seed data.
DELETE /posts/{id} cascades to comments through a foreign key.
GET /posts/{id} fans out 3 lookups (the author, the comments, the comment
authors) and joins them, so every stack has to express concurrency somehow.
POST /posts calls a local profanity-check stub over HTTP with a 2s timeout and
falls open if the stub is slow.
Each stack runs as one plain process with no container, no extra worker processes, and no connection-pool tuning. The only concurrency is what the runtime adds on its own.
# Lines of code
Node writes the least hand-written code of the 7, 546 lines, with Ballerina right behind it at 548. HTTP, JSON binding, JWT, and bcrypt are all in Ballerina’s standard library, no dependency required. Java lands at 867, heavier than Ballerina but still 81 lines under Go.
Rust is the outlier at the top. 1039 hand-written lines. A lot of that is hand-written row mappers and DTO conversions, work that pydantic, TypeBox, and the generated Go, Java, and Ballerina types just do for you.
Go, Java, and Ballerina generate code from the shared openapi.yaml. The other
4 route and bind by hand.
Go’s 948 hand-written lines undersell it. 800 more come out of oapi-codegen,
including a routing interface. Java’s openapi-generator emits 558 lines of
request and response DTOs, and Ballerina’s generator is the leanest of the 3,
only the record types, 131 lines.
# Dependencies
The other half of the code question is what you did not write.
Ballerina needs 1 direct dependency, a SQLite connector, and nothing else. Everything the API touches ships in the standard library.
Node gets to a similar hand-written line count but pulls 3 direct dependencies and 80 transitive packages to do it. Rust sits at 196 transitive crates, because the async runtime, HTTP client, TLS, and JSON parser are all separate. Java needs 8 direct dependencies for HTTP, JSON, JWT, bcrypt, SQLite, and logging, about the same as Go’s 7. Its 25 transitive dependencies stay just as lean.
If small codebases and short dependency lists matter to you, the answer is Node or Ballerina. That counts in Ballerina’s favour.
# Build and startup
Build times delete each stack’s build cache first, so they are cold. Registry and package caches stay warm.
Rust’s build is the slowest, 30.5s. Most of it goes to compiling SQLite and TLS from source. It does produce the smallest artifact here, a 7.7M binary.
Python is the slowest to start, 1721ms, loading the interpreter and the FastAPI
import chain. Ballerina is next at 1178ms, because bal run boots a JVM before
it serves anything. But Java runs on that same JVM, from a fat jar. It starts in
613ms, faster than Go. That points away from the JVM. Something in Ballerina’s
own boot path costs the extra half second. The standard library covering this
whole API doesn’t change that.
Bun builds in 30ms and starts in 141ms, which makes the edit-run loop feel instant.
# Memory
I sampled resident set size twice. Once idle just after startup, then again as the peak during a 10s burst of 50 concurrent reads.
Rust and Go barely move. 11 to 19M for Rust, 21 to 33M for Go.
Ballerina is in a different weight class. 184M idle, 948M under a load every other non-JVM stack absorbed in under 150M. That is roughly 50 times Rust’s peak. Java, the other JVM stack, sits at 115M idle and 417M under load, a fraction of Ballerina’s number. Same JVM. That gap belongs to Ballerina’s own runtime, not the platform under it. On a workload this small, neither stack has a reason to spend that memory. This was the sharpest mark against Ballerina in the exercise.
# The load test
I ran GET /posts/{id} for 30s and POST /posts for 10s against each stack
with hey, same machine, same database, same fixtures.
Bun wins reads by a lot. 15274 req/s, roughly double Node and more than 5 times Ballerina. Java edges out Go on reads, and lands fourth on writes, behind Go, Rust, and Bun. Python is last on both routes. Its write number is 271 req/s. A single uvicorn worker serializes every SQLite call behind a lock.
Go and Ballerina are the only 2 stacks where the GET /posts/{id} fan-out
actually runs in parallel against the database. Each has a real pool and no
single lock in the way. Node and Bun run the same 3 lookups on one thread with a
synchronous SQLite driver. Nothing is parallel. And they win the route anyway.
Parallel database access should be faster. That is the point. But on a fan-out of 3 tiny lookups, the coordination costs more than the parallelism saves. Spawn the work, schedule it across threads, then serialize on the connection pool anyway. Node and Bun skip all of that. They just run the 3 queries back to back on a driver with no locking to coordinate. On this workload, the boring version wins.
That does not mean parallel fan-out is pointless. It means it does not pay for itself until the individual lookups are slow enough to be worth the setup.
# Warm-up
startup to first request is time to the first 200. 600 sequential reads from
one client, p99 of the first 100 against the last 100.
The 2 JVM stacks show it. Ballerina starts at p99 8.4ms and settles to 3.8ms once HotSpot has compiled the hot path. Java shows the same curve at a smaller scale, 2.8ms down to 1.8ms. Everything else, Go, Node, Bun, Rust, and Python, starts at roughly its steady-state number, because there is nothing to warm up.
# Fail-open
Every stack checks a new post against the profanity stub with a 2s timeout and
falls open if the stub stalls. I ran POST /posts at 10 concurrent for 8s with
the stub healthy. Then I ran it again with the stub forced to stall 5s, so every
write trips the timeout.
The fallback works everywhere. Every write still returns 201. No stack leaks a 5xx when the dependency stalls. Java holds up here too, third from the top behind Rust and Bun. That is further ahead than its memory numbers would suggest.
But the fallback fires per request. Nothing caches or short-circuits a known-bad upstream. Every write pays the full 2s timeout. Throughput on that route collapses to concurrency divided by timeout, about 5 req/s, until the stub recovers. Correct, bounded, still a throttle. This is identical across all 7, because it is the same timeout doing the same job. Fixing it means caching the stub’s health or circuit-breaking around it, work no one has written yet in any of these 7 stacks.
# What I would pick
Go is the fastest and cheapest to build, with native SQLite and bcrypt. The cost is verbosity, the most of any compiled stack for the safety it buys.
Java generates its DTOs from the same openapi.yaml as Go, needs a
comparable dependency count, and starts faster than Go despite carrying a JVM.
The cost is memory, 115M idle against Go’s 21M, though nowhere near Ballerina’s.
Rust ships the smallest binary and has the strongest compile-time guarantees. The compiler rejects a missing error branch before the code ever ships. The build is the slowest of all at 30.5s.
Python needs the least code for a working API, and there is no build step. It is also last on every load-test number here.
Node has the smallest hand-written codebase, a fully native SQLite and bcrypt story at 3 dependencies, and second place on reads. No compile-time checking of any kind.
Bun is the fastest under load by a wide margin, on the same application code as Node. That comes from the engine and the router, not the code. Same lack of compile-time enforcement as Node.
Ballerina was the one I was measuring. It writes the least boilerplate per endpoint, needs 1 direct dependency, and its standard library now covers SQLite, JWT, and bcrypt with no Java interop. The concurrency reads like straight-line code. Against that, it takes 1178ms to start, uses 948M of memory under a light load, and posts the second-lowest read throughput of the 7, ahead of only Python.
# Ballerina’s own weight
I went in expecting the story to be about a language gap. It was not. Ballerina can express this whole API cleanly with almost nothing imported.
I also went in assuming the cost was the JVM. Java runs on the same JVM, starts faster than Go, and uses a quarter of Ballerina’s memory under load. Whatever Ballerina spends that startup time and memory on, it is Ballerina’s own runtime, layered on top, not the platform underneath it. Whether that trade is worth it depends on what else you ask the runtime to do. For a small standalone API, I would reach for something lighter. For the integration-heavy work Ballerina is built for, the calculus is probably different. That is the test I still want to run.