Back to Blog
production outagesperformance debuggingcase studiesJava disastersincident responselessons learned

Real-World Java Performance Disasters: 7 Stories From Production

Learn from others' mistakes. Real production outages caused by N+1 queries, memory leaks, connection pools, and thread exhaustion. How they were diagnosed and fixed.

J

JOptimize Team

September 30, 2026· 16 min read

When Performance Issues Go Wrong: 7 Production Disasters

These are true stories from production. Names and companies have been changed. The lessons remain eternal.


Disaster 1: The Black Friday N+1 Query

The Incident

Company: E-Commerce Platform (revenue: $50M/year) Time: Black Friday, 10:47 AM (peak traffic) Duration: 47 minutes Cost: $350k in lost sales

What Happened

// ProductController.java @GetMapping("/products/{id}") public Product getProduct(@PathVariable Long id) { Product p = productRepository.findById(id).orElse(null); // Populate reviews (lazy load triggers here) p.getReviews().forEach(r -> { // Another lazy load per review r.getUser().getProfile(); }); return p; }

The problem:

  • 1 request for product
  • 20 lazy loads for reviews
  • 20 lazy loads for user profiles
  • Total: 41 database queries per endpoint call

At scale:

  • 100 concurrent users
  • Each hits endpoint every 30 seconds
  • 41 queries × 200 req/sec = 8,200 queries/sec
  • Database connection pool exhausted (max 50 connections)
  • Users see infinite loading spinners

The Fix (15 Minutes)

@Query("SELECT DISTINCT p FROM Product p " + "LEFT JOIN FETCH p.reviews r " + "LEFT JOIN FETCH r.user u " + "WHERE p.id = ?1") Product findByIdWithReviewsAndUsers(Long id);

Result:

  • Queries: 41 → 1 per request
  • Response time: 2.1s → 45ms
  • Database load: 8,200 → 200 queries/sec
  • System back to normal

Lesson

Always check for N+1 patterns. A code review would have caught this in 2 minutes. An automated tool (like JOptimize) would have flagged it immediately.


JOptimize detects this automatically

Scan your Java project in 30 seconds — free, no credit card.

Try free →

Disaster 2: The Memory Leak That Waited 6 Months

The Incident

Company: SaaS Payment Processor Time: 2 AM on a Tuesday (maintenance window) Duration: 4 hours until restart Cost: $100k in failed transactions

What Happened

The app ran fine in testing. Fine in staging. But 6 months into production, the memory usage graph started climbing:

Day 1:   400MB heap used
Day 30:  600MB heap used
Day 60:  750MB heap used
Day 180: 3800MB heap used (4GB limit)
Day 181: OutOfMemoryError → Crash

The culprit:

public class PaymentService { private static Map<String, PaymentRecord> cache = new HashMap<>(); // ❌ Unbounded public void processPayment(String transactionId, Payment p) { cache.put(transactionId, p); // Grows forever // ... actual processing ... } }

With 5000 payments/day, after 6 months:

  • 5000 × 180 = 900,000 entries in map
  • Each entry: ~5KB
  • Total: 4.5GB memory leak

The Fix (30 Minutes)

private static final int MAX_CACHE_SIZE = 10000; private static Map<String, PaymentRecord> cache = new LinkedHashMap<String, PaymentRecord>(MAX_CACHE_SIZE, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry eldest) { return size() > MAX_CACHE_SIZE; } };

Result:

  • Memory stabilizes at 600MB
  • No more OutOfMemory errors
  • System runs indefinitely

Lesson

Unbounded caches are time bombs. They will explode in production, guaranteed. Always set max size, TTL, or use a proper cache library.


Disaster 3: The Connection Pool Deadlock

The Incident

Company: Financial Services Firm Time: 9:30 AM Monday Duration: 90 minutes until database restart Cost: $50k in manual trading losses

What Happened

