Spoiler alert: It wasn’t just about writing better code
Hey everyone! 👋
Back to basics with this week article in which I want to share something that happened in my recent project that completely changed how I think about microservices performance. It’s one of those stories that starts with “everything was working fine” and ends with “wait, why didn’t I do this earlier?”
Picture this… You’re building what you think is a solid e-commerce platform with Spring Boot 3.x and Java 21. Six microservices, all talking to each other, handling user orders, processing payments, managing inventory. Everything works like a charm in my pc, but then you hit production traffic and… well, let’s just say the performance wasn’t exactly what you’d call “enterprise-grade.”
I was seeing response times of 800ms, services consuming 2.5GB of memory each, and don’t even get me started on the database connection issues. It was like watching a traffic jam in slow motion .
The breakthrough came when I realized, I was thinking about performance all wrong. I was focused on optimizing individual pieces of code, when the real bottlenecks were in how the services communicated and shared resources. I was totally rejecting the truth of metrics (do not try this at home) and I was trying to “optimize” a platform blindly. And then … There was the moment …
Here’s what I discovered:
Every time a service needed data, it was creating a new database connection. It’s like having a restaurant where every customer has to build their own table before they can eat.
The Problem: 100+ concurrent connections, each of them with 2-3 second connection times
The Solution: Old but Gold … HikariCP connection pooling with optimized configurations
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
The Result: An immediate 65% reduction in connection time (from 2-3s to 0.7s). Concurrent connections dropped from 100+ to just 30, resulting in more than 30% reduction.
My inventory service was hitting PostgreSQL for every single product lookup. Seriously? Haven’t I heard about the cache?
The Problem: 200ms response time for every product lookup
The Solution: Multi-layer Redis caching strategy
@Cacheable(value = "products", key = "#sku")
public Product findBySku(String sku) {
return productRepository.findBySku(sku);
}
@CacheEvict(value = "products", key = "#product.sku")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
The Result: 85% cache hit ratio and the response times dropped from 200ms to 15ms for cached data (that’s 92% faster!)
And here comes the glow … I was running services in Docker containers, but I hadn’t optimized the JVM for containerized environments. It’s like trying to fit a full-size car engine in a compact car. It works, but it’s not efficient.
The Problem: 2.5GB memory usage per service and quite often GC pauses
The Solution: JVM tuning for containerized environments
JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication -XX:+OptimizeStringConcat"
The Result: Almost 50% reduction in memory footprint (2.5GB → 1GB) with 60% fewer GC pauses
But here’s where it got really interesting. I was still thinking synchronously, like service A calls service B, waits for a response, then calls service C. It’s like having a conversation where you can only talk to one person at a time.
The Problem: Sequential processing causing cascading delays (Order → Payment → Inventory)
The Solution: Event-driven architecture with Apache Kafka
// Order Service - Publishing events
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
kafkaTemplate.send("order-events", event);
log.info("Order {} published to Kafka", event.getOrderId());
}
// Payment Service - Consuming events
@KafkaListener(topics = "order-events")
public void processOrderEvent(OrderCreatedEvent event) {
paymentService.processPayment(event.getOrderId());
}
// Inventory Service - Consuming events
@KafkaListener(topics = "order-events")
public void updateInventory(OrderCreatedEvent event) {
inventoryService.reserveItems(event.getOrderItems());
}
The Result: 80% reduction in end-to-end processing time (from 2.5s to 0.5s) and 99.9% message delivery reliability
And Ok I understand this story seems interesting and the solutions a little bit naive, but let’s dive deeper to what really matters, which is nothing else, but the business impact:
But here’s the kicker, this translated to real business value. We’re talking about saving thousands of money monthly while delivering a better user experience.
If I could go back and give my past self some advice, here’s what I’d say:
You can’t fix what you can’t measure. I spent weeks optimizing the wrong things because I didn’t have good visibility into where the real bottlenecks were.
// Add this to see what's actually slow
@Timed(name = "product.lookup", description = "Time taken to lookup product")
public Product findBySku(String sku) {
return productRepository.findBySku(sku);
}
Not everything needs caching, but the things that do need it, they really need it. Focus on the 20% of data that’s accessed 80% of the time.
Circuit breakers aren’t just for handling external service failures, but also they’re protecting your entire system from cascading failures. Resilience4j saved me more times, than I can count.
@CircuitBreaker(name = "payment-service", fallbackMethod = "fallbackPayment")
public Payment processPayment(PaymentRequest request) {
return paymentProvider.process(request);
}
public Payment fallbackPayment(PaymentRequest request, Exception ex) {
log.warn("Payment service down, using fallback");
return Payment.builder()
.status(PaymentStatus.PENDING)
.message("Payment will be processed when service is available")
.build();
}
It’s not something you bolt on at the end. It needs to be part of your architecture decisions from the beginning. (It’s not a Leetcode challenge to start with BruteForce and then to optimize)
Here’s something they don’t tell you in the technical documentation: performance optimization isn’t just about faster code. It’s about building systems that your team can actually work with, debug, and maintain.
When your services respond in 480ms instead of 800ms, your developers spend less time waiting for tests to run. When you have proper monitoring, you spend less time in war rooms trying to figure out what’s broken. When your system is resilient, you sleep better at night.
This project taught me that performance optimization is never really “done.” It’s an ongoing conversation between your code, your infrastructure and your business requirements. Every new feature is an opportunity to optimize and every optimization is a chance to learn something new.
I’m curious on what performance challenges have you faced in your projects? Have you had any “aha!” moments that completely changed how you approach optimization? I’d love to hear your stories in the comments below.
And if you want to dive deeper into the technical details, I’ve open-sourced the entire project on GitHub. It’s a great example of modern microservices architecture with all the performance optimizations we discussed.
Thanks for reading, and remember… Sometimes the best optimization is the one that makes your system more human-friendly, not just faster.
Check out the complete project: github.com/mikopos/springboot-flagship
#Microservices #SpringBoot #PerformanceOptimization #Java #SoftwareEngineering #TechLeadership #DevOps #CloudComputing #CareerGrowth