Definition
- N+1 pattern – an access pattern where a system makes one initial call to fetch N items, then one additional call per item to fetch related data, resulting in 1+N total calls.
- Hibernate version – fetching 100 accounts triggers one SELECT for the list and 100 SELECTs for the transactions, giving 101 database queries.
- Network version – calling a Balance Service once per account means 100 HTTP round trips, each with 20-100ms latency and an independent chance to fail.
- Core fixes – batch/bulk endpoints, caching with Redis multiGet, and parallel fan-out with bounded concurrency.
- Key insight – the network version is far worse because latency stacks and failure probability multiplies with each additional call.
Imagine Rajesh, a bank manager at National Bank of India, needs a report on 100 account holders. He first asks the registry clerk for the list of 100 names, that is 1 trip. Then, instead of asking once for all 100 phone numbers, he walks to the phone-records department 100 separate times, once per name. That is 101 trips when 2 would have been enough. If the phone department is in another building across the city, every trip costs minutes and any one could fail due to traffic.
Understanding the Concepts
What is the N+1 problem in Hibernate?
The classic Hibernate N+1 happens when you fetch a list of entities and then access a lazily loaded association for each one. Fetching 100 accounts issues one SELECT, and touching each account's transactions issues one SELECT per account, giving 101 queries.
package com.bank.repository;
import com.bank.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface AccountRepository extends JpaRepository<Account, String> {
// WRONG: triggers N+1 when transactions are lazily loaded
List<Account> findAll();
// CORRECT: JOIN FETCH loads transactions in one query
@Query("SELECT a FROM Account a JOIN FETCH a.transactions WHERE a.branch = :branch")
List<Account> findAllWithTransactions(String branch);
}
// WRONG (N+1): // Query 1: SELECT * FROM accounts WHERE branch = 'NBI-DELHI' → 100 rows // Query 2: SELECT * FROM transactions WHERE account_id = 'SAV2001' // Query 3: SELECT * FROM transactions WHERE account_id = 'SAV2002' // ... 100 more queries // Total: 101 queries, ~100ms // CORRECT (JOIN FETCH): // Query 1: SELECT a.*, t.* FROM accounts a JOIN transactions t ... → all data // Total: 1 query, ~5ms
Why is the network version worse than the database version?
In microservices, the +N calls become network round trips to other services. A database round trip is roughly 1ms, so 101 queries take about 100ms. A network call to another service is 20-100ms, so 101 network calls take 2-10 seconds. Each network call is also an independent chance for a timeout, dropped connection, or circuit breaker trip.
package com.bank.service;
import com.bank.client.BalanceClient;
import com.bank.model.AccountSummary;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class RelationshipManagerDashboard {
private final BalanceClient balanceClient;
public RelationshipManagerDashboard(BalanceClient balanceClient) {
this.balanceClient = balanceClient;
}
// WRONG: N+1 across the network
public List<AccountSummary> buildReport(List<String> accountIds) {
return accountIds.stream()
.map(id -> {
// Each call: ~30ms network round trip
BigDecimal balance = balanceClient.getBalance(id);
return new AccountSummary(id, balance);
})
.collect(Collectors.toList());
}
}
// 100 accounts x 30ms per call = 3000ms (3 seconds!) // Plus: each call can independently timeout or fail // If call #47 times out → entire dashboard fails or shows partial data
How do you fix it with a batch endpoint?
The best fix is a bulk endpoint that accepts a list of IDs and returns all results in one call. This collapses 1+N into 1+1.
package com.bank.controller;
import com.bank.service.AccountService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
// Batch endpoint: accepts multiple IDs, returns all balances
@PostMapping("/batch/balances")
public Map<String, BigDecimal> getBatchBalances(
@RequestBody List<String> accountIds) {
return accountService.getBalances(accountIds);
}
}
package com.bank.service;
import com.bank.client.BalanceClient;
import com.bank.model.AccountSummary;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class RelationshipManagerDashboardFixed {
private final BalanceClient balanceClient;
public RelationshipManagerDashboardFixed(BalanceClient balanceClient) {
this.balanceClient = balanceClient;
}
// CORRECT: one batch call instead of N calls
public List<AccountSummary> buildReport(List<String> accountIds) {
Map<String, BigDecimal> balances = balanceClient
.getBatchBalances(accountIds); // 1 call for all 100
return accountIds.stream()
.map(id -> new AccountSummary(id, balances.get(id)))
.collect(Collectors.toList());
}
}
// Before: 1 + 100 network calls = ~3000ms
// After: 1 + 1 network call = ~35ms
// Speedup: ~85x faster
How do you fix it with caching?
When balances change slowly, cache them in Redis. Use multiGet to fetch many at once.
package com.bank.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class CachedBalanceService {
private final StringRedisTemplate redis;
public CachedBalanceService(StringRedisTemplate redis) {
this.redis = redis;
}
public Map<String, BigDecimal> getCachedBalances(List<String> accountIds) {
List<String> keys = accountIds.stream()
.map(id -> "balance:" + id)
.collect(Collectors.toList());
// One Redis call for all 100 keys
List<String> values = redis.opsForValue().multiGet(keys);
return IntStream.range(0, accountIds.size())
.boxed()
.filter(i -> values != null && values.get(i) != null)
.collect(Collectors.toMap(
accountIds::get,
i -> new BigDecimal(values.get(i))));
}
}
// Redis multiGet for 100 keys: ~2ms (single round trip)
// vs 100 individual GET calls: ~100ms
// Cache miss: fall back to batch endpoint, then populate cache
How do you fix it with parallel fan-out?
When you truly cannot batch, fire the N calls concurrently but cap the concurrency to avoid overwhelming downstream.
package com.bank.service;
import com.bank.client.BalanceClient;
import com.bank.model.AccountSummary;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.util.List;
public class ParallelBalanceFetcher {
private final BalanceClient balanceClient;
public ParallelBalanceFetcher(BalanceClient balanceClient) {
this.balanceClient = balanceClient;
}
public List<AccountSummary> fetchWithBoundedConcurrency(
List<String> accountIds) {
return Flux.fromIterable(accountIds)
.flatMap(id -> Mono.fromCallable(
() -> new AccountSummary(id, balanceClient.getBalance(id))),
10) // Max 10 concurrent calls
.collectList()
.block();
}
}
// Sequential: 100 calls x 30ms = 3000ms // Parallel (bounded at 10): 10 batches x 30ms = ~300ms // Parallel (unbounded): 100 simultaneous calls → overwhelms downstream! // Bounded concurrency is the safe middle ground
What are common mistakes? (wrong vs correct)
The most dangerous mistake is unbounded parallelism, which turns N+1 into a denial-of-service attack on your own downstream service.
// WRONG: unbounded parallelism
accountIds.parallelStream()
.map(id -> balanceClient.getBalance(id)) // All 100 at once!
.collect(Collectors.toList());
// This floods the Balance Service with 100 simultaneous requests
// It may crash the downstream or exhaust its connection pool
// CORRECT: bounded concurrency with Flux.flatMap(mapper, concurrency)
Flux.fromIterable(accountIds)
.flatMap(id -> fetchBalance(id), 10) // Max 10 at a time
.collectList();
How does batching change failure semantics?
With N separate calls, one failure affects one item. With one batch call, one failure could affect all items. Design for partial results with per-item status.
package com.bank.model;
import java.math.BigDecimal;
public record BatchBalanceResult(
String accountId,
BigDecimal balance,
String status // "OK" or "ERROR"
) {}
// Batch response with per-item status:
// [
// {accountId: "SAV2001", balance: 75000, status: "OK"},
// {accountId: "SAV2002", balance: null, status: "ERROR"},
// {accountId: "SAV2003", balance: 32000, status: "OK"}
// ]
// The dashboard shows balance for SAV2001 and SAV2003,
// and shows "unavailable" for SAV2002
What should a senior engineer know?
A senior engineer always designs batch endpoints when building microservices. They also know that the N+1 pattern can appear in any layer: database, service-to-service, and even at the API gateway aggregation level. The fix at every layer is the same: collapse many individual calls into fewer, larger calls.
// Senior checklist for N+1 prevention:
// 1. Every service exposes batch/bulk endpoints alongside single-item ones
// 2. Batch endpoints return per-item status, not all-or-nothing
// 3. Cache slowly-changing data with Redis multiGet
// 4. When batching is impossible, use bounded parallel fan-out
// 5. Monitor for sequential remote calls in loops (code review signal)
// 6. Set connection pool limits to prevent downstream flooding
Visual Overview
N+1 Problem (Network Version)
─────────────────────────────
Dashboard Account Svc Balance Svc
│ │ │
│── GET accounts ──▶│ │
│◀── [SAV2001..100]─│ │
│ │ │
│── GET balance(SAV2001) ──────────────▶│ call 1
│◀── 75000 ────────────────────────────│
│── GET balance(SAV2002) ──────────────▶│ call 2
│◀── 32000 ────────────────────────────│
│── ... │ ... x 100
│── GET balance(SAV2100) ──────────────▶│ call 100
│◀── 45000 ────────────────────────────│
│ │
Total: 1 + 100 = 101 network calls = ~3 seconds
Fix: Batch Endpoint
────────────────────
Dashboard Account Svc Balance Svc
│ │ │
│── GET accounts ──▶│ │
│◀── [SAV2001..100]─│ │
│ │ │
│── POST batch([SAV2001..SAV2100]) ────▶│ 1 call
│◀── {SAV2001:75000, SAV2002:32000...} ─│
│ │
Total: 1 + 1 = 2 network calls = ~35ms
Key Takeaway
The N+1 problem is an access pattern where you make one call to fetch a collection of N items and then one additional call per item to fetch related data, giving 1+N total calls. Most people first encounter it in Hibernate lazy loading, but it is a universal pattern that appears anywhere you loop over a result set and make a dependent call inside the loop. In microservices, those +N calls become network round trips, and that is where it becomes truly dangerous: each remote call carries 20-100ms of latency instead of 1ms for a database query, and every call is an independent chance to fail.
At National Bank of India, the relationship-manager dashboard fetched 100 accounts and then called the Balance Service once per account. With a few accounts it was fine, but a real manager has hundreds of accounts, so the dashboard made hundreds of network calls and took several seconds. We fixed it by adding a batch endpoint that accepts all account IDs and returns all balances in one call, collapsing 101 calls into 2. Where the data changes slowly, we also cache balances in Redis using multiGet so repeated lookups skip the network entirely.
When a batch endpoint genuinely does not exist, the fallback is parallel fan-out with bounded concurrency. Fire the calls concurrently so they overlap, but always cap how many run at once, because unbounded parallelism turns N+1 into a denial-of-service attack on your own downstream service. The key nuance is that batching changes failure semantics, so always return partial results with per-item status rather than all-or-nothing.
Remember one sentence: the N+1 problem is 1 call for the list plus N calls for the details, and the fix is always the same, collapse many individual calls into fewer, larger ones through batch endpoints, caching, or bounded parallel fan-out.
---