Production Backend Interview Scenarios with Java Examples

A concise Java and Spring Boot study sheet for modern backend interviews: debugging distributed systems under production load, not just explaining framework annotations.

1. Your API works perfectly locally but becomes slow only in production. What would you check first?

Answer:Start with a latency breakdown. Check database time, downstream API time, queue time, thread pool saturation, GC pauses, CPU throttling, network latency, and gateway overhead. Local runs usually do not match production data size, traffic, TLS, rate limits, or dependency latency.

First signals: traces, p95/p99 latency, slow DB queries, connection pool usage, GC logs, and recent deployments.
long start = System.nanoTime();
try {
    return orderService.getOrder(id);
} finally {
    long latencyMs = (System.nanoTime() - start) / 1_000_000;
    log.info("operation=getOrder orderId={} latencyMs={}", id, latencyMs);
}
2. Kafka consumers are running normally, but message lag keeps increasing. Why can this happen?

Answer:The consumers may be alive but processing slower than messages are produced. Causes include slow handlers, too few partitions, hot partitions, DB/API slowness, rebalances, poison messages, GC pauses, or commits happening too late.

Compare input rate with processing rate. Also check per-partition lag, not only total group lag.
@KafkaListener(topics = "orders", groupId = "billing")
public void consume(OrderEvent event) throws InterruptedException {
    process(event); // If this is slower than incoming rate, lag grows.
}

private void process(OrderEvent event) throws InterruptedException {
    Thread.sleep(500); // Simulates slow DB or downstream call.
}
3. Database connections suddenly get exhausted during peak traffic. What could cause this?

Answer:Connection leaks, slow queries, too many request threads, missing timeouts, retry storms, long transactions, or a pool sized independently of database capacity can exhaust connections.

Check active vs idle connections, borrow wait time, slow query logs, transaction duration, and whether connections are always closed.
public User findUser(String id) throws SQLException {
    String sql = "select id, name from users where id = ?";

    try (Connection con = dataSource.getConnection();
         PreparedStatement ps = con.prepareStatement(sql)) {
        ps.setString(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            if (!rs.next()) {
                throw new NoSuchElementException("user not found");
            }
            return new User(rs.getString("id"), rs.getString("name"));
        }
    } // Connection is returned to the pool here.
}
4. Autoscaling creates more pods, but response time still keeps increasing.

Answer:Horizontal scaling does not fix a shared bottleneck. The real bottleneck may be the database, Redis, Kafka partitions, connection pools, a downstream API, locks, or a single hot key.

Look for saturation outside the app pods: database CPU/connections, downstream p99, queue depth, partition distribution, and connection pool waits.
private final Semaphore dbLimit = new Semaphore(50);

public Order getOrder(String id) throws Exception {
    if (!dbLimit.tryAcquire()) {
        throw new TooManyRequestsException("database concurrency limit reached");
    }

    try {
        return repository.findById(id).orElseThrow();
    } finally {
        dbLimit.release();
    }
}
5. Retry logic starts creating duplicate payment transactions during failures.

Answer:Retries are dangerous for non-idempotent operations. Payment APIs must use idempotency keys so the same logical request returns the original result instead of charging again.

Check whether the idempotency key is stable across retries and whether the database has a unique constraint on it.
@Transactional
public Payment pay(String idempotencyKey, PaymentRequest request) {
    return paymentRepository.findByIdempotencyKey(idempotencyKey)
            .orElseGet(() -> {
                Payment charged = paymentGateway.charge(request);
                charged.setIdempotencyKey(idempotencyKey);
                return paymentRepository.save(charged);
            });
}
6. A scheduled job suddenly starts executing multiple times after scaling.

Answer:Each pod runs its own scheduler. After scaling from one pod to many pods, the same job can run once per pod unless you use a distributed lock, leader election, or an external scheduler.

Check number of replicas and job logs by pod name. If every pod logs the job start, the scheduler is not coordinated.
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(name = "invoiceJob", lockAtMostFor = "10m")
public void generateInvoices() {
    invoiceService.run();
}
7. One slow downstream service starts affecting the entire platform.

Answer:Slow calls consume threads, connections, and queues. If dependencies share the same thread pool, one slow service can starve unrelated requests.

