Java Backend Interview Q&A Production Debugging
Real-world distributed systems questions with code examples
The shift in backend interviews: not Can you explain annotations? but Can you debug distributed systems under production load?
1. Your API works perfectly locally but becomes slow only in production. What would you check first?
What to check first (in order):
1. Latency breakdown measure where time is spent: app, DB, downstream, network. Locally everything is on localhost (sub-millisecond); in prod each hop adds latency.
2. N+1 queries small datasets locally hide them; prod data exposes them instantly.
3. Connection pools (HikariCP, HTTP client, Redis) defaults are often too small for prod concurrency.
4. GC pauses heap sized differently in prod; check -Xlog:gc* and pause times.
5. TLS / DNS / proxy hops gateway, service mesh, sidecars add hidden overhead.
6. Noisy neighbor / cold caches JIT not warm, OS page cache empty after deploy.
Example: instrument with Micrometer to find the slow hop.
@RestController
@RequiredArgsConstructor
public class OrderController {
private final
MeterRegistry registry;
private final
OrderService service;
@GetMapping("/orders/{id}")
public Order get(@PathVariable
Long id) {
Timer.Sample sample = Timer.start(registry);
try {
return service.findById(id);
} finally {
// Tag by outcome so
prod dashboards split fast vs slow paths
sample.stop(registry.timer("orders.get", "id", String.valueOf(id % 10)));
}
}
}
2. Kafka consumers are running normally, but message lag keeps increasing. Why can this happen?
Common root causes:
· Slow processing per record a downstream call, DB write, or heavy deserialization makes each poll() cycle longer than the production rate.
· Too few partitions consumer parallelism is capped by partition count. 1 partition = 1 consumer thread, regardless of how many instances you run.
· max.poll.records too high combined with slow handler broker thinks consumer is dead, triggers rebalance, lag grows.
· Synchronous external calls inside the consumer loop without batching.
· Single-threaded listener when records are independent and could be processed in parallel.
Fix: bounded parallel processing inside the listener.
@KafkaListener(topics = "payments", concurrency = "6")
public void consume(List<ConsumerRecord<String, Payment>> batch, Acknowledgment ack) {
// Process batch in
parallel but bounded don't unbounded-fan-out to a thread pool
List<CompletableFuture<Void>>
futures = batch.stream()
.map(rec -> CompletableFuture.runAsync(() -> handle(rec.value()), workerPool))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
ack.acknowledge(); //
commit only after all succeed; otherwise replay
}
private void handle(Payment
p) {
// keep this idempotent
see duplicate-payment Q below
paymentService.process(p);
}
3. Database connections suddenly get exhausted during peak traffic. What could cause this?
Likely causes:
· Pool size too small for peak concurrency (default Hikari = 10).
· Long-running transactions holding connections (e.g., @Transactional wrapping an external HTTP call).
· Connection leaks code paths that don't return connections (missing try-with-resources, manual getConnection()).
· Idle-in-transaction sessions from a misconfigured ORM.
· Cascade from a slow query every request queues for a connection, pool saturates.
Fixes: size pool from threads Χ (1 + wait/compute), never call remote services inside a transaction, enable Hikari leak detection.
@Configuration
public class DataSourceConfig {
@Bean
public HikariDataSource dataSource() {
HikariConfig cfg = new
HikariConfig();
cfg.setJdbcUrl(System.getenv("DB_URL"));
cfg.setMaximumPoolSize(30); // tune from real metrics,
not guess
cfg.setMinimumIdle(10);
cfg.setConnectionTimeout(2_000); //
fail fast instead of queuing forever
cfg.setLeakDetectionThreshold(10_000); //
log stack trace if held > 10s
cfg.setMaxLifetime(1_800_000);
return new
HikariDataSource(cfg);
}
}
// ❌ BAD: holds a DB connection for the entire
HTTP round-trip
@Transactional
public void badCharge(Long
id) {
Order o = repo.findById(id).orElseThrow();
paymentGateway.charge(o); //
2s remote call connection held the whole time
o.markPaid();
}
// ✅ GOOD: do remote work outside the transaction
public void goodCharge(Long
id) {
Order o = readOnly(() -> repo.findById(id).orElseThrow());
var receipt =
paymentGateway.charge(o); // no DB connection
held
txTemplate.executeWithoutResult(s -> repo.markPaid(id, receipt));
}
4. Autoscaling creates more pods, but response time still keeps increasing.
The bottleneck is downstream, not the app. More pods just multiply the pressure on the shared resource.
· DB / cache / queue saturated adding pods adds connections, not capacity.
· External API rate limit more pods = more 429s.
· Shared lock / single-writer pattern somewhere in the path.
· Cold start dominates for short-lived pods.
· Scaling on the wrong metric (CPU) when the real bottleneck is latency or queue depth.
Fix: scale on a saturation signal AND protect the bottleneck with a bulkhead.
//
Resilience4j bulkhead cap concurrent calls into a shared dependency,
// so adding pods cannot multiply pressure beyond what
the DB can handle.
@Bean
public Bulkhead dbBulkhead() {
return Bulkhead.of("db", BulkheadConfig.custom()
.maxConcurrentCalls(20)
.maxWaitDuration(Duration.ofMillis(50))
.build());
}
@Service
@RequiredArgsConstructor
public class ReportService {
private final
Bulkhead dbBulkhead;
private final
ReportRepo repo;
public Report build(Long id) {
return Bulkhead.decorateSupplier(dbBulkhead, () -> repo.heavyQuery(id)).get();
}
}
5. Retry logic starts creating duplicate payment transactions during failures.
Retries without idempotency = duplicates. A timeout doesn't mean the call didn't happen only that you didn't get the response.
Fix:
· Use an idempotency key per logical operation; the payment provider deduplicates.
· Persist the key + state before calling the provider.
· Retry only on idempotent failures (network/timeout), not on 200 + business error.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepo
repo;
private final
StripeClient stripe;
@Retryable(retryFor =
IOException.class, maxAttempts = 3,
backoff = @Backoff(delay
= 200, multiplier = 2))
public Receipt charge(ChargeRequest req) {
// 1. Reserve a stable
idempotency key tied to the business operation
String key = "charge:" +
req.orderId();
PaymentRecord rec = repo.findByKey(key).orElseGet(() ->
repo.save(new PaymentRecord(key, req.orderId(), Status.PENDING)));
if (rec.getStatus()
== Status.SUCCEEDED) return rec.toReceipt(); // short-circuit
// 2. Provider sees the
same key on retry and returns the original charge
Receipt r = stripe.charge(req, key);
repo.markSucceeded(rec.getId(), r.id());
return r;
}
}
6. A scheduled job suddenly starts executing multiple times after scaling.
Every replica runs its own @Scheduled. With N pods you get N executions.
Fixes:
· Distributed lock (ShedLock, Redisson) only one node executes per tick.
· External scheduler (Quartz with JDBC store, K8s CronJob, Temporal) scheduling is centralized.
· Leader election (Spring Cloud, K8s lease).
//
ShedLock: backed by JDBC / Redis / Mongo only one pod runs the job per window
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor
= "PT5M")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource ds) {
return new
JdbcTemplateLockProvider(ds);
}
}
@Component
public class ReportJob {
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "hourlyReport",
lockAtLeastFor = "PT1M", lockAtMostFor = "PT4M")
public void run() {
// guaranteed single
execution cluster-wide
}
}
7. One slow downstream service starts affecting the entire platform.
Threads pile up waiting on the slow dependency, then every endpoint stalls classic cascading failure.
Isolate it:
· Timeouts everywhere (connect + read).
· Circuit breaker fail fast when error rate spikes.
· Bulkhead cap concurrent calls so the slow path can't drain the shared thread pool.
· Fallback return cached or degraded response.
@Service
@RequiredArgsConstructor
public class RecommendationService {
private final
RecommendationClient client;
@CircuitBreaker(name = "reco", fallbackMethod = "fallback")
@TimeLimiter(name = "reco")
@Bulkhead(name = "reco", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<List<Item>> recommend(Long userId) {
return CompletableFuture.supplyAsync(() -> client.fetch(userId));
}
@SuppressWarnings("unused")
private CompletableFuture<List<Item>> fallback(Long userId, Throwable t) {
// degraded but available
better than a cascading outage
return CompletableFuture.completedFuture(List.of());
}
}
8. APIs randomly return 500 errors, but infrastructure looks healthy.
"Healthy infra" usually means CPU/RAM are fine but 500s come from application-level issues:
· Connection pool exhaustion intermittently (see Q3).
· Race conditions on shared mutable state.
· Unhandled edge cases in deserialization (nulls, missing fields).
· Downstream returning malformed payloads at certain hours.
· Thread-local leaks after async boundaries.
· Bad pod in the set one replica is misconfigured.
First move: correlate the 500s by pod, endpoint, payload shape, time window.
//
Global handler that logs *everything* needed to correlate later
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorBody> handle(Exception
ex, HttpServletRequest req) {
String traceId = MDC.get("traceId"); // set by your tracing filter
log.error("Unhandled:
method={} uri={} pod={} traceId={}",
req.getMethod(), req.getRequestURI(),
System.getenv("HOSTNAME"), traceId, ex);
return ResponseEntity.status(500)
.body(new ErrorBody("INTERNAL", traceId)); // give clients the trace
id
}
record ErrorBody(String
code, String traceId) {}
}
9. Health checks pass, but users still face failures.
The health check is too shallow it returns 200 as long as the process is alive, but doesn't verify the dependencies users actually need.
Use two probes:
· Liveness only checks the process is not deadlocked. Failing restarts the pod.
· Readiness checks DB, cache, downstream auth are reachable. Failing removes the pod from the load balancer but doesn't restart it.
Never reuse the same endpoint for both.
@Component
public class DbReadinessIndicator implements
HealthIndicator {
private final
JdbcTemplate jdbc;
public DbReadinessIndicator(JdbcTemplate jdbc) { this.jdbc =
jdbc; }
@Override
public Health health() {
try {
jdbc.queryForObject("SELECT 1", Integer.class);
return Health.up().build();
} catch (Exception e) {
// Pod leaves load
balancer rotation; users stop seeing failures from this pod
return Health.down(e).build();
}
}
}
// application.yml
// management.endpoint.health.probes.enabled: true
// management.health.livenessstate.enabled: true
// management.health.readinessstate.enabled: true
10. Cache improves performance initially, but later starts returning stale data.
Caching strategy mismatched to the write pattern:
· No TTL + no invalidation → stale forever.
· TTL only → window of inconsistency tolerated but unbounded for hot keys updated mid-window.
· Write-through / invalidate-on-write missing in some code paths (e.g., a batch job updates DB directly).
· Cache stampede when the key expires under load → many DB reads, then re-cached with old value due to race.
Fix: invalidate on every write path + short TTL + single-flight refresh.
@Service
@RequiredArgsConstructor
public class ProductService {
private final
ProductRepo repo;
@Cacheable(value = "product", key = "#id")
public Product get(Long id) {
return repo.findById(id).orElseThrow();
}
// Every mutation path
MUST evict including admin tools and batch jobs
@CacheEvict(value = "product", key = "#p.id")
@Transactional
public Product update(Product p) {
return repo.save(p);
}
@CacheEvict(value = "product", allEntries = true)
public void onBulkImport() { /* called from batch job */ }
}
11. Logs exist everywhere, but debugging across services is still difficult.
Missing correlation. Each service logs independently; without a shared identifier you cannot reconstruct a single request's path.
Fix:
· Distributed tracing (OpenTelemetry / Micrometer Tracing) propagate traceId + spanId across HTTP and Kafka.
· MDC put traceId, userId, tenantId into every log line.
· Structured logs (JSON) searchable in Loki/ELK/Datadog.
//
Filter that ensures every request has a traceId in MDC and logs
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("traceId", traceId);
res.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(req, res);
} finally {
MDC.clear(); //
critical with thread pools avoid leaking into next request
}
}
}
// logback pattern: %d %-5level [%X{traceId}] %logger -
%msg%n
12. JVM memory usage slowly increases after every deployment.
Classic memory leak signatures:
· static collections growing unbounded (caches without eviction).
· ThreadLocal not cleared in pooled threads.
· Listeners/observers registered but never deregistered.
· Classloader leak with hot reloads (old class versions retained).
· Connection / stream not closed.
Process: take a heap dump (jmap -dump:live), open with Eclipse MAT, look at the dominator tree.
//
❌ unbounded static cache heap grows forever
public class BadCache {
private static final Map<String, byte[]> CACHE = new
HashMap<>();
public static void put(String k, byte[] v) { CACHE.put(k, v); }
}
// ✅ bounded with Caffeine evicts by size and
time
public class GoodCache {
private static final Cache<String, byte[]> CACHE = Caffeine.newBuilder()
.maximumWeight(64L * 1024 * 1024)
// 64MB cap
.weigher((String k, byte[] v) -> v.length)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
}
// ThreadLocal in a pooled thread always clear in
finally
ThreadLocal<User>
CURRENT = new ThreadLocal<>();
try {
CURRENT.set(user);
handle();
} finally {
CURRENT.remove(); //
otherwise the next request inherits this user
}
13. APIs work in staging but fail behind the production gateway.
The gateway changes the request shape. Things that pass in staging (direct calls) fail in prod:
· Header rewriting / stripping (Authorization, Host, custom headers).
· Body size limits smaller than the app's.
· Timeouts stricter than the app's processing time.
· TLS termination X-Forwarded-Proto not honored, redirect loops.
· Path rewriting /api/v1/x becomes /x, breaking route matching.
· WAF rules blocking certain payloads.
Debug by capturing what the app actually receives.
//
Log the exact request the gateway delivers, once, to diff against staging
@Component
public class GatewayDiagnosticsFilter extends
OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
if ("true".equalsIgnoreCase(req.getHeader("X-Diag"))) {
Collections.list(req.getHeaderNames()).forEach(h ->
log.info("hdr
{}={}", h, req.getHeader(h)));
log.info("uri={}
query={} proto={} forwarded={}",
req.getRequestURI(),
req.getQueryString(),
req.getHeader("X-Forwarded-Proto"),
req.getHeader("Forwarded"));
}
chain.doFilter(req, res);
}
}
14. Thread pools become exhausted even though CPU usage is stable.
Threads are blocked on I/O, not burning CPU. Low CPU + high latency = blocked threads.
Causes:
· Synchronous calls to a slow downstream threads wait, pool drains.
· Lock contention (a synchronized block around a hot resource).
· Blocking calls inside reactive code (block() on a Reactor pipeline).
· DB connection wait threads queued for a connection.
Fix: separate thread pools per dependency, async / non-blocking I/O, timeouts on every blocking call.
//
Dedicated, bounded pool per downstream slow service can't drain the shared
pool
@Configuration
public class Pools {
@Bean("paymentsPool")
public ExecutorService paymentsPool() {
return new
ThreadPoolExecutor(
10, 10,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
// bounded queue
new ThreadFactoryBuilder().setNameFormat("payments-%d").build(),
new ThreadPoolExecutor.AbortPolicy() // fail fast don't queue forever
);
}
}
@Service
@RequiredArgsConstructor
public class PaymentsFacade {
@Qualifier("paymentsPool") private final ExecutorService pool;
private final
PaymentsClient client;
public CompletableFuture<Receipt> charge(ChargeRequest
r) {
return CompletableFuture.supplyAsync(() -> client.charge(r), pool)
.orTimeout(2, TimeUnit.SECONDS); // every blocking
call gets a timeout
}
}
15. Circuit breakers are configured, but cascading failures still happen.
A circuit breaker alone is not enough. Common reasons it fails to stop a cascade:
· No timeout the breaker waits forever for the slow call, never opens.
· Thresholds too lenient opens only after damage is done.
· No bulkhead even when open, queued threads are already exhausted.
· Shared breaker across very different operations one bad endpoint poisons the metrics for the healthy ones.
· Missing fallback open breaker throws, caller cascades anyway.
· Retries inside the protected call amplify load and keep the breaker closed.
Fix: timeout + breaker + bulkhead + fallback, per dependency, with retries OUTSIDE.
//
Full resiliency stack order matters: bulkhead → timeLimiter →
circuitBreaker → retry
@Service
@RequiredArgsConstructor
public class InventoryFacade {
private final
InventoryClient client;
@Bulkhead(name = "inventory", type = Bulkhead.Type.THREADPOOL)
@TimeLimiter(name = "inventory") // hard deadline so breaker can see failures
@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
@Retry(name = "inventory") // retries only when breaker is closed
public CompletableFuture<Stock> check(Long sku) {
return CompletableFuture.supplyAsync(() -> client.stock(sku));
}
@SuppressWarnings("unused")
private CompletableFuture<Stock> fallback(Long sku, Throwable t) {
return CompletableFuture.completedFuture(Stock.unknown(sku)); // degraded, never throw
}
}
// application.yml
// resilience4j.circuitbreaker.instances.inventory:
// slidingWindowSize: 50
// failureRateThreshold: 30
// waitDurationInOpenState: 10s
// permittedNumberOfCallsInHalfOpenState: 5