00:00

Scenario-Based Microservices Interview Questions for Senior Java Engineers

Last updated:

Scenario-based microservices interview questions describe a real production situation, such as a service that falls over again right after recovering, or Kafka lag that grows no matter how many pods you add, and ask what you would do. They test whether you can reason about a running system under pressure: stabilise it first, find the real cause, then prevent it from happening again.

This page has 30 senior-level scenarios that are different from the questions on the Microservices Interview Questions page. Each answer explains what is happening inside the system, what to check first and how to fix it, and three runnable Java 21 programs show the most commonly asked mechanisms in action.

1. How to Answer Scenario-Based Questions

A scenario question has no single correct answer. The interviewer wants to see how you think when something is broken and the facts are incomplete. Senior candidates follow a clear order, and say it out loud:

  1. Clarify. Ask about scale, timing and recent changes. "When did it start? Did anything deploy? Is it all users or some?"
  2. Stabilise. Protect users first: roll back, scale out, switch off a feature flag or shed load. Finding the root cause can wait a few minutes; a long outage can't.
  3. Diagnose. Form a hypothesis and name the exact metric, log or trace that would confirm it.
  4. Fix. Change the cause, not just the symptom.
  5. Prevent. Add the alert, test, limit or design change that stops it from happening again.
Senior signal: separate mitigation from root cause in your answer. "First I would roll back to stop the impact, then investigate with the traces from the bad version" shows production experience in one sentence.

Many scenarios share the same few internal mechanisms: synchronised retries, too much work arriving at once, events arriving in an unexpected order, and limits you didn't know you had. If you understand those, you can reason through scenarios you have never seen before.

2. Traffic, Load and Recovery

Q. The inventory service recovers from a 5-minute outage, and within seconds it falls over again. Why?

Answer: All callers failed at the same moment, so they all retry on the same schedule. When the service comes back, it gets the normal traffic plus a wave of synchronised retries, and also has cold caches and empty connection pools. It is overloaded and fails again. This is a retry storm, also called the thundering herd problem.

The program below sends 1,000 clients' retries into 100 ms windows. The recovering service can handle 200 requests per window.

RetryStormDemo.java
import java.util.Random;
import java.util.function.IntToLongFunction;

public class RetryStormDemo {

    static final int CLIENTS = 1_000;
    static final int CAPACITY_PER_100MS = 200; // what the recovering service can handle

    // Returns the busiest 100 ms window when every client retries once
    static int peakPerWindow(IntToLongFunction retryDelayMillis) {
        int[] windows = new int[100];
        for (int client = 0; client < CLIENTS; client++) {
            long at = retryDelayMillis.applyAsLong(client);
            windows[(int) (at / 100)]++;
        }
        int peak = 0;
        for (int count : windows) peak = Math.max(peak, count);
        return peak;
    }

    static void report(String strategy, int peak) {
        String verdict = peak > CAPACITY_PER_100MS ? "OVERLOADED" : "ok";
        System.out.printf("%-34s peak %4d requests/100ms -> %s%n", strategy, peak, verdict);
    }

    public static void main(String[] args) {
        Random random = new Random(42); // fixed seed keeps the output repeatable

        report("Fixed delay 1000 ms", peakPerWindow(c -> 1_000));
        report("Equal jitter 500 + [0, 500) ms", peakPerWindow(c -> 500 + random.nextInt(500)));
        report("Full jitter [0, 1000) ms", peakPerWindow(c -> random.nextInt(1_000)));
        report("Full jitter [0, 4000) ms", peakPerWindow(c -> random.nextInt(4_000)));
    }
}
Output
Fixed delay 1000 ms                peak 1000 requests/100ms -> OVERLOADED
Equal jitter 500 + [0, 500) ms     peak  211 requests/100ms -> OVERLOADED
Full jitter [0, 1000) ms           peak  115 requests/100ms -> ok
Full jitter [0, 4000) ms           peak   37 requests/100ms -> ok

A fixed delay just moves the spike 1 second later. Equal jitter (half fixed, half random) still overloads, because all retries land in only 500 ms. Full jitter, a random delay between zero and the backoff limit, spreads the load, and a wider window spreads it further.

