15 min read

Go Had One Job, and Java Took It

The reasons to pick Go over Java in 2016 have evaporated. A fair look at how Java caught up, where Go still wins, and where it actually fits in 2026.

Share

For about a decade, saying “we’re building it in Java” got you a look. A small, pitying look. Java was what the banks ran on, what university tortured you with, the language of ceremony and XML and class names longer than the methods inside them. Go was the future. Small, fast, concurrent, one binary you could drop on a server and walk away. If you were starting something new and paying attention, you reached for Go.

I lived a little of that and watched a lot more of it. And here is the thing nobody went back and checked: that comparison was run around 2016, and then nobody re ran it. So I did. Category by category, being as fair as I can. The uncomfortable conclusion is that most of the reasons you picked Go over Java have quietly expired.

Let me walk you through it.

First, let me make Go’s case

In 2016 Go was a real breath of fresh air. Goroutines gave you cheap concurrency the JVM could not touch without a thread pool and a lot of care. go build produced a single static binary you could copy onto a box with no runtime to install. Compile times were measured in seconds while a Java project of the same size took a… coffee break. The language was small enough to learn in a weekend, the tooling was simple, and it started instantly.

Java, meanwhile, was verbose to the point of self parody, drowning in XML, weighed down by enterprise frameworks, and shipping new versions on a schedule that felt geological.

Go was built at Google to solve exactly these problems, “a language for writing server programs” in the words of its own FAQ, after a single internal C++ binary reportedly took 45 minutes to build. It was a great answer to a real problem. The only question is whether it is still the answer in 2026.

Category by category

Concurrency: Go won, then Java took it back

Cheap concurrency was Go’s single most compelling advantage. “You just write go doSomething() was a real argument Java had no clean answer to.

Java 21 shipped virtual threads in 2023 (Project Loom). Same idea: millions of cheap threads multiplexed onto a handful of OS threads, except you write ordinary blocking code and the runtime does the rest. Java 24 fixed the last rough edge, thread pinning. Goroutines still hold an edge in ergonomics and baseline memory, and they are the built in default with zero ceremony. But the capability gap, the thing that made people say “you cannot do that cheaply on the JVM” is gone. Virtual threads walked up to Go’s biggest selling point and neutralised it.

Syntax: Go won, then gave the lead back

“Java is painfully verbose” was true, and it is now not just stale, it is close to backwards. Records killed the fifty line data class. Pattern matching, sealed types, switch expressions, var, and text blocks cut the boilerplate that had made Java the language people loved to mock. Lambdas and the Streams API turned data wrangling into one readable line, list.stream().filter(...).map(...).toList().

Here is the uncomfortable part. A lot of everyday Go is now the verbose one. No streams, so you write the loop by hand every time. No clean map or filter idiom. And if err != nil stamped down every few lines like a nervous tic. Go is still leaner at the edges, in the type and struct declarations, but that was never where the verbosity actually hurt. In the business logic that fills the middle of a function, the place the line count really piles up, modern Java routinely says the same thing in fewer lines.

Release cadence: Java ships twice a year now

The “Java never changes” jab died in 2017. Since Java 9 there has been a release every six months, with an LTS every couple of years. Java now ships substantial features twice a year, which is faster than most of its critics have noticed, and on par with Go’s release cadence.

Compiled binary vs virtual machine: not the clean line it used to be

“Go compiles to a native binary, Java needs a VM” was a clean, damning contrast. It is muddier now, in two directions.

First, you can ship native Java. GraalVM Native Image compiles your app ahead of time into a standalone executable with no JVM at runtime, and it is in production behind Quarkus, Micronaut, and Spring Boot’s native mode.

Second, and this is the part nobody says out loud, Go is not magic here either. A Go binary statically links its own runtime, garbage collector and goroutine scheduler and all, which is why a Go “hello world” is several megabytes. The honest distinction is not “runtime vs no runtime.” It is “native by default (Go) vs bytecode + JIT by default, with native available (Java).”

The JIT is a genuine Java advantage the binary framing hides. Because it uses live profiling data, a warmed up JVM can beat Go on raw throughput for a long running service. It just pays for that with slow startup and warmup.

Raw throughput: mind which frameworks you compare

Everyone reaches for the TechEmpower benchmarks. The number people quote is Spring Boot versus Go Fiber, where Fiber wins comfortably, roughly 441,000 requests per second to Spring’s 244,000 on the Fortunes test. Case closed, Go is twice as fast.

Except that comparison pits Java’s slowest popular framework against one of Go’s fastest. Swap Spring for the JVM’s actual speed demons, Vert.x or Quarkus, and they land right alongside or just ahead of the top Go frameworks. The JVM was never the bottleneck. Spring’s blocking model was. Java and Go trade blows in the same weight class, both a tier below Rust and C++.

Footprint: Go still wins, but mind the tradeoff

On the plain JVM, Java is memory hungry. A small Spring Boot service happily sits at 200 to 400 MB of RSS.

