DEV Community

Avaneesh Yadav
Avaneesh Yadav

Posted on Originally published at buildingai.in

10 Spring Boot Mistakes That Silently Kill Production Apps

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expose pool metrics:

management:
  metrics:
    enable:
      hikaricp: true
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

+ 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());
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 LR
    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.

Top comments (0)