Back to the blog

I Spent Years on the JVM. Then I Moved to Go for Cloud Native Work.

14 Sept 20267 min readcloud-computinggolanggoogle-cloud-platformspring

This is based on my experience why Go wins on memory, cold starts and throughput.

I wrote Java for a long time. Payment systems, transaction processing, the kind of code where a bug costs real money. I still think Java is a great language. Its the legendary write once runs anywhere.

Recently I started building services that run on GCP Cloud Run and other server-less infrastructure, scale to zero, wake up on a burst of traffic and go back to sleep. That is where my opinion changed.

This is not a language war. It is about what happens when your code stops living on a fixed server and starts living on infrastructure you pay for by the second.

The bill arrives in megabytes

Here is the thing nobody tells you in geek for geeks or baeldung. In the cloud, memory is money.

I once extended an AbstractSingletonProxyFactoryBean to add a header. One header. A string, and another string. By the time it worked I had three new classes, a config bean and a genuine question about my career :)

A JVM service does not start small. Before it handles a single request it has already reserved heap, loaded thousands of classes, spun up its own thread pools and started a garbage collector that wants breathing room. A modest Spring Boot service sits around 250MB to 400MB of RSS while doing nothing at all.

A Go service that does the same job sits around 20MB to 50MB.

That gap sounds hypothetical until you multiply it. Twenty services. Three replicas each. Now you are paying for 15GB of memory that exists mostly to hold a runtime, not your business logic.

On Cloud Run it shows up on the invoice directly, because you are billed on the memory you request.

Same workload. Same traffic. One tenth the footprint.

Why the gap exists

Java compiles to bytecode. That bytecode needs a virtual machine to run it. The VM is a whole program with its own memory model, its own threads and its own optimiser.

Go compiles to a native binary. The runtime is small and it is inside your binary. There is no second program underneath your program.

That single design decision explains most of what follows.

Cold starts are a user problem, not a benchmark problem

Scale to zero is one of the best things about modern cloud platforms. You stop paying when nobody is using the service. Traffic arrives and a new instance spins up.

The question is how long that new requester waits.

A JVM has to start the VM, load classes, verify them, then run your code in interpreted mode until the JIT compiler decides a code path is hot enough to optimise. Only then does it get fast. That warmup is real. Early requests are slow requests.

Example case of how typical numbers look like:

  • Java Spring Boot: 200 to 400MB image, 3 to 10 seconds to first request, 250MB+ idle, warmup measured in seconds.
  • Go: 15 to 30MB image, under 200ms to first request, 20 to 50MB idle, no warmup at all.

Go has nothing to warm up. The binary is already machine code. It starts, it serves, it is at full speed on request number one.

This matters in three places.

Scale to zero. If your service sleeps overnight, the first user in the morning eats the cold start. With Go they never notice.

Traffic spikes. A promotion goes live and traffic jumps 10x. Your autoscaler adds instances. With Java those new instances are slow exactly when you need them fast. You end up over provisioning to hide the problem, which defeats the point of autoscaling.

Deployments. Rolling out fifteen services is a different experience when each one is ready in under a second.

You can fix a lot of this on the JVM with GraalVM native image. It works. It also adds build complexity, longer build times and a class of reflection problems that will find you at the worst possible moment. Go gives you the same result with go build.

Throughput, and the honest version of it

On a long running CPU heavy workload, a warmed up JVM is genuinely fast. The JIT compiler watches your code as it runs and optimises for the paths you actually take. Go’s compiler does its work ahead of time and optimises for build speed. On pure number crunching, Java can beat Go.

Yet that is not the number that matters for most cloud services.

The number that matters is throughput per megabyte of memory, and requests served per dollar. On that measure Go wins comfortably, for two reasons.

Goroutines are cheap. A goroutine starts at about 2KB of stack and grows as needed. You can run hundreds of thousands of them on one machine. Java threads were OS threads, roughly 1MB each, which is why the JVM world built an entire reactive programming industry to avoid blocking one.

The GC is built for latency. Go’s garbage collector is concurrent and tuned to keep pauses under a millisecond. It gives up some throughput to get there. That is exactly the right trade for a request serving system, where the p99 is what your users feel.

But Java 21 closed a lot of this with virtual threads. Project Loom is real progress and it removed the main reason JVM concurrency used to be painful. But you still carry the memory footprint and the startup cost with you.

One binary, zero surprises

This is the part I did not expect to care about, and now I care about it a lot.

A Go build produces one static binary. No runtime to install. No JVM version to match. No classpath. You copy that file into a scratch or distroless container image and you are done.

That gives you:

  • Images of 15MB to 30MB, so pulls are fast and scale out is fast
  • Almost nothing in the image to scan, patch or exploit
  • No works on my machine gap between local and production
  • No JVM flag tuning inherited from an engineer who left two years ago

This is huge because when something breaks at 3am, the number of moving parts between you and the problem is small. That is worth more than a benchmark.

Concurrency you can read out loud

This is part where it stole me from Java. Most cloud services do the same thing all day. Wait on a database. Wait on an HTTP call. Wait on Kafka. Do a little work in between.

Go was designed for exactly that shape of problem.

results := make(chan Result, len(ids))

for _, id := range ids {
    go func(id string) {
        results <- fetch(id)
    }(id)
}

That is concurrent code. You can read it top to bottom and it does what it looks like it does.

The JVM equivalent for years meant CompletableFuture chains or a reactive library. Powerful, but the code stopped reading like a sequence of steps. Log traces became very complex.

By the way, project Loom fixes this in Java 21. Credit where it is due. But Go had it in 2012 and built the whole ecosystem around it, which means every library you pull in already assumes this model.

How I actually decide

My recommendation is forget the language. Look at the shape of the workload.

Pick Go when the service scales to zero, traffic is spiky, you run many small services, pod density matters, cold start is user facing, or you want deployments measured in seconds.

Pick Java when the service runs hot all day, the work is CPU bound, you depend on the JVM ecosystem, or the domain logic is genuinely complex.

For the work I do now, which is payment event processing on Cloud Run, Go was not a close call. Events arrive in bursts. Services need to wake fast, handle a surge and go quiet. Each service is small and does one thing. Memory density is the cost driver.

I preferred Go as this problem exactly is in Go’s shape.

Originally published on Medium