16 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’ll be honest before we start. I think Go mistakes “lack of features” for “simple,” and I’ve spent enough evenings with it to have opinions I can’t fully defend in polite company. So I’m going to do the harder thing and be fair to it anyway, because here’s what nobody actually went back and checked: that whole scorecard was drawn up around 2016, and then everyone just kept reciting it. Nobody re-ran the test. So I did. Category by category and feature by feature. The uncomfortable conclusion is that most of the reasons you picked Go over Java have quietly expired.

TL;DR

If you’re skimming:

  • Most of the 2016 reasons to reach for Go over Java don’t hold anymore. Java caught up on concurrency, syntax, startup, and release cadence. (I never would have expected that)
  • Go still wins cleanly on build tooling, and the cloud-native ecosystem. For infra, CLIs, and networking it’s still the tool I’d reach for. (Well, between that and Java…)
  • For a lot of ordinary backends, modern Java (or Kotlin) now does the job just as well.
  • Nobody’s dying here. Pick per service, and stop treating Go as the automatic default.

Still with me? Good. Let me walk you through how I got there.

First, let me make Go’s case

In 2016 Go was a real breath of fresh air. Goroutines gave you cheap concurrency the JVM couldn’t 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 just worked, 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’s 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, and for years Java had no clean answer to it.

Then 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 filed off the last rough edge, thread pinning. Goroutines still have nicer ergonomics and a smaller baseline memory footprint, and they’re the built in default with zero ceremony. But the capability gap, the thing that made people say “you can’t 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 handed the lead back

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

And here’s 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, where the line count really piles up, modern Java routinely says the same thing in fewer lines.

Release cadence: Java ships twice a year now

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

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’s 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’s in production behind Quarkus, Micronaut, and Spring Boot’s native mode.

Second, and this is the part the Go crowd never says out loud, Go isn’t 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” weighs several megabytes. The honest distinction isn’t “runtime vs no runtime.” It’s “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 optimises off 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. Right?

Except, look at what that benchmark actually compares: 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 a memory hog. 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, right there 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 gets within reach of Go.

There’s a catch, though. Native image 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’s the worst part of my Java days. You don’t configure it so much as negotiate with it, and it doesn’t bargain in good faith. 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 just stops. Maven is calmer, just a lot of XML, but it has its own trap: it picks dependency versions by rules nobody remembers, and I’ve lost an entire afternoon to a library resolving to the wrong version because two lines in a file were in the wrong order. I’d like that afternoon back. Whatever else you think, this round Go wins outright.

Standard library: the gap is closing

I expected to hand Go a clean win here on “batteries included.” It isn’t that clear cut in 2026. Java’s standard library has picked up things Go 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’s 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’ll ever touch. In Go you assemble a lot of that 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’s 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 don’t have to. On a good day, having less to reason about isn’t a weakness, it’s the feature you’re paying for.

And crucially, nobody forces the magic on you. Don’t want it? Reach for something lean like Quarkus. That’s 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 can’t hand you the other end even when you want it. There’s no Spring for Go, and that’s 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. It’s like furniture from IKEA: the box is cheap, but you’re the one on the living room floor at midnight assembling it, and there’s always one screw that somehow disappeared. Every primitive you build yourself is time the “simple” language charges you, and it charges interest. Simplicity in the language is complexity moved into your codebase. That’s a fine trade for some teams and a slow bleed for others, but it’s never free.

The billion dollar mistake

To be fair in the other direction, and I do want to be fair, Java didn’t 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’s convention limited, the compiler ignores it, and an Optional can itself be null, which is almost funny, unless you find out on a Friday.

Go has nil pointers, nil maps, and the infamous nil interface gotcha, so it didn’t solve this either.

So on null it’s roughly a draw, and it belongs on Java’s “still bad” list. But notice the difference in effort. Java at least shipped Optional, 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 every time. Java’s fix is weak and .NET’s is strong, but both platforms at least tried to give you one. Go decided you didn’t need it.

Except Java has an escape hatch Go structurally can’t 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 unofficial, community driven answer to the same itch 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 isn’t enough, well, 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 the single binary and fast startup.
  • Records, pattern matching, and streams (2014 to 2023) went after the verbosity.

Go spent the decade closing its own gaps instead:

  • 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 didn’t 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’s still if err != nil on every third line, by design.

So here’s 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. Yes, again. Some wounds don’t heal.
  • 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’re really just Java’s if err != nil, errors promoted into the signature so you can’t quietly pretend they don’t exist. Go made the exact same bet with explicit error values, and it’s a reasonable one in both languages.

None of this makes Java pretty. It makes Java good enough that the specific reasons you used to skip it for Go mostly don’t hold anymore.

So where does Go actually fit now?

Go still has a real place, and I don’t want this to collapse into “just use Java for everything,” because that’s wrong too. (It physically hurt to type that, but it’s true.)

Go’s documented home is infrastructure, networking, cloud-native systems, CLIs, and DevOps tooling, and it earned it. Tellingly, that’s 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’s where it lives. Over 75% of CNCF projects are written in Go.

But look closely at the reasons you’d 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. What hasn’t 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’re writing infrastructure, that’s 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. This is the hill I will die on. 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’s one more thing worth naming. 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 isn’t dying and isn’t 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 isn’t 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’s 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 else here.

If you’re weighing this for real, picking a stack for something new or wondering whether the one you already run still earns its place, that’s the kind of argument I actually enjoy having. Bring your side, I’ll bring mine.