What 6 Months of Rust Taught Me About Java's Blind Spots

Every senior Java developer I know has a production story. You know the one — the one where a NullPointerException took down a service at 2 AM, or a GC pause lasted long enough to trigger a cascade of health check failures. I have three of those. Maybe four, if you count the time a ConcurrentModificationException corrupted a batch job and I had to explain to the VP why 40,000 invoices were duplicated.
Six months ago I decided to learn Rust. Not because I was abandoning Java — I still ship Spring Boot every week — but because I wanted to see what a language that forces you to think about memory safety would teach me.
What the borrow checker taught me about my own code surprised me. Not because Rust is “better” — it’s not, for everything — but because it exposed blind spots in Java that years of production code had normalized into “that’s just how it is.”
The First Week: Humility
If you’re a Java developer picking up Rust, you will feel stupid for the first two weeks. This is not a metaphor. You will try to write a simple linked list. You will fail. You will read the Rust book’s chapter on ownership for the third time and still not understand why the compiler won’t let you do something that feels obviously correct.
This happened to me. Here’s what I tried to write on day three:
struct Node {
value: i32,
next: Option<Box<Node>>,
}
fn traverse(node: &Node) {
let mut current = node;
while let Some(next) = current.next {
current = &next; // ERROR: cannot move out of `current.next`
}
}
The compiler rejected this because current.next is a Box<Node> — an owned type — and I was trying to borrow from it while also consuming it. In Java, this would be node = node.next and nobody would blink. In Rust, the ownership system is asking a question Java never asks: who owns this data right now, and who else is allowed to reference it?
After twenty minutes of frustration, the fix clicked:
fn traverse(node: &Node) {
let mut current = Some(node);
while let Some(n) = current {
current = n.next.as_ref().map(|next| next.as_ref());
}
}
This is the fundamental difference: Java trusts you to not shoot yourself in the foot. Rust assumes you will, and makes the compiler the safety inspector.
What Rust Made Me Notice About Java
Null Is a Design Decision, Not a Bug
Java has Optional<T>. It was added in Java 8. Eighteen years after the language was created. And yet, I still see null checks in production code I review. Not because developers are careless — because null is convenient. It’s the path of least resistance.
Rust doesn’t have null. It has Option<T>, and the type system forces you to handle both cases:
// Rust: the compiler won't let you skip the None case
fn get_user(id: u64) -> Option<User> {
db.users.get(id)
}
// You MUST handle both cases at the call site:
match get_user(42) {
Some(user) => println!("Found: {}", user.name),
None => println!("User not found"),
}
In Java, Optional<User> exists but you can still ignore it:
// Java: compiles fine, explodes at runtime
Optional<User> maybeUser = getUser(42L);
User user = maybeUser.get(); // NoSuchElementException if empty
The borrow checker made me realize something uncomfortable: Java’s type system is advisory. Rust’s type system is mandatory. And in 10 years of Java, I’ve written code that compiled but was fundamentally unsafe, relying on team conventions and code reviews to catch what the language itself couldn’t.
The GC Tax Is Real
I’d accepted GC pauses as a fact of life, like rain in Quebec. You don’t question rain. You just carry an umbrella and configure your health check timeout to be generous enough.
Then I wrote a small HTTP server in Rust using axum — no GC, no JIT warmup, no “stop the world” pauses — and deployed it alongside a Spring Boot equivalent for the same API. The Rust service used 28MB of RAM. The Spring Boot service used 340MB. Not 340MB under load — 340MB at idle.
// Spring Boot: startup cost you can't avoid
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
// ~340MB RSS, ~3s startup, JVM warmup period
}
}
// Axum: starts fast, stays light
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/users", get(list_users));
axum::serve(listener, app).await.unwrap();
// ~28MB RSS, ~0.1s startup, no warmup
}
I’m not saying every service needs 28MB. For a typical CRUD API, the JVM overhead is a rounding error. But when you’re running 50 microservices on a cluster, that 312MB difference per service compounds. And during a GC pause, your 200ms p99 latency becomes 2000ms. Rust doesn’t have this problem because there is no GC. Memory is freed deterministically when the owner goes out of scope.
Error Handling That Actually Composes
Java’s checked exceptions are one of the language’s best ideas, poorly executed. They force you to declare what can go wrong, but the syntax is verbose and encourages catching-and-logging-then-continuing, which is arguably worse than letting it crash.
Rust’s Result<T, E> is what checked exceptions should have been:
fn parse_config(path: &str) -> Result<Config, ConfigError> {
let content = fs::read_to_string(path)
.map_err(|e| ConfigError::Io(e))?;
let config: Config = toml::from_str(&content)
.map_err(|e| ConfigError::Parse(e))?;
Ok(config)
}
The ? operator is the killer feature here. It propagates errors up the call stack without the verbosity of try/catch blocks, and the compiler forces the caller to decide: handle it or propagate it. No more silent swallowed exceptions.
Where Java Still Wins (And I Don’t Care What Rust Evangelists Say)
Some will say Rust makes Java obsolete. They’re not wrong about Rust’s technical merits, but they’re wrong about what actually matters in production.
Here’s where Java still dominates, and I say this as someone who spent six months writing Rust:
| Concern | Reality |
|---|---|
| ”Rust is faster” | True for cold starts and memory usage. For sustained throughput under load, the JVM’s JIT optimization closes the gap significantly. |
| ”Rust has better concurrency” | async/await in Rust is zero-cost. But Java’s virtual threads (Project Loom, Java 21+) are “good enough” for 95% of services and much easier to reason about. |
| ”Rust ecosystem is mature” | For systems programming, yes. For enterprise web development? Spring’s ecosystem (security, data, cloud) is still unmatched. tokio + axum is good, not comprehensive. |
| ”Learning curve” | Rust’s borrow checker takes 2-4 weeks to stop fighting you. Java’s learning curve is gentler, but the mastery curve (understanding the JVM, GC tuning, profiling) is equally steep. |
| ”Team adoption” | Onboarding a new Java developer takes days. Onboarding a new Rust developer takes weeks. That’s a real cost. |
If I’m building a new greenfield service that needs low latency, minimal memory footprint, or runs on edge hardware — I’m reaching for Rust. If I’m building a complex business application with transactions, authentication, and a team of eight developers who already know Spring — I’m not rewriting it in Rust. That would be engineering theater.
The Mental Shift That Stuck
After six months, the biggest thing Rust changed wasn’t my code — it was my thinking about code. I now look at Java code and immediately ask:
- Who owns this object? Not in the Rust sense, but: is this shared mutable state? If yes, what happens under concurrent access?
- What are the null paths? Every
Optionalor nullable field is a branch in the logic. Am I testing all of them? - What errors are being swallowed? Every
.catch(e -> log.warn("ignored", e))is a time bomb. - Is this the right abstraction? Sometimes the “enterprise” pattern (factories, builders, dependency injection) is solving a problem the language created.
These questions apply to any language. Rust just forces you to ask them at compile time instead of during a postmortem.
The Decision Matrix
| Scenario | My Recommendation | Why |
|---|---|---|
| New microservice, greenfield, performance-sensitive | Rust | Low memory, fast startup, no GC pauses. Worth the learning investment. |
| Enterprise CRUD app, team of 5+ | Java/Spring Boot | Ecosystem, developer availability, maintainability > raw performance. |
| CLI tool or systems utility | Rust | Single binary, no runtime, cross-compilation. Unbeatable for distribution. |
| Data processing pipeline | Java | Mature ecosystem (Kafka clients, data libraries, JVM profiling tools). |
| Side project / learning | Rust | It will make you a better developer in every language, including Java. |
Bottom Line
I didn’t switch from Java to Rust. I added Rust to my toolbox. The two languages solve different problems at different layers of the stack. But learning Rust made me a better Java developer because it forced me to confront the compromises I’d normalized.
The borrow checker isn’t a punishment — it’s a teacher. And the lessons it taught me about ownership, error handling, and memory safety made me write better code in every language I use.
If you’re a Java developer and you’ve been curious about Rust, my advice is: spend one weekend with it. Build something small. Fight the borrow checker. Then go back to your Java code and see what looks different.
It will. I promise.
Want to learn Rust? The Rust Book is free and genuinely excellent. For Java developers, I recommend Programming Rust by Blandy, Orendorff, and Tindall — it respects your existing knowledge while teaching Rust's unique concepts.
Still shipping Java? I've got you covered — check out my Spring Boot + Testcontainers article for real-world testing patterns that actually work in production.
Enjoying the content? Here are tools I personally use and recommend:
- 🌐 Hosting: Bluehost — what this blog runs on
- 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use
Purchases through my links help keep this blog ad-free 💙
Enjoyed this post?
Subscribe to the newsletter or follow on YouTube for more dev content.
🎬 Watch Shorts