I spent last Tuesday untangling a massive billing monolith that kept crashing our staging cluster. Three nodes, 32GB RAM each, and the thing still choked under moderate load. Whenever this happens, someone immediately blames the language. They say it’s too heavy. They want to rewrite the whole thing in Go or Rust.
Well, that’s not entirely accurate. The language isn’t the problem. The way we write it is.
We carry over habits from 2015 into modern cloud environments and wonder why our apps break. If you’re replatforming an older application, lifting and shifting your ten-layer architectures straight into a container won’t magically fix your performance issues. You have to drop the baggage.
And here is what actually works for me right now, stripped of the enterprise over-engineering.
Stop Writing Data Carriers Like It’s 2010
I still see codebases filled with massive POJOs. Hundreds of lines of getters, setters, equals, and hashcode methods generated by the IDE. It’s noise. It hides actual business logic.
If you are on Java 21 (and as of late last year, there is zero excuse not to be on at least 21 LTS), Records should be your default for data passing. They are immutable by design, which immediately solves a whole category of concurrency bugs I used to spend days debugging.
Here is a clean contract using an interface and records. Notice how readable this is when you drop the boilerplate.
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}
// These two lines replace about 80 lines of traditional class boilerplate
public record PaymentRequest(String userId, BigDecimal amount, String currency) {}
public record PaymentResult(boolean success, String transactionId, String error) {}
I enforce this in my teams now. If a class just holds data and doesn’t mutate state, it’s a Record. Fight me.
The Stream API Gotcha That Will Take Down Your App
Streams are fantastic. I use them constantly. But there is a specific trap with Collectors.toMap() that burned me so badly on a Friday night deployment that I now check for it in every code review.
Let’s say you want to process a batch of requests and map them by user ID. The naive approach looks like this:
public Map<String, PaymentRequest> mapByIdNaive(List<PaymentRequest> requests) {
// DO NOT DO THIS
return requests.stream()
.collect(Collectors.toMap(
PaymentRequest::userId,
req -> req
));
}
The docs don’t make this obvious enough, but if your list contains two objects with the exact same user ID, this code doesn’t just overwrite the value. It throws an IllegalStateException: Duplicate key. It fails fast and hard. This exact exception took down our nightly invoicing job because one user accidentally double-clicked a submit button upstream.
You have to provide a merge function. Always. Even if you think duplicates are impossible.
public Map<String, PaymentRequest> mapByIdSafe(List<PaymentRequest> requests) {
return requests.stream()
.filter(req -> req.amount().compareTo(BigDecimal.ZERO) > 0)
.collect(Collectors.toMap(
PaymentRequest::userId,
req -> req,
(existing, replacement) -> existing // Keep the first one we found
));
}
Network Calls Fail. Plan For It.
When you move an older app to the cloud, the network suddenly becomes your biggest enemy. Things that used to be local database calls are now HTTP requests to microservices across different availability zones. They will timeout. They will drop.
I stopped using third-party heavy HTTP clients for simple REST calls. The native java.net.http.HttpClient is completely fine now. But you absolutely must set explicit timeouts. The default behavior is to wait forever, which leads to thread exhaustion.
public class StripeProcessor implements PaymentGateway {
// Share the client instance - it's thread-safe and expensive to create
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
@Override
public PaymentResult charge(PaymentRequest request) {
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.internal/charge"))
.timeout(Duration.ofSeconds(5)) // Fails fast if the server hangs
.POST(HttpRequest.BodyPublishers.ofString(toJson(request)))
.build();
try {
HttpResponse<String> response = client.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return new PaymentResult(response.statusCode() == 200, "txn_123", null);
} catch (HttpTimeoutException e) {
// Log this specifically. It means the network is degraded.
return new PaymentResult(false, null, "GATEWAY_TIMEOUT");
} catch (Exception e) {
return new PaymentResult(false, null, "INTERNAL_ERROR");
}
}
private String toJson(PaymentRequest req) {
return """
{"user":"%s", "amount":%s}
""".formatted(req.userId(), req.amount());
}
}
Setting that 5-second timeout cut our ghost-connection issues to zero. When the downstream billing service locks up now, our app just returns a clean failure instead of taking the whole Tomcat instance down with it.
Virtual Threads Are Actually Worth the Hype
I am usually highly skeptical of new JVM features promising magical performance gains. Most of the time, the bottleneck is your database queries anyway.
But Virtual Threads (Project Loom) completely shifted how I handle concurrent workloads. I tested this on a t4g.large EC2 instance running Tomcat 10.1.19. We had a specific endpoint that aggregated data from four different external APIs.
With standard OS threads, we capped out at about 400 requests per second before latency spiked into the 5-second range due to thread context switching. I swapped our standard thread pool for a virtual thread executor.
public List<PaymentResult> processConcurrent(List<PaymentRequest> requests) {
// This creates a lightweight virtual thread for every single task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<PaymentResult>> futures = requests.stream()
.map(req -> executor.submit(() -> charge(req)))
.toList();
return futures.stream()
.map(future -> {
try {
return future.get(); // Blocks the virtual thread, NOT the OS thread
} catch (Exception e) {
return new PaymentResult(false, null, "CONCURRENCY_ERROR");
}
})
.toList();
}
}
The results were ridiculous. CPU utilization dropped by 38% and throughput jumped to over 1,200 requests per second. We didn’t touch the business logic at all. We just stopped wasting system resources waiting for network I/O.
If you are doing heavy I/O operations (HTTP calls, database queries), switch to newVirtualThreadPerTaskExecutor(). Just make sure you aren’t using synchronized blocks heavily inside those tasks, as that pins the virtual thread to the carrier thread and ruins the performance gains. Use ReentrantLock instead.
Keep your contracts simple. Handle your network failures explicitly. Let the JVM do the heavy lifting for concurrency. The language has evolved massively—you just have to actually use the new tools instead of
