Spring Boot makes starting a service easy. Keeping it running under real conditions is a different challenge. These five problems show up in production systems reliably, regardless of team size or application complexity. Each one has a clear fix.

#1 Thread Pool Exhaustion Under Load

Problem: All threads get blocked waiting on slow downstream calls. New requests queue up, latency climbs, and eventually the whole service stalls.

The default Spring MVC thread pool has a fixed ceiling. When multiple upstream systems respond slowly, threads pile up in a waiting state. There are no threads left to serve new requests even fast ones that have nothing to do with the bottleneck.

Fix: Use @Async with a custom executor. Set explicit timeouts on RestTemplate or WebClient. Route slow calls through an isolated thread pool.

@Configuration
public class AsyncConfig {

@Bean("slowServiceExecutor")
    public Executor slowServiceExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(5);
        exec.setMaxPoolSize(20);
        exec.setQueueCapacity(50);
        exec.setThreadNamePrefix("slow-svc-");
        exec.initialize();
        return exec;
    }
}

@Service
public class InventoryService {
    @Async("slowServiceExecutor")
    public CompletableFuture<Stock> fetchStock(String productId) {
        // slow third-party call isolated here
        Stock result = externalClient.getStock(productId);
        return CompletableFuture.completedFuture(result);
    }
}

Pair this with a WebClient timeout so a hung upstream doesn't hold the thread indefinitely:

WebClient client = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create()
            .responseTimeout(Duration.ofSeconds(3))
    ))
    .build();

Flow: Request → Main thread pool (fast work only) → slowServiceExecutor (isolated + timeout) → External service

#2 LazyInitializationException in Production

Problem: Entity relationships loaded outside the transaction boundary. Works fine in tests, breaks under real usage patterns.

JPA defaults to lazy loading for most relationships. When the code accesses a lazy collection after the session closes, Hibernate throws LazyInitializationException. This often surfaces in serializers, event listeners, or service calls where the transaction has already ended.

Fix: Use @Transactional correctly, fetch eagerly where needed, or project into DTOs to avoid touching unloaded relationships entirely.

// Option A: DTO projection — cleanest for read-heavy paths
public interface OrderSummary {
    Long getId();
    String getStatus();
    String getCustomerName(); // flattened from Customer
}

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<OrderSummary> findByStatus(String status);
}

// Option B: JPQL fetch join - load everything in one query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Order findWithItems(@Param("id") Long id);

Avoid the spring.jpa.open-in-view=true workaround. It hides the problem rather than fixing it and causes N+1 queries in production traffic.

#3 Scheduled Job Running on Every Instance

Problem: Deploying three instances means the job runs three times. Duplicate emails sent, duplicate charges processed, duplicate records created.

Spring's @Scheduled has no awareness of other instances. Every pod runs every job, every time. Horizontal scaling makes this worse, not better.

Fix: Use ShedLock to guarantee only one instance acquires the lock and executes the job. The lock is stored in the database or Redis.

// Add to pom.xml:
// net.javacrumbs.shedlock:shedlock-spring:5.x
// net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.x

@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
@Configuration

public class SchedulerConfig { }

@Component
public class DailyReportJob {
    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "dailyReportJob",
        lockAtLeastFor = "PT5M",
        lockAtMostFor  = "PT30M"
    )
    public void run() {
        // only one instance executes this block
        reportService.generate();
    }
}

Flow: @Scheduled fires on all instances → Lock store (DB/Redis) → Instance 1 acquires lock ✓ → Instance 2 blocked ✗

#4 Different Behaviour in Production vs Local

Problem: Wrong Spring profile active. Missing environment variables. Config that works on a developer's laptop silently fails in the cloud.

Spring profiles exist to solve environment differences but only if the active profile is known and required properties are validated at startup. Silent misconfiguration is the worst kind: the app starts, requests come in, and the wrong behaviour plays out for hours before anyone notices.

Fix: Log the active profile at startup. Use @Value with sensible defaults. Validate config with @ConfigurationProperties + @Validated so the app refuses to start if something is missing.

@ConfigurationProperties(prefix = "payment")
@Validated
@Component
public class PaymentProperties {

    @NotBlank
    private String apiKey;
   
    @NotNull
    @Min(1000)
    private Integer timeoutMs;
    // getters / setters
}

// In your main application class:
@SpringBootApplication
public class App {
    public static void main(String[] args) {
        ConfigurableApplicationContext ctx =
            SpringApplication.run(App.class, args);
        Environment env = ctx.getEnvironment();
        log.info("Active profiles: {}",
            Arrays.toString(env.getActiveProfiles()));
    }
}

If a required property is missing, the app fails at startup with a clear validation message far better than a NullPointerException at runtime under load.

#5 Duplicate Requests from Retry Logic

Problem: Network timeouts trigger retries. The original request already reached the server and was processed. The retry processes it again order placed twice, payment charged twice.

Retry logic is a necessary reliability pattern. Without idempotency, it becomes a correctness problem. This is especially dangerous in payment systems, inventory deductions, and notification dispatchers.

Fix: Assign each request a unique idempotency key. Store processed keys in the database. On retry, return the cached result without re-executing the operation.

@Service
public class OrderService {

    @Transactional
    public OrderResult placeOrder(OrderRequest request) {
        String key = request.getIdempotencyKey();
        // check if already processed
        return idempotencyRepo
            .findByKey(key)
            .map(IdempotencyRecord::getResult)
            .orElseGet(() -> {
                OrderResult result = processOrder(request);
                // store so retries return the same result
                idempotencyRepo.save(new IdempotencyRecord(key, result));
                return result;
            });
    }
}

Flow: Client sends request (+ retry) → Check idempotency key → If new: process + save result → If seen: return cached result

The key should be generated client-side (UUID) and sent as a request header. Keep records for at least 24 hours to cover delayed retries.

Quick reference

  • Thread exhaustion isolate slow calls in a custom executor with timeouts
  • LazyInitializationException prefer DTO projections or fetch joins over open-session workarounds
  • Duplicate job runs ShedLock on all @Scheduled jobs, every time
  • Profile mismatch validate config at startup, log the active profile, fail fast on missing values
  • Duplicate processing idempotency keys stored in the database before any side effects

These are not edge cases. They happen in most production Spring Boot deployments sooner or later. Building the fixes in early costs far less than diagnosing them at 2 AM.