Use timeouts, bulkheads, circuit breakers, separate pools per dependency, and fallback responses.
@TimeLimiter(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
public CompletableFuture<Inventory> getInventory(String sku) {
    return CompletableFuture.supplyAsync(
            () -> inventoryClient.get(sku),
            inventoryPool
    );
}

private CompletableFuture<Inventory> fallback(String sku, Throwable ex) {
    return CompletableFuture.completedFuture(Inventory.unavailable(sku));
}
8. APIs randomly return 500 errors, but infrastructure looks healthy.

Answer:Random 500s often come from application behavior: null values, race conditions, bad assumptions about input, serialization errors, timeout exceptions, pool exhaustion, or config differences.

Group errors by exception type, endpoint, instance, deployment version, request payload, and dependency call.
@RestControllerAdvice
class ApiErrors {
    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorResponse> handle(Exception ex) {
        log.error("request failed", ex);
        return ResponseEntity.status(500)
                .body(new ErrorResponse("internal_error"));
    }
}

record ErrorResponse(String code) {}
9. Health checks pass, but users still face failures.

Answer:The health check may be too shallow. A process can be alive while unable to serve real traffic because the database, Kafka, cache, or required config is broken.

/live should confirm the process is alive. /ready should confirm the instance can safely receive traffic.
@GetMapping("/ready")
public ResponseEntity<Void> ready() {
    boolean ready = databaseReachable() && kafkaReachable();
    return ready
            ? ResponseEntity.ok().build()
            : ResponseEntity.status(503).build();
}

@GetMapping("/live")
public ResponseEntity<Void> live() {
    return ResponseEntity.ok().build();
}
10. Cache improves performance initially, but later starts returning stale data.

Answer:A cache needs an invalidation strategy. Stale data appears when writes update the database but not the cache, TTL is too long, or multiple services maintain inconsistent cache state.

Use TTL, explicit eviction on writes, versioned keys, event-based invalidation, or read-through/write-through patterns.
@Cacheable(value = "users", key = "#id")
public User getUser(String id) {
    return repository.findById(id).orElseThrow();
}

@CacheEvict(value = "users", key = "#user.id")
public void updateUser(User user) {
    repository.save(user);
}
11. Logs exist everywhere, but debugging across services is still difficult.

Answer:Logs without correlation are hard to join. Add trace IDs, span IDs, structured logging, and distributed tracing so one request can be followed across services.

Every log line should include trace ID, service name, endpoint, status, latency, and important business identifiers.
String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
        .orElse(UUID.randomUUID().toString());

try {
    MDC.put("traceId", traceId);
    response.setHeader("X-Trace-Id", traceId);
    chain.doFilter(request, response);
} finally {
    MDC.clear();
}
12. JVM memory usage slowly increases after every deployment.

Answer:Look for memory leaks and deployment-related classloader leaks: static caches, unclosed clients, metrics labels with high cardinality, scheduled tasks, listeners, and ThreadLocal values.

Compare heap after Full GC, take heap dumps, inspect dominator tree, and check objects retained by static fields or thread locals.
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

public void handle(UserContext userContext) {
    try {
        CONTEXT.set(userContext);
        processRequest();
    } finally {
        CONTEXT.remove(); // Important in reused server threads.
    }
}
13. APIs work in staging but fail behind the production gateway.

Answer:Production gateway behavior can differ: path rewriting, auth headers, TLS, CORS, body size limits, timeouts, rate limits, forwarded headers, or blocked methods.

Compare direct service call vs gateway call. Inspect request path, headers, body size, auth claims, and timeout settings.
@GetMapping("/orders/{id}")
public Order getOrder(
        @PathVariable String id,
        @RequestHeader(value = "X-Forwarded-Proto", required = false) String proto,
        @RequestHeader(value = "X-Request-Id", required = false) String requestId) {

    log.info("requestId={} forwardedProto={}", requestId, proto);
    return service.get(id);
}
14. Thread pools become exhausted even though CPU usage is stable.

Answer:Low CPU with exhausted threads usually means threads are blocked, not computing. They may be waiting on DB connections, slow HTTP calls, locks, queues, file I/O, or sleeps.

Take a thread dump. Look for many threads in WAITING, TIMED_WAITING, BLOCKED, socket read, connection borrow, or lock acquisition.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        20,
        20,
        0,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new ThreadPoolExecutor.AbortPolicy()
);

// Bounded queue prevents unlimited request buildup.
15. Circuit breakers are configured, but cascading failures still happen.

Answer:Circuit breakers are only one guardrail. Cascading failures still happen when retries are too aggressive, timeouts are too long, dependencies share thread pools, fallbacks are expensive, or there is no backpressure.

Combine circuit breakers with timeouts, retry budgets, bulkheads, load shedding, separate pools, and simple fallbacks.
@Retry(name = "payment")
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
@Bulkhead(name = "payment", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<PaymentResult> charge(PaymentRequest request) {
    return CompletableFuture.supplyAsync(() -> paymentClient.charge(request));
}

private CompletableFuture<PaymentResult> fallback(PaymentRequest request, Throwable ex) {
    return CompletableFuture.completedFuture(PaymentResult.pending());
}