Why Your API Slows Down: 8 Critical Bottlenecks in Spring Boot Performance

This article analyzes eight common API performance bottlenecks in Spring Boot applications, including N+1 queries, improper entity returns, missing indexes, JOIN FETCH pagination issues, in-memory filtering, deep pagination, long transactions, and caching strategies, with code examples and optimization techniques for each.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Why Your API Slows Down: 8 Critical Bottlenecks in Spring Boot Performance

1. Introduction

API response slowdown is a common issue as systems scale. Initially requests return in milliseconds, but with business growth, data increase, and longer call chains, latency rises and throughput drops. Solving this requires a complete performance analysis approach from request entry to response return, not just guessing or adding hardware.

2. Eight Key Performance Factors

2.1 N+1 Query Problem

A classic and frequent issue. Example: an Order entity with a lazy-loaded Customer relationship.

@Entity
@Table(name = "x_order")
public class Order {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private BigDecimal totalAmount;
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "c_id")
  private Customer customer;
  @Enumerated(EnumType.STRING)
  private OrderStatus status;
}

Service method:

public void query() {
  List<Order> orders = this.orderRepository.findAll();
  orders.forEach(order -> System.err.println("customer: %s".formatted(order.getCustomer().getName())));
}

If the first query returns 1,000 orders, the application executes 1 order query + 1,000 customer queries = 1,001 queries.

Optimization 1: Use JOIN FETCH

@Query("""
  SELECT o
  FROM Order o
  JOIN FETCH o.customer
  """)
List<Order> findOrdersBy();

Optimization 2: Use @EntityGraph

@EntityGraph(attributePaths = "customer")
List<Order> findOrdersEntityGraphBy();

Optimization 3: Use DTO Projection

@Query("""
  SELECT new com.pack.performance.dto.OrderSummary(
    o.id,
    o.totalAmount,
    c.name
  )
  FROM Order o
  JOIN o.customer c
  """)
List<OrderSummary> findOrdersDtoProjectionBy();

2.2 Returning Entire Entities in API Responses

Directly returning entities triggers lazy-loading N+1 queries, exposes sensitive fields, increases serialization overhead and network bandwidth. Use DTOs to trim fields.

@Entity
public class User {
  private Long id;
  private String name;
  private String email;
  private String phone;
  private String address;
  private String idNo;
  private LocalDateTime lastLoginAt;
  @OneToMany
  private List<Order> orders;
}

Problematic endpoint:

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
  return userRepository.findById(id).orElseThrow();
}

Issues: sensitive field leakage, accidental relationship serialization, lazy-loading during serialization, large object graphs, tight coupling between DB and API models.

Correct approach with DTO:

public record UserResponse(
  Long id,
  String name,
  String email
) {}

Projection query:

@Query("""
  SELECT new com.pack.api.UserResponse(
    u.id,
    u.name,
    u.email
  )
  FROM User u
  WHERE u.id = :id
  """)
Optional<UserResponse> findUserResponseById(Long id);

This tells the database exactly which columns are needed. Less data loaded means less mapping, storage, serialization, and transmission.

2.3 Missing Proper Indexes

Query example:

SELECT *
FROM t_order
WHERE c_id = 101 AND STATUS = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

Potential index columns: customer_id, status, created_at. But three separate indexes ≠ one proper composite index.

CREATE INDEX idx_orders_customer_status_created ON t_order(c_id, status, created_at DESC);

Correct indexes depend on DB engine, column cardinality, data distribution, query structure, sort requirements, write frequency, and existing indexes. Never create indexes based solely on column names. Check actual query plans:

EXPLAIN ANALYZE SELECT *
FROM t_order
WHERE c_id = 101 AND STATUS = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

Every index adds overhead to inserts, updates, deletes, storage, and maintenance. Create indexes based on actual access patterns.

2.4 Using JOIN FETCH with Pagination on Collections

After learning JOIN FETCH, developers may overuse it. Example:

@Query("""
  SELECT o
  FROM Order o
  JOIN FETCH o.items
  WHERE o.status = :status
  """)
Page<Order> findOrders(OrderStatus status, Pageable pageable);

Looks perfect: one query, no lazy loading, no N+1. But order and item row counts differ. Querying 20 orders with 50 items each generates ~1,000 joined rows before Hibernate rebuilds the object graph.