Fix: exponential backoff with full jitter, a retry budget, and circuit breakers so clients stop calling while the service is down. On the server side, add load shedding so excess requests get a fast 503 instead of queueing, and bring traffic back gradually.

Q. A popular product's cache entry expires and the database CPU jumps to 100% for a few seconds. What happened?

Answer: This is a cache stampede. With the usual cache-aside pattern, every request that misses goes to the database. When a hot key expires, hundreds of requests miss at the same moment and all run the same query.

CacheStampedeDemo.java
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;

public class CacheStampedeDemo {

    static final AtomicInteger databaseLoads = new AtomicInteger();

    static String loadPrice(String sku) {
        databaseLoads.incrementAndGet();
        try {
            Thread.sleep(100); // a slow query
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return sku + "=19.99";
    }

    // Naive cache-aside: every caller that misses goes to the database
    static final Map<String, String> naiveCache = new ConcurrentHashMap<>();

    static String naiveGet(String sku) {
        String price = naiveCache.get(sku);
        if (price == null) {
            price = loadPrice(sku);
            naiveCache.put(sku, price);
        }
        return price;
    }

    // Single flight: the first caller loads, everyone else waits for the same future
    static final Map<String, CompletableFuture<String>> inFlight = new ConcurrentHashMap<>();

    static String singleFlightGet(String sku) {
        CompletableFuture<String> mine = new CompletableFuture<>();
        CompletableFuture<String> existing = inFlight.putIfAbsent(sku, mine);
        if (existing != null) return existing.join();
        try {
            String price = loadPrice(sku);
            mine.complete(price);
            return price;
        } catch (RuntimeException e) {
            mine.completeExceptionally(e); // waiting callers must not hang
            throw e;
        } finally {
            inFlight.remove(sku, mine); // the next miss starts a fresh load
        }
    }

    static int burst(Function<String, String> get) throws InterruptedException {
        databaseLoads.set(0);
        CountDownLatch start = new CountDownLatch(1);
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                executor.submit(() -> {
                    start.await();
                    return get.apply("SKU-42");
                });
            }
            start.countDown(); // 100 requests hit the expired key at once
        }
        return databaseLoads.get();
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("Naive cache, 100 concurrent misses  -> database loads: " + burst(CacheStampedeDemo::naiveGet));
        System.out.println("Single flight, 100 concurrent misses -> database loads: " + burst(CacheStampedeDemo::singleFlightGet));
    }
}
Output
Naive cache, 100 concurrent misses  -> database loads: 100
Single flight, 100 concurrent misses -> database loads: 1

With single flight, the first caller loads the value and all the others wait for its result, so one query runs instead of a hundred. Note the details: waiting callers must receive the error if the load fails, and the entry is removed afterwards so the next miss loads fresh data. This works per instance; with 50 pods, there can still be 50 loads.

