DEV Community

sandeep chagalakonda
sandeep chagalakonda

Posted on

"Why Your Spring Boot API is Slow: The N+1 Query Problem (And How I Fixed It in Production)"

 I was debugging a Spring Boot API at 2 AM on a Tuesday when I realized something that should have been obvious: my database query was being executed 500 times per request.

Not 5 times. Not 50 times.

Five. Hundred. Times.

One single user request → 500 SQL queries → API response time: 3.2 seconds.

That's when I learned about the N+1 query problem. And how it nearly destroyed production.

The Setup: Everything Seemed Fine
Our food delivery microservice was working great. Orders were being processed, customers were happy, and performance looked good in development.

Then we hit production traffic.

Day 1: Latency: 200ms ✅ Day 5: Latency: 500ms ⚠️ Day 10: Latency: 1.2 seconds 🔴 Day 15: Latency: 3+ seconds 💥

Our API was getting slower every single day. And we had no idea why.

I grabbed a profiler and started investigating. That's when I found it: the N+1 query catastrophe.

What Is the N+1 Query Problem?
Imagine you want to fetch a list of orders with their customers.

The naive approach:

@entity
public class Order {
@id
private Long id;
private String orderNumber;

@ManyToOne
private Customer customer; // This relationship is the problem
Enter fullscreen mode Exit fullscreen mode

}

@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;

public List<Order> getAllOrders() {
    return orderRepository.findAll(); // Query 1: Get all orders
    // For each order, fetch customer // Query 2, 3, 4, 5...
}
Enter fullscreen mode Exit fullscreen mode

}
Here's what happens in your database:

-- Query 1: Get all orders (1 query)
SELECT * FROM orders;

-- Query 2: Get customer for order 1 (N queries)
SELECT * FROM customers WHERE id = 101;

-- Query 3: Get customer for order 2
SELECT * FROM customers WHERE id = 102;

-- Query 4: Get customer for order 3
SELECT * FROM customers WHERE id = 103;

-- ... repeat for every single order ...
If you fetch 500 orders:

1 query to get orders
500 queries to get each customer
Total: 501 queries
That's the N+1 problem. You execute 1 query, then N more queries (one per result).

At scale, this destroys performance.

How I Discovered It (The Hard Way)
I was looking at our order endpoint logs:

GET /api/v1/orders
Database queries: 487
Query time: 2.8 seconds
487 queries for a single API request.

I added Spring Boot's query logging to see what was happening:

application.properties

spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
Then I made a single API call. Here's what the logs showed:

Hibernate: select order0_.id, order0_.customer_id, order0_.order_number, order0_.total_amount from orders order0_ limit 100
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
... (repeat 95 more times)
My jaw dropped.

Hibernate was fetching the customer for every single order, one at a time. It was supposed to be doing one fetch. Instead, it was doing 100+ queries.

This is classic N+1 query problem.

The Root Cause: Lazy Loading
By default, JPA uses lazy loading for relationships:

@ManyToOne(fetch = FetchType.LAZY) // Default behavior
private Customer customer;
This means:

When you load an Order, the Customer is NOT loaded
When you access order.getCustomer(), JPA fetches it then
If you have 500 orders and access customer on each one → 500 queries
This was my code:

@GetMapping("/orders")
public ResponseEntity> getAllOrders() {
List orders = orderService.getAllOrders();

// This loop triggers the N+1 problem
List<OrderDTO> dtos = orders.stream()
    .map(order -> new OrderDTO(
        order.getId(),
        order.getOrderNumber(),
        order.getCustomer().getName() // Query executed here! 
    ))
    .collect(Collectors.toList());

return ResponseEntity.ok(dtos);
Enter fullscreen mode Exit fullscreen mode

}
Every time the code accessed order.getCustomer().getName(), Hibernate fired a separate SQL query.

Result: 1 query to get orders + 500 queries to get customers = 501 total queries.

The Fix #1: Eager Loading (The Quick Fix)
The simplest solution: tell JPA to fetch the customer when loading the order.

@entity
public class Order {
@id
private Long id;

@ManyToOne(fetch = FetchType.EAGER) // Change to EAGER
private Customer customer;
Enter fullscreen mode Exit fullscreen mode

}
Now Hibernate does:

SELECT order0_.id, order0_.customer_id, order0_.order_number, customer1_.id, customer1_.name, customer1_.email
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. 500 results.

Result:

Before: 501 queries, 2.8 seconds
After: 1 query, 120ms
That's 23x faster.

Why This Isn't Always The Answer
Problem: Eager loading loads customers even if you don't need them.

Example: If you have another endpoint that just needs order numbers:

@GetMapping("/orders/numbers")
public List getOrderNumbers() {
List orders = orderRepository.findAll(); // Loads 500 customers unnecessarily
return orders.stream()
.map(Order::getOrderNumber)
.collect(Collectors.toList());
}
Now you're loading data you don't use. Wastes memory and database resources.

Better approach: Use eager loading only where you need it.

The Fix #2: Fetch Join (The Proper Fix)
Instead of changing the entity, use JPQL fetch join in your query:

@Repository
public interface OrderRepository extends JpaRepository {

@Query("SELECT DISTINCT o FROM Order o " +
       "LEFT JOIN FETCH o.customer c " +
       "WHERE o.id IN :orderIds")
List<Order> findOrdersWithCustomers(@Param("orderIds") List<Long> orderIds);

// Or get all with customers
@Query("SELECT DISTINCT o FROM Order o " +
       "LEFT JOIN FETCH o.customer c")
List<Order> findAllWithCustomers();
Enter fullscreen mode Exit fullscreen mode

}
Now update your service:

