Comparing Ballerina with Other Backend Languages

September 19, 202613 min read
performancecomparison

Table Of Contents

  1. The backend
  2. Lines of code
  3. Dependencies
  4. Build and startup
  5. Memory
  6. The load test
  7. Warm-up
  8. Fail-open
  9. What I would pick
  10. Ballerina’s own weight

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.

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

Hand-written lines
Test lines

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.

Lines generated from OpenAPI

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.

Direct dependencies
Transitive dependencies

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

Cold build or setup
Output size
Startup to first request

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.

Idle RSS
Under-load RSS

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.

GET /posts/{id} throughput
POST /posts throughput
GET /posts/{id} average latency
POST /posts average latency
GET /posts/{id} p99 latency
POST /posts p99 latency

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.

First 100 requests, p99
Last 100 requests, p99

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.

Throughput with the stub healthy

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.