Native image changes the shape of this. Startup drops to tens of milliseconds (Quarkus native around 50 ms, competitive with Go) and RSS falls by 2.5x to 4.5x. But Go still usually wins on memory, often using 2x to 5x less than Spring’s native image, and its binaries are smaller. In fairness, the leanest native builds close the gap: a trimmed Quarkus service around 70 MB can get within reach of Go.

But native image has a catch. It gives up the JIT, which means it gives up peak throughput. On Oracle’s own benchmark of the Spring PetClinic app, the native build hit about 80% of the JIT build’s throughput, and that gap has barely moved through 2026 for the free edition. Go’s real, durable edge here is consistent latency with zero warmup and no tuning.

Build tooling: Go wins, cleanly

go build and Go modules are simple and pleasant: one tool, a go.mod you can read at a glance, and most projects never need a build script at all. You forget the build tool is even there, which is the highest compliment you can pay one.

Gradle is the opposite, and it is the worst part of my Java days. You do not configure it so much as negotiate with it. Half the snippets you copy off the internet silently do nothing, the error messages point everywhere except the actual problem, and every couple of releases something that worked stops working. Maven is calmer, just a lot of XML, but it has its own trap: it picks dependency versions by rules nobody remembers, and I have watched a library resolve to the wrong version because two lines in a file were in the wrong order. Whatever else you think, this is one round Go wins outright.

Standard library: the gap is closing

I expected to hand Go a clean win here on “batteries included.” It is not that clear cut in 2026. Java’s standard library has picked up things Go’s still lacks: a built-in WebSocket client, HTTP/3 as of JDK 26, a far richer date and time library. Go, famously, has no WebSockets in the standard library at all.

Go still wins the quick start. net/http and encoding/json stand up a JSON service with zero dependencies, where Java has you add Jackson from line one. But that is a smaller win than it sounds. The moment you need more than the basics, the question stops being about standard libraries and becomes about ecosystems.

Ecosystem maturity: Java’s real lead, and the simplicity tax

Decades of libraries. Spring Boot. Dependency injection, autoconfiguration, starters, an integration for every database, queue, and cloud you will ever touch. In Go you assemble a lot of this yourself: wire up dependency injection by hand, pick an ORM or write the SQL, bolt on migrations.

And yes, Spring Boot is “magic,” and that magic is often the whole point. Autoconfiguration means the sensible default is already wired: a datasource, a connection pool, metrics, health checks, security, all there before you write a line. That is less to decide, less to maintain, and less to get wrong. “Spring is bloated” misses what the bloat actually is, which is a thousand decisions someone already made correctly so you do not have to. On a good day, having less to reason about is not a weakness, it is the feature you are paying for.

And crucially, nobody forces the magic on you. Don’t want it? Reach for something lean like Quarkus. That is the part most miss: Java hands you the whole range, from a bare entry point to the full framework, and lets you choose where to sit on it. Go cannot hand you the latter even when you want it. There is no Spring for Go, and that is structural, the language’s minimalism and the community’s taste push hard against anyone ever building one.

This is where Go’s celebrated simplicity shows its bill. Every primitive you roll by hand is time the “simple” language charges you, and it charges interest. Simplicity in the language is complexity moved into your codebase. That is a fine trade for some teams and a slow bleed for others, but it is never free.

The billion dollar mistake

To be fair in the other direction, Java did not win everywhere. Java still has null, and NullPointerException is still the most common way a Java program dies. Optional is just a band aid: it is convention limited, the compiler ignores it, and an Optional can itself be null, which is almost funny.

Go has nil pointers, nil maps, and the infamous nil-interface gotcha, so it did not solve this either.

So on null it is roughly a draw, and it belongs on Java’s “still bad” list. But notice the difference in effort. Java at least shipped Optional in the standard library, the same instinct that pushed .NET all the way to compiler enforced non nullable reference types. Go was asked for the same thing more than once, and closed the proposals each time. Java’s fix is weak and .NET’s is strong, but both platforms at least tried to give you one. Go decided you did not need it.

Except Java has an escape hatch Go structurally cannot offer. Because the JVM is a platform and not just a language, you can write the null sensitive parts in Kotlin, which did solve null (String and String? are different types and the compiler enforces it), keep the entire Java ecosystem and the JVM’s low level JIT optimisations. Same runtime, full interop, a better language exactly where you want it. Go’s answer to the same problem has been niche supersets almost nobody ships and years of code generation hacks to fake the features the language refused to add. One platform invites other languages in. The other gives you Go, and if that is not enough, good luck.

The asymmetry

Java spent its decade attacking Go’s advantages:

  • Virtual threads (2023) went straight at goroutines and cheap concurrency.
  • Native image and Project Leyden (2019 and 2025) for single binary and fast startup.
  • Records, pattern matching, and streams (2014 to 2023) addressed the verbosity complaints.