@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;

public List<Order> getAllOrdersWithCustomers() {
    return orderRepository.findAllWithCustomers(); // Uses fetch join
}
Enter fullscreen mode Exit fullscreen mode

}
Hibernate generates:

SELECT DISTINCT order0_.id, order0_.customer_id, order0_.order_number,
customer1_.id, customer1_.name, customer1_.email
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. All data loaded.

Why this is better:

✅ Only loads customer when you need it
✅ Still uses one query (no N+1 problem)
✅ You control when eager loading happens
✅ Different queries can load different relationships
The Fix #3: Projection (The Advanced Fix)
Sometimes you don't need the full Order object. You just need specific fields:

public interface OrderDTO {
Long getId();
String getOrderNumber();
String getCustomerName(); // Comes from customer table
BigDecimal getTotalAmount();
}

@Repository
public interface OrderRepository extends JpaRepository {

@Query("SELECT new com.example.dto.OrderDTO(" +
       "o.id, o.orderNumber, c.name, o.totalAmount) " +
       "FROM Order o " +
       "LEFT JOIN o.customer c")
List<OrderDTO> findAllOrderDTOs();
Enter fullscreen mode Exit fullscreen mode

}
Hibernate generates:

SELECT order0_.id, order0_.order_number, customer1_.name, order0_.total_amount
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. Only the fields you need.

Why this is best for APIs:

✅ Single query (no N+1)
✅ Returns DTO directly (no mapping overhead)
✅ Database returns only needed columns
✅ Fastest option for REST responses
The Real-World Fix (What I Did)
In production, I did all three:

  1. Identified N+1 queries:

Enable query logging to spot N+1 problems

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=debug

  1. Fixed the critical endpoints with fetch join:

@GetMapping("/api/v1/orders")
public ResponseEntity> getAllOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {

// Uses fetch join - single query with pagination
Page<OrderDTO> orders = orderRepository.findAllOrderDTOs(
    PageRequest.of(page, size)
);

return ResponseEntity.ok(orders.getContent());
Enter fullscreen mode Exit fullscreen mode

}

  1. Added Spring Data Specification for complex queries:

@Repository
public interface OrderRepository extends
JpaRepository,
JpaSpecificationExecutor {

// Specifications handle complex queries efficiently
Enter fullscreen mode Exit fullscreen mode

}

@Service
public class OrderService {
public List searchOrders(OrderSearchCriteria criteria) {
return orderRepository.findAll((root, query, cb) -> {
Join customerJoin = root.join("customer", JoinType.LEFT);

        // Complex query with joins - still single query
        Predicate predicate = cb.and(
            cb.like(customerJoin.get("name"), criteria.getCustomerName() + "%"),
            cb.greaterThan(root.get("totalAmount"), criteria.getMinAmount())
        );

        return predicate;
    });
}
Enter fullscreen mode Exit fullscreen mode

}
The Results (Before & After)
Metric Before After Improvement
Queries per request 487 1 487x ↓
Response time 2.8s 120ms 23x ↓
Database load 95% CPU 15% CPU 80% ↓
Customer complaints 47 0 100% ↓
After the fix:

Orders endpoint latency: 120ms (was 2.8s)
Database CPU dropped from 95% to 15%
Concurrent users increased from 100 to 500 without slowdown
Zero customer complaints about slow orders
How To Prevent This In The Future

  1. Enable query counting in development:

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=debug

  1. Monitor queries in your tests:

@test
@Transactional
public void testOrderFetch() {
// Hibernate counts queries during test
List orders = orderService.getAllOrders();

// If this shows > 1 query, N+1 problem detected
// The test fails before production sees it
Enter fullscreen mode Exit fullscreen mode

}

  1. Use database profiling tools:

MySQL: Enable query logs and analyze slow queries
PostgreSQL: Enable auto_explain
Spring Boot Actuator: Monitor database metrics

  1. Code review checklist:

When you see this in a PR:

List orders = orderRepository.findAll();
orders.forEach(order -> {
String customerName = order.getCustomer().getName(); // RED FLAG
});
Ask: "Is there a way to fetch this with a single query?"

The Lesson
The N+1 query problem is invisible until it hits production.

It doesn't show up in:

❌ Unit tests (usually tiny datasets)
❌ Local development (cache hides the problem)
❌ Early production (low traffic)
It explodes when:

✅ Real data volume arrives
✅ Multiple concurrent users
✅ Database connection pool saturated
✅ API timeout starts happening
Prevention is easier than debugging:

Use fetch joins for relationships
Use projections for DTOs
Monitor queries in development
Test with realistic data volumes
Next Steps
If you're experiencing slow Spring Boot APIs:

Enable Hibernate query logging
Make a single API request
Count the SQL queries
If > 2-3 queries for simple endpoint → N+1 problem
Use fetch join or projection to fix
It usually takes 30 minutes to fix and saves your users hours of waiting.

That Tuesday at 2 AM was frustrating. But it taught me something valuable: always think about how many database queries your code executes.

Your users will thank you.

Questions? Comments? Drop them below! I read every single comment and reply within 24 hours.

Related Reading
Spring Boot with Hibernate: Why Your Queries Are Slow
JPA Fetch Strategies: Lazy vs Eager Loading Explained
How to Debug Spring Boot Database Performance

Next Article: "The Connection Pool Mistake That Cost Us $5,000 in RDS Bills"

Cross-posted from my blog: Spring Boot N+1 Query Problem

Follow for more production engineering insights on Java, Spring Boot, and distributed systems.

Top comments (0)