The app had a connection pool of 20 connections. During a data migration, something unexpected happened:

public void migrateData() { Connection conn = dataSource.getConnection(); // Takes 1 pool connection try { processLargeDataset(); } finally { conn.close(); } } public void processLargeDataset() { List<Item> items = fetchAllItems(); // Tens of thousands for (Item item : items) { Connection c = dataSource.getConnection(); // ❌ Pool exhaustion // ... 20 more connections taken ... // Outer connection still holding connection #1 // Deadlock: outer waits for #21 that will never come } }

Result:

  • Outer thread: holds connection 1, waits for connection 21 (doesn't exist)
  • All other threads: queued waiting for connections
  • New requests: timeout after 30 seconds
  • System unresponsive

The Fix (But Too Late)

public void processLargeDataset() { List<Item> items = fetchAllItems(); for (Item item : items) { try (Connection c = dataSource.getConnection()) { // Returns immediately // ... process ... } } }

Better: Batch processing

public void processLargeDataset() { List<List<Item>> batches = partition(fetchAllItems(), 100); // Batch of 100 for (List<Item> batch : batches) { try (Connection c = dataSource.getConnection()) { processItems(c, batch); } } }

Lesson

Never hold connections longer than necessary. Nested connection requests will exhaust the pool. Always close immediately after use (try-with-resources).


Disaster 4: The Garbage Collection Pause

The Incident

Company: High-Frequency Trading Firm Time: 2:15 PM Thursday Duration: 3 seconds (eternity in trading) Cost: $2M in missed arbitrage opportunities

What Happened

The app hit 4GB of heap usage. The JVM ran a Full GC:

Full GC started: 2:15:32.000
Scanned heap: 2:15:35.847
Full GC ended:   2:15:35.847

Total pause: 3,847 milliseconds

Quote arrived at 2:15:32.500 (+500ms into pause)
Response should send at 2:15:32.525
Actual response sent at 2:15:35.875

Loss: 3,350ms × $600,000/sec = $2,010,000

Root cause:

public void processQuotes(List<Quote> quotes) { quotes.forEach(q -> { byte[] data = new byte[1_000_000]; // 1MB per quote processQuote(q, data); // data not explicitly freed = waits for GC }); // Processing millions of quotes = millions of MB created // Heap fills, Full GC triggered }

The Fix

public void processQuotes(List<Quote> quotes) { byte[] reusableBuffer = new byte[1_000_000]; // Reuse single buffer for (Quote q : quotes) { processQuote(q, reusableBuffer); // Array reused, minimal GC } }

Plus GC tuning:

java -Xmx8g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=100 \ -XX:G1ReservedPercentage=10 \ MyTradingApp

Result:

  • GC pause: 3,847ms → 45ms (85x improvement)
  • Missed opportunities: 0

Lesson

Minimize object allocation in hot paths. Reuse objects, use object pools, and tune GC aggressively for low-latency apps.


Disaster 5: The Runaway Thread Pool

The Incident

Company: Microservices Platform Time: 11 AM (typical traffic) Duration: 2 hours Cost: Service degradation for millions

What Happened

@Service public class EmailService { private ExecutorService executor = Executors.newCachedThreadPool(); // ❌ Unbounded public void sendBatch(List<Email> emails) { for (Email e : emails) { executor.submit(() -> sendEmail(e)); // Creates new thread per email } } }

With 10,000 emails to send:

  • 10,000 threads created
  • Each thread uses 1MB stack
  • Total: 10GB memory
  • OS can't handle 10k threads
  • Everything grinds to halt

The Fix

@Configuration public class ThreadPoolConfig { @Bean public ExecutorService emailExecutor() { return Executors.newFixedThreadPool(10); // Max 10 threads } }

Or Spring async:

@Configuration @EnableAsync public class AsyncConfig { @Bean("taskExecutor") public AsyncTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(500); return executor; } }

Lesson

Never create unbounded thread pools. Always set core size, max size, and queue capacity. Better: use Spring's async or a job queue (RabbitMQ, Kafka).


Disaster 6: The String Concatenation Loop

The Incident

Company: Log Aggregation Service Time: 6 PM (peak logging) Duration: 8 minutes Cost: Lost logs for all customers

What Happened

public String formatLogs(List<LogEntry> entries) { String result = ""; // ❌ String is immutable for (LogEntry e : entries) { result += e.toString(); // Creates new String each iteration } return result; }

Memory explosion:

Iteration 1:   1 String (10 bytes)
Iteration 2:   2 Strings (20 bytes total)
Iteration 3:   3 Strings (30 bytes total)
...
Iteration 10k: 10k Strings (100MB total)

With 1000 requests/sec all doing this:

  • 10 billion strings created per second
  • Heap fills instantly
  • Full GC every 100ms
  • App unresponsive

The Fix

public String formatLogs(List<LogEntry> entries) { StringBuilder sb = new StringBuilder(); // Reusable buffer for (LogEntry e : entries) { sb.append(e.toString()); } return sb.toString(); }

Result:

  • Strings created: 10,000 → 1
  • Memory: 100MB → 1KB
  • Time: 500ms → 5ms (100x faster)

Lesson

Never use String concatenation in loops. Always use StringBuilder. Modern IDEs warn about this; if you're not seeing the warning, enable the inspection.


Disaster 7: The Debug Logging Trap

The Incident

Company: API Gateway Time: 3 PM Wednesday Duration: 15 minutes (until restart) Cost: 50% traffic loss

What Happened

Someone added debug logging for troubleshooting:

@Aspect @Component public class LoggingAspect { @Around("execution(* com.example..*(..))") // ALL methods public Object log(ProceedingJoinPoint pjp) throws Throwable { System.out.println("Method: " + pjp.getSignature()); long start = System.currentTimeMillis(); Object result = pjp.proceed(); System.out.println("Result: " + result); // ❌ Logs EVERYTHING System.out.println("Time: " + (System.currentTimeMillis() - start)); return result; } }

With 10,000 requests/sec:

  • Each request generates 3 log lines
  • 30,000 lines/sec
  • Each line: ~200 bytes = 6MB/sec
  • Log file: 360MB per minute
  • Disk fills
  • App crashes

The Fix

@Aspect @Component public class LoggingAspect { private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class); @Around("@annotation(LogExecutionTime)") // Only annotated methods public Object log(ProceedingJoinPoint pjp) throws Throwable { if (log.isDebugEnabled()) { // Only if debug enabled log.debug("Executing: {}", pjp.getSignature()); } // ... rest ... } }

Plus logging configuration:

logging: level: com.example: INFO # Only INFO and above file: max-size: 100MB # Rotate logs max-history: 7 # Keep 7 days

Lesson

Debug logging must be configurable. Always use SLF4J with level checks. Never hardcode System.out.println() in production code.


The Common Thread

All 7 disasters share a pattern:

  1. Development: "This will never be a problem"
  2. Testing: Works fine with 100 test items
  3. Production: 1 million real items = catastrophe
  4. Post-Mortem: "We should have tested this"

How JOptimize Prevents These

JOptimize detects:

  • ✅ N+1 query patterns (Disaster 1)
  • ✅ Unbounded caches and collections (Disaster 2)
  • ✅ Connection pool exhaustion (Disaster 3)
  • ✅ Excessive object allocation in loops (Disasters 4, 6)
  • ✅ Unbounded thread pools (Disaster 5)
  • ✅ Debug logging in hot paths (Disaster 7)

None of these make it to production.


The Takeaway

Performance disasters aren't acts of God—they're predictable patterns that repeat. Once you know them, you can:

  1. Avoid them during development
  2. Catch them in testing
  3. Prevent them from reaching production

Start catching patterns: joptimize.io

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.