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.
JOptimize Team
These are true stories from production. Names and companies have been changed. The lessons remain eternal.
Company: E-Commerce Platform (revenue: $50M/year) Time: Black Friday, 10:47 AM (peak traffic) Duration: 47 minutes Cost: $350k in lost sales
// 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:
At scale:
@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:
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.
Company: SaaS Payment Processor Time: 2 AM on a Tuesday (maintenance window) Duration: 4 hours until restart Cost: $100k in failed transactions
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:
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:
Unbounded caches are time bombs. They will explode in production, guaranteed. Always set max size, TTL, or use a proper cache library.
Company: Financial Services Firm Time: 9:30 AM Monday Duration: 90 minutes until database restart Cost: $50k in manual trading losses
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:
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); } } }
Never hold connections longer than necessary. Nested connection requests will exhaust the pool. Always close immediately after use (try-with-resources).
Company: High-Frequency Trading Firm Time: 2:15 PM Thursday Duration: 3 seconds (eternity in trading) Cost: $2M in missed arbitrage opportunities
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 }
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:
Minimize object allocation in hot paths. Reuse objects, use object pools, and tune GC aggressively for low-latency apps.
Company: Microservices Platform Time: 11 AM (typical traffic) Duration: 2 hours Cost: Service degradation for millions
@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:
@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; } }
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).
Company: Log Aggregation Service Time: 6 PM (peak logging) Duration: 8 minutes Cost: Lost logs for all customers
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:
public String formatLogs(List<LogEntry> entries) { StringBuilder sb = new StringBuilder(); // Reusable buffer for (LogEntry e : entries) { sb.append(e.toString()); } return sb.toString(); }
Result:
Never use String concatenation in loops. Always use StringBuilder. Modern IDEs warn about this; if you're not seeing the warning, enable the inspection.
Company: API Gateway Time: 3 PM Wednesday Duration: 15 minutes (until restart) Cost: 50% traffic loss
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:
@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
Debug logging must be configurable. Always use SLF4J with level checks. Never hardcode System.out.println() in production code.
All 7 disasters share a pattern:
JOptimize detects:
None of these make it to production.
Performance disasters aren't acts of God—they're predictable patterns that repeat. Once you know them, you can:
Start catching patterns: joptimize.io
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.