Other fixes: refresh hot keys in the background before they expire (Caffeine's refreshAfterWrite does this), add random jitter to expiry times so keys don't expire together, and serve the stale value briefly while one request refreshes it.

Q. A partner API allows 100 requests per second. You have 20 pods, and autoscaling can add more. How do you stay under the limit?

Answer: Splitting the limit statically, 5 per pod, breaks as soon as the pod count changes. Options:

  • A shared token bucket, for example in Redis with an atomic Lua script, that all pods take tokens from.
  • A single egress proxy or gateway for that partner, which applies the limit in one place.
  • A queue in front of the partner, with a fixed number of workers sending at a controlled rate. This also smooths out spikes.

Also handle 429 Too Many Requests correctly: respect the Retry-After header and don't retry immediately. Keep a margin below the limit, because the partner's clock and window may not match yours.

Q. Every night at month-end, a reporting batch makes the online checkout slow. What do you do?

Answer: The batch and the online traffic share something: the same service instances, database or connection pool. The batch wins because it sends work as fast as it can. Fixes, roughly in order of effort:

  • Throttle the batch: a fixed rate or small pages with pauses.
  • Run it against a read replica or a reporting copy of the data.
  • Deploy the batch as a separate set of pods with its own connection pool, which is a bulkhead.
  • Give online requests priority, for example with separate queues, so batch work is shed first under pressure.

The principle: user-facing traffic and background traffic should never compete for the same limited resource without priorities.

Q. The autoscaler keeps adding and removing pods every few minutes, and latency spikes each time. Why?

Answer: This is flapping. Common causes: scaling on a metric that reacts to scaling itself, such as CPU that spikes during JVM startup; scale-down that happens too fast; and new pods that receive full traffic before they are warm. Use a stabilisation window for scale-down (the Kubernetes HPA default is 300 seconds), scale on a steadier signal such as requests per pod or queue depth, set sensible minimum replicas for peak hours, and make sure readiness probes only pass once the pod can really take traffic.

Q. Every deployment causes high latency for the first two or three minutes, then it settles. Why?

Answer: The JVM starts by interpreting bytecode and only compiles hot methods to optimised machine code with the JIT compiler after they have run many times. Caches are empty and connection pools are not yet filled. A new pod receiving full traffic immediately is slow until it warms up. Fixes: run warm-up requests before the readiness probe passes, use slow start on the load balancer (Envoy and AWS ALB support it) so new pods get a growing share of traffic, pre-fill connection pools, and use class data sharing to shorten startup. Rolling deployments that replace pods slowly also limit the effect.

3. Kafka and Messaging

Q. Consumer lag keeps growing. You scale from 6 to 12 consumer pods, and nothing improves. Why?

Answer: In a Kafka consumer group, each partition is assigned to at most one consumer. If the topic has 6 partitions, the extra 6 consumers sit idle. Options: increase the number of partitions, keeping in mind that this changes which partition a key goes to and can't easily be undone; make each consumer faster, for example by batching database writes; or process records in parallel inside a consumer while keeping per-key order, which libraries like Confluent's Parallel Consumer support. Also check whether one slow dependency, not Kafka, is the real bottleneck.

Q. Consumers keep rebalancing, the same messages are processed again and again, and lag never goes down. What is happening?

Answer: A consumer must call poll() again within max.poll.interval.ms (default 5 minutes). If processing a batch takes longer, for example 500 records (the default max.poll.records) at one second each, the consumer is considered dead and removed from the group. Its partitions move to another consumer, the uncommitted records are delivered again, and the same thing happens there. This is a rebalance loop. Fix: lower max.poll.records, make processing faster, or raise the interval if long processing is expected. Use static membership (group.instance.id) and cooperative rebalancing to reduce rebalances during rolling deployments.

Q. One Kafka partition has huge lag while the others are nearly empty. Why?

Answer: Records are assigned to partitions by a hash of the key. If one key is much busier than the others, such as one huge customer in a multi-tenant system, all of its events go to one partition and one consumer. This is a hot partition. If strict per-key order isn't needed, spread that key with a suffix ("tenant-9#1" to "tenant-9#8"). If order matters only per sub-entity, key by the sub-entity, such as the order ID instead of the tenant ID. For truly large tenants, a dedicated topic or consumer can also make sense.

Q. A bug in a consumer wrote wrong prices for the last three days. The bug is fixed. How do you repair the data?

Answer: If the events are still within Kafka's retention period, you can replay them: stop the consumer group and reset its offsets to a time before the bug, for example with kafka-consumer-groups.sh --reset-offsets --to-datetime. Before doing that, check two things. First, the consumer must be idempotent, or replaying will create duplicates. Second, side effects such as emails, payments or calls to partners must not be repeated; disable them during the replay or use a separate repair consumer that only rewrites the data. If replaying isn't possible, run a one-off correction job from the source of truth, and reconcile afterwards.

Q. A producer fails with RecordTooLargeException for some events. What do you do?

Answer: Kafka limits record size: by default around 1 MB on the broker (message.max.bytes) and on the producer (max.request.size). Raising the limits works for small increases, but large messages slow brokers, replication and consumers. The better pattern for large payloads is the claim check: store the large data in object storage, such as S3, and send a small event containing a reference to it. Also ask why the event is large; often it contains a full object graph when consumers only need a few fields.

Q. An OrderPaid event sometimes arrives before OrderCreated. How do you handle it?

Answer: Order is only guaranteed within one partition. If the events come from different topics, different producers, or a retry path, they can arrive in any order. Don't rely on timestamps, because different machines' clocks drift. Instead, give each event a version or sequence number per entity, ignore versions you have already applied, and park events that arrive early until the missing ones arrive.

OutOfOrderEventsDemo.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class OutOfOrderEventsDemo {

    // Every event carries the order's version: 1 = created, 2 = paid, 3 = shipped
    sealed interface OrderEvent permits OrderCreated, OrderPaid, OrderShipped {
        String orderId();
        int version();
    }
    record OrderCreated(String orderId, int version, String customer) implements OrderEvent {}
    record OrderPaid(String orderId, int version, String amount) implements OrderEvent {}
    record OrderShipped(String orderId, int version, String courier) implements OrderEvent {}

    static class OrderProjection {
        private final Map<String, Integer> appliedVersion = new HashMap<>();
        private final Map<String, String> status = new HashMap<>();
        private final Map<String, List<OrderEvent>> parked = new HashMap<>();

        void on(OrderEvent event) {
            int current = appliedVersion.getOrDefault(event.orderId(), 0);
            if (event.version() <= current) {
                System.out.println("  skip   " + name(event) + " (already applied)");
            } else if (event.version() > current + 1) {
                System.out.println("  park   " + name(event) + " (waiting for version " + (current + 1) + ")");
                parked.computeIfAbsent(event.orderId(), k -> new ArrayList<>()).add(event);
            } else {
                apply(event);
                drainParked(event.orderId()); // an earlier gap may now be filled
            }
        }

        private void apply(OrderEvent event) {
            String newStatus = switch (event) {
                case OrderCreated c -> "CREATED for " + c.customer();
                case OrderPaid p -> "PAID " + p.amount();
                case OrderShipped s -> "SHIPPED via " + s.courier();
            };
            appliedVersion.put(event.orderId(), event.version());
            status.put(event.orderId(), newStatus);
            System.out.println("  apply  " + name(event) + " -> " + newStatus);
        }

        private void drainParked(String orderId) {
            List<OrderEvent> waiting = parked.getOrDefault(orderId, List.of());
            boolean progress = true;
            while (progress) {
                progress = false;
                for (OrderEvent e : List.copyOf(waiting)) {
                    if (e.version() == appliedVersion.get(orderId) + 1) {
                        waiting.remove(e);
                        apply(e);
                        progress = true;
                    }
                }
            }
        }

        private static String name(OrderEvent e) {
            return e.getClass().getSimpleName() + "(v" + e.version() + ")";
        }
    }

    public static void main(String[] args) {
        OrderProjection projection = new OrderProjection();
        List<OrderEvent> arrivals = List.of( // the order the broker delivered them
                new OrderShipped("A-7", 3, "DHL"),
                new OrderPaid("A-7", 2, "59.00"),
                new OrderCreated("A-7", 1, "maya@example.com"),
                new OrderPaid("A-7", 2, "59.00")); // a duplicate delivery

        for (OrderEvent event : arrivals) projection.on(event);
        System.out.println("Final status: " + projection.status.get("A-7"));
    }
}
Output
  park   OrderShipped(v3) (waiting for version 1)
  park   OrderPaid(v2) (waiting for version 1)
  apply  OrderCreated(v1) -> CREATED for maya@example.com
  apply  OrderPaid(v2) -> PAID 59.00
  apply  OrderShipped(v3) -> SHIPPED via DHL
  skip   OrderPaid(v2) (already applied)
Final status: SHIPPED via DHL

Two events were parked and applied in the right order as soon as OrderCreated arrived, and the duplicate was skipped. In production, parked events are stored durably, and there is a timeout: if the missing event never arrives, the parked ones go to a dead letter topic and someone is alerted.

4. Data Consistency

Q. A user changes their delivery address, refreshes the page, and sees the old address. Why, and how do you fix it?

Answer: The write went to the primary database or the owning service, but the read came from a read replica, a cache or a read model that hasn't been updated yet. This breaks read-your-writes consistency, the expectation that you see your own changes. Options: read from the primary for that user for a short time after a write; return the updated data in the write response and show that in the UI; or include a version in the response and make the read path wait until the replica reaches it. Other users can still see the old address briefly; usually that is acceptable.

Q. A saga charged the customer, then the shipping step failed, and the refund compensation is failing too. What now?

Answer: Compensations can fail, so plan for it. Retry the refund with backoff, using the same idempotency key so it can't refund twice. If it still fails after the retry limit, move the saga to a "needs attention" state, alert, and give the support team a tool to see and finish stuck sagas. Never leave a saga silently half-done. Prevent it too: don't capture payment until the steps most likely to fail have succeeded. Authorise first, and capture when the order is ready to ship.

Q. Finance reports that a few orders each day have no matching payment record. How do you find and fix the cause?

Answer: Build a reconciliation job that compares the two services' data on a key, such as the order ID, for a time window, and reports orders without payments and payments without orders. Classify the mismatches: lost events (a missing outbox), failed consumers (dead letters), or timing (still in progress). Fix each root cause, and keep the job running permanently, because in distributed systems some drift is normal, and reconciliation is how mature systems detect it.

Q. Two services read and write the same customer table. You need to separate them. How?

Answer: Pick one owner, usually the service whose business rules control the data. Then: (1) the other service stops writing directly and calls the owner's API or sends commands instead; (2) for reads, the other service either calls the owner or keeps its own copy updated from the owner's events; (3) once no other service accesses the table, move it into the owner's own schema and remove the old access. Use change data capture during the transition if needed, and move one access path at a time.

Q. In an active-active setup in two regions, the same customer updates their profile in both regions at the same time. Which update wins?

Answer: Without a plan, one write silently overwrites the other. Options: route each customer to one home region so writes for one customer never happen in two places, which is the simplest and most common choice; last-writer-wins using a version or hybrid logical clock, accepting that one update is lost; merging per field; or conflict-free data types (CRDTs) for things like counters and sets. Explain which data can accept which option. A shopping cart can merge; an account balance must have one owner.

Q. Month-end finance needs a consistent snapshot across orders, payments and refunds, all in different services. How?

Answer: You can't take one transactional snapshot across separate databases. Instead, choose a cut-off time and build the report from events: each service publishes its changes, and a reporting store receives them all. When every source has confirmed it has processed everything up to the cut-off, for example by consumer lag reaching zero past that time, the report is built. Changes after the cut-off go into the next period. Add the reconciliation job described above, so the report states which items are still in progress.

5. Infrastructure and the JVM

Q. Pods are being killed with OOMKilled, but heap usage in your dashboards never goes above 60%. Why?

Answer: The container limit covers the whole process, not just the heap: heap, metaspace, thread stacks, the JIT code cache, direct and memory-mapped buffers (used by Netty and Kafka clients), and memory used by GC itself. If -Xmx is set close to the container limit, the non-heap memory pushes the process over and the kernel kills it, with exit code 137 and no Java OutOfMemoryError. Fix: size the heap with -XX:MaxRAMPercentage (often 60–75% instead of the default 25%), and measure non-heap memory with Native Memory Tracking (-XX:NativeMemoryTracking=summary and jcmd <pid> VM.native_memory).

Q. Average CPU is only 40%, but latency spikes regularly. Kubernetes shows the pods are fine. What could it be?

Answer: CPU throttling. A Kubernetes CPU limit is enforced in periods of 100 ms. A pod with a limit of 1 CPU and 8 busy threads, including GC and JIT threads, can use its whole quota in about 12.5 ms and is then paused for the rest of the period. The average looks low, but individual requests wait. Check the container_cpu_cfs_throttled_periods_total metric. Fixes: raise or remove the CPU limit while keeping requests, reduce GC threads, or use more pods with fewer threads each.

Q. About 1 in 1,000 requests to an internal service fails with 502 or "connection reset", with no pattern. What's happening?

Answer: A likely cause is a keep-alive timeout mismatch. HTTP clients reuse idle connections. If something between the client and the server, such as a load balancer or the server itself, closes idle connections after 60 seconds while the client keeps them for longer, the client sometimes sends a request on a connection that was just closed. The request fails at random. The rule: each side that reuses connections must drop idle ones sooner than the side that accepts them. Set the client's idle eviction below the load balancer's idle timeout, and the server's keep-alive above it. Safe automatic retries for idempotent requests hide the rest.

Q. All requests suddenly hang. Thread dumps show hundreds of threads waiting in HikariPool.getConnection. The database is healthy. Why?

Answer: The connection pool is empty because connections aren't coming back fast enough. Common causes: a transaction that stays open while it calls a remote service, so every slow remote call holds a database connection; a code path that doesn't close connections, which is a leak; or a sudden increase in concurrency, for example after switching to virtual threads. Turn on HikariCP's leakDetectionThreshold (disabled by default) to log where connections are borrowed and not returned, keep transactions short with no remote calls inside them, and make sure callers fail fast: HikariCP's connectionTimeout defaults to 30 seconds, which is longer than most users will wait.

Q. A service's memory grows slowly for days until it restarts. There are no errors. How do you find the cause?

Answer: This is usually a leak in the heap: something keeps references to objects forever. Typical culprits in microservices are unbounded in-memory caches or maps keyed by user or request ID, listeners that are registered but never removed, and ThreadLocal values that are never cleared on pooled threads. Take two heap dumps hours apart (jcmd <pid> GC.heap_dump) and compare which classes grew, using a tool such as Eclipse MAT. Fix by using bounded caches with expiry, such as Caffeine with maximumSize and expireAfterWrite.

Q. At 2 a.m., calls between all services fail with TLS handshake errors. Nothing was deployed. What do you suspect?

Answer: An expired certificate: an internal certificate authority, a mesh root certificate, or a certificate that was installed manually and forgotten. Because every service trusts the same chain, an expiry can break all of them at once. Mitigate by renewing and rolling out the certificate. Prevent it by automating issuance and renewal (for example with cert-manager or the service mesh), alerting on certificate expiry weeks ahead, and testing rotation regularly so it isn't a rare, risky event.

6. APIs and Integration

Q. A "generate report" endpoint takes 10 minutes, and clients and the gateway keep timing out. How do you redesign it?

Answer: Use the asynchronous request-reply pattern. The client sends POST /reports; the service stores a job and immediately replies 202 Accepted with a Location header pointing to a status resource such as /reports/jobs/91. A worker generates the report in the background. The client polls the status URL, with a Retry-After hint, or receives a webhook or notification when it's done, and then downloads the result. This keeps HTTP connections short, survives restarts if the job is stored, and allows progress reporting.

Q. Users upload 2 GB videos through the API gateway, and uploads fail or slow down every service behind it. What should change?

Answer: Large uploads shouldn't flow through the gateway and the services at all. Let the client ask your service for a pre-signed upload URL for object storage, then upload directly to storage. The storage service sends an event when the upload is complete, and your service processes the file asynchronously. This removes the load from your gateway, avoids gateway request-size and timeout limits, and allows resumable multipart uploads.

Q. Old mobile app versions can't be forced to update, but you need to change an API they use. How?

Answer: Mobile apps live for years, so treat their API as a long-lived contract. Keep the old behaviour behind a versioned endpoint or a Backend for Frontend for mobile that translates old requests to the new internal API. Measure traffic by app version (send it in a header), and only remove old versions when their traffic is negligible. Some teams also build a "minimum supported version" check into the app, so it can ask users to update when it's truly necessary.

Q. A payment provider sends webhooks. Sometimes you get the same event twice, and sometimes "refund" arrives before "payment succeeded". How do you build the receiver?

Answer: Most providers state that webhook events can be delivered more than once and in any order, Stripe for example. Build the receiver in three steps. (1) Verify the signature, so nobody can fake events. (2) Store the raw event and reply 2xx quickly; slow responses cause the provider to retry, creating more duplicates. (3) Process asynchronously and idempotently, using the provider's event ID for deduplication. For ordering, don't trust the event sequence; fetch the current state of the object from the provider's API when processing, or use the version and parking logic described earlier.

7. Security and Compliance

Q. A customer asks you to delete all their personal data, but it is spread over 20 services and Kafka topics that keep data for months. How?

Answer: This is the GDPR right to erasure. Publish a CustomerErasureRequested event that every service handles by deleting or anonymising its own data, and track confirmations from each service so you can prove completion. For Kafka: with compacted topics, write a tombstone (a record with the key and a null value), and the key's records are removed after compaction. For long-lived event logs that can't be changed, use crypto-shredding: encrypt each customer's personal data with a per-customer key, and delete the key, so the data becomes unreadable. Don't forget backups, logs and search indexes.

Q. You discover that a service logged full access tokens and database passwords for the last month. What do you do?

Answer: Treat it as an incident. Rotate the exposed secrets first: database passwords, API keys, and signing keys if necessary, and revoke active tokens. Then remove the secrets from log storage, check access logs to see who could read those logs, and decide with the security team whether it must be reported. Prevent recurrence with log masking filters for known secret patterns, short-lived credentials so leaks expire quickly, and a rule that request and response bodies and headers are never logged in full.

8. Quick Triage Table: Symptom, Cause, First Check

SymptomLikely causeFirst thing to check
Service fails again right after recoveringRetry storm, cold cachesRequest rate vs normal, retry metrics
Database spikes when a hot key expiresCache stampedeCache miss rate and query count per key
Lag grows, more consumers don't helpPartitions ≤ consumers, slow dependencyPartition count, per-partition lag
Same messages processed again and againRebalance loopRebalance logs, processing time per batch
OOMKilled with healthy heap graphsNon-heap memory over the container limitNative Memory Tracking, -Xmx vs limit
Latency spikes at low average CPUCPU throttlingcontainer_cpu_cfs_throttled_periods_total
Random rare 502 or connection resetKeep-alive timeout mismatchIdle timeouts on client, load balancer, server
Threads waiting on getConnectionPool leak or long transactionsHikari leak detection, transaction length

9. Common Mistakes in Scenario Rounds

1. Jumping to a fix before clarifying. "Add more pods" is wrong for half of these scenarios, such as Kafka lag that more consumers can't fix. Ask what changed and what the metrics show first.

2. Skipping mitigation. Explaining the root cause for five minutes while users are still affected sounds academic. Say how you would stop the impact first.

3. Fixing the symptom only. Restarting pods hides a memory leak, and raising a timeout hides a slow query. Name the real cause and the permanent fix.

4. Forgetting side effects when replaying or retrying. Replaying events or retrying calls can send emails or charge cards again. Always ask what else happens when the code runs twice.

5. No prevention step. Every answer should end with what you would add so it doesn't happen again: an alert, a limit, a test or a design change.

10. Best Practices

  • Use the five-step order: clarify, stabilise, diagnose, fix, prevent.
  • Name specific evidence: the metric, log line, trace or dump you would look at, not just "I would check the logs".
  • Know your limits and defaults: Kafka poll interval and record size, connection pool timeouts, container memory and CPU behaviour.
  • Design for running twice: idempotent consumers, idempotent compensations and safe replays.
  • Separate traffic classes: online vs batch, and big tenants vs small ones.
  • Rehearse failure: game days and chaos experiments make real incidents feel familiar.

11. Summary

  • Scenario-based microservices interview questions test how you reason about a running system, not definitions.
  • Answer in order: clarify, stabilise, diagnose, fix, prevent.
  • Load scenarios are often caused by synchronisation: retry storms and cache stampedes; jitter and single flight break it.
  • Kafka scenarios depend on partitions, poll intervals and replay safety.
  • Data scenarios need versions, parking of early events, reconciliation and a plan for failed compensations.
  • JVM scenarios in containers often come from non-heap memory, CPU throttling, keep-alive mismatches and connection pool leaks.

Practise these scenario-based microservices interview questions out loud, and connect each one to an incident you have seen or a system you have run. Together with the Microservices Interview Questions page, they cover what senior Java interviews ask.