Pagination becomes complex because the database pages by rows while the application processes by root entities (Order) . This causes incorrect page sizes, duplicate root entities, oversized result sets, in-memory pagination, high DB memory usage, and slow queries despite small page sizes.

Correct approach: Two-step query

First, fetch root entity IDs:

@Query("""
  SELECT o.id
  FROM Order o
  WHERE o.status = :status
  ORDER BY o.createdAt DESC
  """)
Page<Long> findOrderIds(OrderStatus status, Pageable pageable);

Then load required data:

@Query("""
  SELECT DISTINCT o
  FROM Order o
  JOIN FETCH o.items
  WHERE o.id IN :ids
  """)
List<Order> findOrdersWithItems(List<Long> ids);

This separates pagination from graph retrieval. One SQL query is not necessarily faster than two well-designed queries. Query count is just one metric; you must also measure returned rows, data transferred, join products, DB execution time, and application reconstruction time.

2.5 Filtering Data in Application Code

Common anti-pattern:

List<Order> orders = orderRepository.findAll();
return orders.stream()
  .filter(order -> order.getTotalAmount().compareTo(BigDecimal.valueOf(1000)) > 0)
  .toList();

Databases excel at filtering. Applications should not load entire tables only to keep a small subset. Push filter conditions into the query:

List<Order> findByTotalAmountGreaterThan(BigDecimal amount);

@Query("""
  SELECT o
  FROM Order o
  WHERE o.totalAmount > :amount
  """)
List<Order> findExpensiveOrders(BigDecimal amount);

In-Java filtering creates unnecessary overhead: more rows transferred, more entities created, higher heap usage, more GC cycles, increased CPU, and difficult pagination. Push filtering, sorting, grouping, and aggregation to the data source. Leverage the database for data selection.

2.6 Deep Pagination Problem

SQL with large offset:

SELECT *
FROM t_order
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

For early pages this is fine. For deep pages, the database still scans 100,020 rows before returning 20 rows.

Optimization: Use keyset pagination for large datasets

Instead of "skip first 100,000 rows", say "start from this known position, give me the next 20 rows".

Window<User> findBy(Pageable pageable, ScrollPosition position);

Test case demonstrates scroll queries:

@Test
public void testScrollQueryUsers() {
  Result<User> result = null;
  result = this.userService.scrollQueryUsers(null, null);
  System.err.println(result);

  result = this.userService.scrollQueryUsers(result.keys(), result.direction());
  System.err.println(result);

  result = this.userService.scrollQueryUsers(result.keys(), result.direction());
  System.err.println(result);

  result = this.userService.scrollQueryUsers(result.keys(), result.direction());
  System.err.println(result);
}

2.7 Handling Non-Transactional Operations Inside Transactions

Common problematic pattern:

@Transactional
public void processOrder(Long orderId) {
  Order order = orderRepository.findById(orderId).orElseThrow();
  paymentClient.verifyPayment(order);
  shippingClient.createShipment(order);
  notificationClient.sendEmail(order);
  order.markAsProcessed();
}

During payment verification, shipping creation, email sending, network retries, and timeouts, this method holds the database connection and transaction open. Database transactions should only cover database operations, not all surrounding operations.

Refactored approach:

private final OrderService orderService;

public void processOrder(Long orderId) {
  Order order = this.orderService.getOrder(orderId);
  PaymentResult payment = paymentClient.verifyPayment(order);
  this.orderService.paymentResult(orderId, payment);
  shippingClient.createShipment(order);
}

@Transactional(readOnly = true)
public Order getOrder(Long orderId) {
  return orderRepository.findById(orderId).orElseThrow();
}

@Transactional
public void paymentResult(Long orderId, PaymentResult result) {
  Order order = orderRepository.findById(orderId).orElseThrow();
  order.applyPaymentResult(result);
}

Shorter transactions mean shorter connection and lock holding times. Don't blindly split every transaction; business consistency still matters.

2.8 Using Caching to Optimize Queries

For caching strategies, refer to the linked article: "Deprecate Spring Cache! Prefer Multi-level Cache Framework JetCache".

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

cachingSpring Bootpaginationtransaction managementdatabase indexingAPI performancekeyset paginationN+1 queryJOIN FETCHDTO projection
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.