Go spent its decade closing its own gaps and never touched Java’s:

  • Generics (1.18, 2022) were the headline, after a decade of interface{} and code generation. But generics were table stakes Java had since 2004. They did not give anyone a new reason to pick Go. They stopped Go from bleeding the users who were fed up without them.
  • log/slog (1.21) was catching up to structured logging the Java world always had.
  • The loop-variable fix (1.22) repaired a footgun that had bitten every Go developer at least once.
  • Modules (2018 to 2021) finally killed GOPATH.

All real, all good work. And notice what every single item has in common: it made Go less annoying to people already using Go. But not one of them removed a reason to choose Java.

The most revealing part is the gap Go refused to close at all. Error handling. The check/handle and try proposals were floated and abandoned, and it is still if err != nil on every third line, by design.

So here is the asymmetry in one sentence. Go spent the decade patching its own potholes, and Java spent it paving over Go’s advantages.

What is still genuinely bad about Java

Java in 2026 still has real problems:

  • Gradle. The single worst part of the daily experience.
  • Cold start and memory on the plain JVM. Native image helps, but the builds take minutes, and it chokes on any library that leans on reflection unless you declare every case by hand. You trade one pain for another.
  • Annotation and reflection magic, when it turns on you. The same autoconfiguration that saves you a thousand decisions becomes a black box the day it misbehaves, and “where is this bean actually configured” is a question that eats an afternoon. A good trade most days, an infuriating one on the bad ones.
  • Null, as covered.

You might expect checked exceptions on that list. I left them off on purpose. People love to hate them, but they are really just Java’s if err != nil, errors promoted into the signature so you cannot quietly pretend they do not exist. Go made the exact same bet with explicit error values, and it is a reasonable one in both languages.

None of this makes Java clean. It makes Java good enough that the specific reasons you used to skip it for Go mostly do not hold anymore.

So where does Go actually fit now?

Go still has a real place, and I do not want this to collapse into “just use Java for everything,” because that is wrong too.

Go’s documented home is infrastructure, networking, cloud-native systems, CLIs, and DevOps tooling, and it earned it. Tellingly, that is exactly what Go is used for at Google, the company that made it: the database layer behind YouTube, Kubernetes, gVisor, the SRE systems. The big consumer products are C++, Java, and Python. Go was built “for writing server programs,” and that is where it lives. Over 75% of CNCF projects are written in Go.

But look closely at the reasons you would reach for it and several are softer than they were:

  • Cloud infra, yes, but the reasons keep shrinking. The old “no runtime to install” pitch is mostly solved, and native image Java now ships the same way, a single self contained binary in a lean container. Go’s binary is usually still smaller, but they sit in the same ballpark now. The reason that has not shrunk is the ecosystem: for low level and cloud native primitives, networking, gRPC, the Kubernetes and container libraries, Go has deeper and more mature support, because that whole world was built in Go. If you are writing infrastructure, that is the edge that still matters.
  • “Small fast services,” maybe not. Native image Java now starts just as fast.
  • “Teams that prize simplicity” pay for it. I will die on this hill. Go’s simplicity is real, but rolling everything by hand slows a team down, and “simple language” often just means “complicated codebase” a year later.

There is also something else worth calling out. Below Go, Rust now owns the latency and safety critical edge: Firecracker, Cloudflare’s Pingora, Discord’s famous Go to Rust rewrite. Above Go, a modernised Java and Kotlin compete hard for ordinary backends. Go can look squeezed from both sides.

But the data tells us something different. Go usage is still rising (16.4% on the 2025 Stack Overflow survey), and Rust is mostly displacing C and C++, not Go. Rust also asks a lot in return, between the borrow checker, the slow compiles, and the extra work just to produce a fully static binary. Go’s real staying power is that no single rival matches everything it offers at once: fast compiles, a single binary, concurrency built into the language, and a cloud native ecosystem already written in it. When Microsoft rebuilt the TypeScript compiler in 2025, it chose Go over Rust for exactly that, speed of development.

So Go is not dying and is not being displaced. But its status as the obvious default has to be re earned now, service by service. For a lot of ordinary backends, modern Java already covers it.

The whole story

Java improved on the things Go was good at. Go never improved on the things Java was good at.

And that is not luck. Java is still here, still relevant, thirty years after its inception, because it kept listening to the people who actually write it and was never too proud to borrow. Generics, lambdas, streams, records, pattern matching, even the idea of cheap threads: almost none of it was invented in Java, and that is exactly the point. It watched what other languages did in a more modern or simply better way and it adapted, again and again. Go decided early what it was and mostly refused to move, and for a while that discipline was a genuine feature. But maintaining a language is a long term commitment, and the willingness to keep meeting your developers where they are is the thing that compounds. Ten years from now, I think that difference will matter more than anything mentioned here.

If you are weighing this for real, picking a stack for something new or wondering whether the one you already run still earns its place, I’m ready to help you make the right choice.