Java
    August 31, 202634 min read

    10 Spring Boot Mistakes That Silently Kill Production Apps

    A practical breakdown of the most common Spring Boot anti-patterns that cause degraded performance, memory leaks, and outages — with before/after code, real query plans, and how to catch them before they reach production.

    Share

    Most Spring Boot production incidents don't come from exotic bugs. They come from the same ten patterns, repeated across codebases, that work fine under light load and silently degrade as traffic grows.

    This guide covers the anti-patterns that production Java systems hit most often — with the exact code that causes them, why they fail, and what to write instead.


    1. @Transactional on Private Methods

    The mistake:

    @Service
    public class OrderService {
    
        // This @Transactional does NOTHING.
        @Transactional
        private void processPayment(Order order) {
            // changes to order are NOT in a transaction
        }
    
        public void submitOrder(Order order) {
            processPayment(order); // direct call, bypasses proxy
        }
    }

    Why it fails: Spring's @Transactional works through a proxy that wraps the bean. When you call a private method — or any method from within the same class — you bypass the proxy and call the raw object directly. No proxy → no transaction. Exceptions don't roll back. Data writes commit even when they shouldn't.

    This is the most common Spring transactional bug. It compiles and passes unit tests. It fails silently in production when a mid-transaction exception leaves the database in a partially-written state.

    The fix:

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final PaymentService paymentService; // separate @Service
    
        public void submitOrder(Order order) {
            paymentService.processPayment(order); // goes through Spring proxy
        }
    }
    
    @Service
    public class PaymentService {
    
        @Transactional // works because external call goes through proxy
        public void processPayment(Order order) { ... }
    }

    If refactoring to a separate class isn't practical, inject self:

    @Service
    public class OrderService {
    
        @Autowired
        private OrderService self; // Spring injects the proxied version
    
        public void submitOrder(Order order) {
            self.processPayment(order); // goes through proxy
        }
    
        @Transactional
        public void processPayment(Order order) { ... }
    }

    Catch it early: Enable Spring's proxy debug logging (logging.level.org.springframework.aop=DEBUG) in a staging environment and verify transactions are opening where expected.


    2. The N+1 Query Problem

    This is the single most common performance issue in JPA-based Spring Boot applications. A page that renders in 50ms under load testing collapses to 4 seconds in production because the dataset grew.

    The mistake:

    // Entity
    @Entity
    public class Order {
        @OneToMany(fetch = FetchType.LAZY)
        private List<LineItem> lineItems;
    }
    
    // Service
    public List<OrderSummary> getOrders() {
        List<Order> orders = orderRepository.findAll(); // 1 query
        return orders.stream()
            .map(o -> new OrderSummary(o, o.getLineItems().size())) // N queries
            .toList();
    }

    For 200 orders: 1 query to fetch orders + 200 queries to fetch each order's line items = 201 queries. At 1000 orders it's 1001 queries. Most ORMs make this invisible — the queries happen lazily, inside the stream, with no stack frame pointing to the cause.

    How to detect it: Enable query logging:

    spring:
      jpa:
        show-sql: true
        properties:
          hibernate:
            format_sql: true
    logging:
      level:
        org.hibernate.SQL: DEBUG
        org.hibernate.orm.jdbc.bind: TRACE

    If you see the same query repeated in a loop with different ID parameters, it's N+1.

    The fix — JOIN FETCH:

    public interface OrderRepository extends JpaRepository<Order, Long> {
    
        @Query("SELECT o FROM Order o LEFT JOIN FETCH o.lineItems WHERE o.status = :status")
        List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
    }

    One query. Fetches orders and their line items in a single JOIN.

    The fix — Projections (when you only need some fields):

    public interface OrderSummaryView {
        String getOrderId();
        BigDecimal getTotal();
        int getItemCount();
    }
    
    public interface OrderRepository extends JpaRepository<Order, Long> {
        List<OrderSummaryView> findByStatus(OrderStatus status);
    }

    Projections let Spring Data generate a SELECT with only the columns you need — no entity materialization, no lazy loading risk.

    The fix — @EntityGraph:

    @EntityGraph(attributePaths = {"lineItems", "lineItems.product"})
    List<Order> findAll();

    Declarative JOIN FETCH without a JPQL query. Cleaner for simple cases.


    3. Loading Entities for Count/Existence Checks

    // Loads the entire entity from DB just to check if it exists
    if (orderRepository.findById(orderId).isPresent()) {
        // ...
    }
    
    // Loads all orders just to count them
    int count = orderService.findAll().size();

    Both are SELECT * queries that transfer full row data across the network, materialize Java objects in heap, then throw most of it away.

    The fix:

    // Existence — single indexed column lookup, no data transfer
    if (orderRepository.existsById(orderId)) { ... }
    
    // Count — aggregation runs on the DB side
    long count = orderRepository.count();
    
    // Conditional count
    long pendingCount = orderRepository.countByStatus(OrderStatus.PENDING);

    For complex conditions:

    @Query("SELECT COUNT(o) FROM Order o WHERE o.status = :status AND o.createdAt > :since")
    long countRecentByStatus(@Param("status") OrderStatus status, @Param("since") LocalDateTime since);

    4. Misconfigured Hikari Connection Pool

    HikariCP ships with maximum-pool-size=10. Under low load this is invisible. Under real traffic it causes connection wait times that cascade into request timeouts.

    # Default — often wrong for production
    spring:
      datasource:
        hikari:
          maximum-pool-size: 10  # too low for most services

    Symptoms: Requests succeed but take 200-500ms longer than expected. Actuator metrics show hikaricp.connections.pending > 0 regularly. Logs show HikariPool - Connection is not available, request timed out.

    The fix — size for your thread model:

    spring:
      datasource:
        hikari:
          # For virtual threads: connections = (number of cores * 2) is a starting point
          # For platform threads: connections = thread pool size
          maximum-pool-size: 20
          minimum-idle: 5
          connection-timeout: 3000    # fail fast — 3s, not the default 30s
          idle-timeout: 600000        # 10 min
          max-lifetime: 1800000       # 30 min — rotate before DB kills idle connections
          keepalive-time: 60000       # prevent firewall from killing idle connections

    Expose pool metrics:

    management:
      metrics:
        enable:
          hikaricp: true

    Then monitor hikaricp.connections.pending and hikaricp.connections.acquire p99. If pending > 0 for more than brief spikes, increase the pool.

    [!NOTE] With virtual threads (Spring Boot 3.2+, spring.threads.virtual.enabled=true), the optimal pool size is lower than with platform threads — virtual threads park during I/O so fewer connections handle more concurrent requests. Start at 2× core count and tune from metrics.


    5. @Transactional(readOnly = true) Left Off Read Queries

    // Missing readOnly — Spring opens a full R/W transaction
    @Transactional
    public List<Order> findRecentOrders(LocalDate since) {
        return orderRepository.findByCreatedAtAfter(since.atStartOfDay());
    }

    readOnly = true on a read-only transaction:

    • Tells Hibernate to skip dirty checking (no snapshot comparison at flush time)
    • Lets the JPA provider skip the write-ahead log
    • Some databases route read-only transactions to read replicas automatically

    The fix:

    @Transactional(readOnly = true)
    public List<Order> findRecentOrders(LocalDate since) {
        return orderRepository.findByCreatedAtAfter(since.atStartOfDay());
    }

    Low effort, measurable improvement under read-heavy load. On services with 80% read traffic, this alone reduces database write-lock contention.


    6. Missing Database Indexes on JPA Foreign Keys

    JPA doesn't automatically create indexes on foreign key columns. It creates the constraint, not the index. This causes full table scans on every JOIN against a large table.

    @Entity
    public class LineItem {
        @ManyToOne
        @JoinColumn(name = "order_id")  // creates FK constraint, NOT an index
        private Order order;
    }

    Finding all line items for an order does SELECT * FROM line_item WHERE order_id = ?. Without an index on order_id, this is a full scan of the entire line_item table.

    The fix — declare the index explicitly:

    @Entity
    @Table(name = "line_item",
        indexes = @Index(name = "idx_line_item_order_id", columnList = "order_id"))
    public class LineItem {
        @ManyToOne
        @JoinColumn(name = "order_id")
        private Order order;
    }

    Or via Liquibase/Flyway (preferred for production — schema migrations in code):

    -- V3__add_line_item_order_index.sql
    CREATE INDEX idx_line_item_order_id ON line_item(order_id);

    Finding missing indexes: Run EXPLAIN ANALYZE on your slowest queries. Any Seq Scan on a large table that filters by a FK column is a missing index candidate.


    7. Leaking Hibernate Sessions Into Jackson Serialization

    @Entity
    public class Order {
        @OneToMany(fetch = FetchType.LAZY)
        private List<LineItem> lineItems;
    }
    
    // Controller — Open Session in View is ON by default
    @GetMapping("/orders/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderRepository.findById(id).orElseThrow();
        // Jackson serializes Order → touches lineItems → Hibernate loads lazily
        // If session is closed: LazyInitializationException
        // If session is open (OSIV): unexpected extra query per request
    }

    Spring Boot enables Open Session in View (OSIV) by default. This keeps the Hibernate session open through the entire request, including serialization. This means:

    • Lazy collections load silently during JSON serialization (N+1 hidden in Jackson)
    • Session-per-request model doesn't compose with virtual threads
    • Database connections held for the full request lifecycle, not just the DB operation

    The fix — disable OSIV, use DTOs:

    spring:
      jpa:
        open-in-view: false  # disable OSIV
    // Fetch everything you need while the session is open
    @Transactional(readOnly = true)
    public OrderResponse getOrder(Long id) {
        Order order = orderRepository.findByIdWithItems(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    
        // Map to a DTO — no Hibernate proxy, safe to serialize
        return OrderResponse.from(order);
    }
    
    public record OrderResponse(String orderId, BigDecimal total, List<LineItemResponse> items) {
        static OrderResponse from(Order o) {
            return new OrderResponse(
                o.getId().toString(),
                o.getTotal(),
                o.getLineItems().stream().map(LineItemResponse::from).toList()
            );
        }
    }

    8. Unbounded @Async Thread Pools

    @Configuration
    @EnableAsync
    public class AsyncConfig implements AsyncConfigurer {
        // No custom config — Spring defaults to SimpleAsyncTaskExecutor
        // SimpleAsyncTaskExecutor creates a new thread per task, no limit
    }
    
    @Service
    public class NotificationService {
        @Async
        public void sendEmail(String to, String subject) {
            // ...
        }
    }

    SimpleAsyncTaskExecutor (the default when no pool is configured) creates a new thread per task with no upper bound. Under sustained load this spins up thousands of threads and OOMs the service.

    The fix — explicit bounded pool:

    @Configuration
    @EnableAsync
    public class AsyncConfig implements AsyncConfigurer {
    
        @Override
        public Executor getAsyncExecutor() {
            ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
            executor.setCorePoolSize(4);
            executor.setMaxPoolSize(20);
            executor.setQueueCapacity(500);       // tasks queue before rejecting
            executor.setThreadNamePrefix("async-");
            executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
            executor.initialize();
            return executor;
        }
    }

    CallerRunsPolicy is usually the right rejection handler for background tasks — it runs the task on the caller's thread rather than throwing. This creates natural backpressure: if the async pool is saturated, the calling thread slows down instead of the service crashing.

    For virtual threads (Java 21+):

    @Bean
    public Executor asyncExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor(); // unbounded, but virtual threads are cheap
    }

    Virtual thread executors are safe to make unbounded because virtual threads don't block OS threads during I/O and cost ~1KB each.


    9. Exception Swallowing in Background Jobs

    @Component
    public class ReconciliationJob {
    
        @Scheduled(cron = "0 0 2 * * *")
        public void run() {
            try {
                reconcile();
            } catch (Exception e) {
                log.error("Reconciliation failed", e);
                // returns normally — scheduler sees success
                // no alert, no retry, no escalation
            }
        }
    }

    The scheduler sees this method return without exception and marks it successful. The error is in the log. Without a log alert configured, the failure is invisible. Reconciliation silently stops running.

    This pattern — catch, log, swallow — is how data inconsistencies accumulate over months without anyone noticing.

    The fix — let exceptions propagate, or use structured alerting:

    @Scheduled(cron = "0 0 2 * * *")
    public void run() {
        try {
            reconcile();
        } catch (Exception e) {
            // Send an alert before rethrowing
            alertService.sendAlert("Reconciliation failed: " + e.getMessage(), AlertSeverity.HIGH);
            meterRegistry.counter("job.reconciliation.failure").increment();
            throw new RuntimeException("Scheduled reconciliation failed", e);
            // Spring will log this as an error — also visible in Actuator health
        }
    }

    Or better — use Temporal or Spring Batch for jobs that need fault tolerance and visibility (see the Temporal guide).


    10. Logging in the Hot Path With String Concatenation

    // Evaluated on every call, even when DEBUG logging is off
    log.debug("Processing order " + order.getId() + " for customer " + customer.getName());

    + string concatenation creates intermediate String objects on every call. At 10,000 requests/minute, even a disabled DEBUG statement that runs on every request produces garbage that GC must collect, adding latency spikes.

    The fix — parameterized logging:

    // SLF4J defers evaluation — no string created if DEBUG is off
    log.debug("Processing order {} for customer {}", order.getId(), customer.getName());

    The {} placeholders are only evaluated if the logger is actually at DEBUG level. Zero allocation in production where DEBUG is off.

    For complex log objects that are expensive to produce:

    // Supplier form — only called if DEBUG is on
    log.debug("Order state: {}", () -> computeExpensiveOrderDump(order));

    The Production Checklist

    Before a Spring Boot service goes to production, run through this:

    • Grep for @Transactional on private methods — fix any found
    • Run queries against staging data at 10× production volume — look for N+1 patterns in query logs
    • Disable OSIV (spring.jpa.open-in-view: false) and fix resulting LazyInitializationExceptions
    • Run EXPLAIN ANALYZE on your top 10 slowest queries — add missing indexes
    • Add @Transactional(readOnly = true) to all read-only service methods
    • Verify Hikari pool size matches your threading model — expose pool metrics in Actuator
    • Configure a named, bounded ThreadPoolTaskExecutor for @Async tasks
    • Verify all @Scheduled jobs either let exceptions propagate or send alerts before swallowing
    • Replace log.debug("text" + var) with parameterized log.debug("text {}", var) in hot paths
    • Set management.health.db.enabled=true and test liveness/readiness probes
    graph TD A[Spring Boot Service] --> B{Code Review} B --> C[Transaction Check] B --> D[Query Analysis] B --> E[Pool Config] B --> F[Async Config] C --> G[No private @Transactional] D --> H[No N+1 — JOINs or projections] D --> I[Indexes on FK columns] E --> J[Bounded Hikari pool] E --> K[Pool metrics exposed] F --> L[Bounded executor configured] G --> M[Production Ready] H --> M I --> M J --> M K --> M L --> M

    Catching These Before Production

    Static analysis. SpotBugs + find-sec-bugs catches some of these (including the @Transactional private method issue). Add it to your Maven/Gradle build.

    Query logging in staging. Enable org.hibernate.SQL: DEBUG in your staging environment against a dataset that's at least 10% of production volume. Any N+1 pattern shows up as repeated identical queries.

    Load testing. k6, Gatling, or even ab at 2× expected peak. Watch HikariCP's connections.pending, Hibernate's queries.execution p99, and JVM GC pause time. Spikes in any of these under load point to one of the issues above.

    Production metrics. Every Spring Boot 3 service should export to an observability backend. Dashboards to have:

    • DB connection pool saturation (hikaricp.connections.pending > 0)
    • Slow query p99 (> 100ms threshold)
    • GC pause duration (> 50ms threshold)
    • Heap utilization trend (monotonic growth = memory leak)

    These patterns aren't obscure. They appear in production codebases at companies of every size, written by engineers who knew the frameworks well. The difference between a service that holds up under load and one that requires an incident at 2 AM is usually caught before deployment, not after.


    Spotted one of these in your codebase? Or have a production anti-pattern that isn't on this list? Find me on LinkedIn — the list grows from war stories.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments