Microservices Interview Questions, Answered by Running Them

Microservices interview questions are mostly about failure, and the stock answers are pattern names. Every answer here came from running Spring Boot 4.1.1 services. Three hops each retrying three times sent 64 calls to the failing fourth service, not 9. An idempotency key checked with a SELECT still created duplicate orders in 9 of 15 races. Resilience4j's default circuit breaker let 100 failing calls through before opening, and an outbox delivered one event 7 times.

Microservices interview questions sound like architecture and are really about failure. "What is a circuit breaker" has an answer everyone gives; "how many calls reach the dead service before it opens" is the follow-up, and almost nobody has seen the number.

So every answer below came from running it: small Spring Boot 4.1.1 services (Spring Framework 7.0.9, Tomcat 11.0.24) on OpenJDK 25.0.1, Resilience4j 2.4.0, H2 and PostgreSQL 17. They report counts and status codes rather than latency, because counts do not change when the machine is busy. Several contradict the answer you would normally give.

The openers, in one line each

Monolith against microservices. One deployable against several that deploy independently. The price is that a method call becomes a network call, and a network call can fail slowly — what a stopped or slow downstream does to the caller is measured separately, so the timeouts are not repeated here.

How services find each other. By a name that something resolves: a Docker network or Kubernetes Service, or a registry such as a Eureka server when instances come and go.

Synchronous against asynchronous. An HTTP call couples the caller to the callee being up right now; a message broker decouples that and brings its own problems, such as what makes a consumer group rebalance.

What a gateway is for. One entry point, so clients need not know how many services there are, and cross-cutting concerns such as authentication live in one place.

Database per service. Each service owns its tables and no other service reads them directly. The consequence is that a business operation spanning two services has no single transaction, which is where the dual-write question near the end comes from.

Now the ones worth running.

The client timed out. Should it retry the POST?

The folklore answer is "yes, with backoff". An orders service commits the row and then takes 1.5 seconds to answer. The caller has a 1-second read timeout and Spring Framework 7's @Retryable with nothing changed:

@Retryable   // every attribute left at its default
String postWithRetry(String url, String key) { return postOnce(url, key); }

The Spring reference states the default: "with at most 3 retry attempts (maxRetries = 3) after an initial failure, and a delay of 1 second between attempts." One checkout:

attempts=4 failed: ResourceAccessException   HTTP 502 in 7.10s
orders table: 4 rows

The user was told the order failed, and there are four orders. Every timeout was a response that got lost, not a request that failed; the server did the work each time. A retry is only safe on an operation where doing it twice has the same effect as doing it once, and a plain POST is not one.

The 7.10 seconds is the policy made visible: four 1-second timeouts and three 1-second delays. Fewer retries would only make the duplicates rarer. The fix is making the second attempt harmless, which is the next question.

Does an idempotency key fix it?

Half of it. The client generates one key per checkout, sends it on every attempt, and the server refuses to create a second order for a key it has seen. Sequentially, both implementations tried worked — the second attempt found the first order, 2 attempts and 1 row.

The difference shows under concurrency. Twenty simultaneous POSTs carrying one key, fifteen rounds each:

SELECT the key, then INSERT      rows per round: 20 1 1 4 1 6 3 2 1 2 1 3 1 2 2
INSERT, unique constraint wins   rows per round:  1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

The check-then-insert version created duplicates in 9 of 15 rounds, and all twenty on the first, cold round. Two requests can both run the SELECT before either runs the INSERT, so both see no order. The version where the key column is unique produced exactly one row every time, because the database decides which insert wins and the loser catches DuplicateKeyException and returns the existing order.

One detail worth knowing: the IETF Idempotency-Key draft says a request "retried before the original request completed" should get a 409 conflict. The version here replays the stored order with a 200 instead, which is simpler and less informative.

Three services each retry three times. How many calls reach the fourth?

"Nine" is the common guess — three retries, three hops. Four Spring Boot services in a chain, edge → orders → payments → ledger, the ledger answering 503, and every caller using that default @Retryable:

edge      received 1   made 4
orders    received 4   made 16
payments  received 16  made 64
ledger    received 64
HTTP 503 in 64.21s   (second run: 64.59s)

Sixty-four. Each layer makes four attempts, one plus three retries, and each attempt at a layer triggers four at the next, so the bottom sees 4 × 4 × 4. It also took a minute: three seconds of delays at the bottom, 4 × 3 + 3 = 15 in the middle, 4 × 15 + 3 = 63 at the edge. With retries left only at the edge, the ledger received 4 calls and the request failed in 3.18 seconds.

That multiplication is why a retry storm finishes off a service that was only struggling. The answer interviewers want is a policy: retry at one layer, fail fast everywhere else, and add jitter so clients do not retry in step.

When does a circuit breaker open?

"After a few failures" is the usual answer. Resilience4j's defaults, printed from CircuitBreakerConfig.ofDefaults(): failure-rate threshold 50%, count-based sliding window of 100, minimum number of calls 100, 60 seconds open, 10 calls in half-open.

200 calls through a default breaker to a service that fails every time:

default   reached downstream 100   refused by breaker 100   state OPEN
tuned     reached downstream  10   refused by breaker 190   state OPEN

A hundred calls hit a dead service before the default breaker did anything, and the ledger's own counter agreed. The Resilience4j documentation is explicit about why: "If only 9 calls have been recorded the CircuitBreaker will not transition to open even if all 9 calls have failed." The tuned breaker used a window and minimum of 10 and a 2-second wait.

So the tuning question is really about traffic. The window here counts calls, not time: a dependency that receives ten calls a minute would need ten minutes of failures before a default breaker had enough calls to calculate a rate at all.

Then recovery, same run twice:

20 calls, downstream failing       10 failed, 10 refused    OPEN
downstream healed, 5 more calls      5 refused               OPEN
waited 2.1s                                                 still OPEN
5 more calls                        5 succeeded             CLOSED
transitions: CLOSED→OPEN, OPEN→HALF_OPEN, HALF_OPEN→CLOSED

Two things to say out loud. A healed downstream is still refused until the wait expires. And the breaker was still OPEN after the wait: with automaticTransitionFromOpenToHalfOpenEnabled false by default, the first call after the wait moves it to HALF_OPEN, not the clock.

How do you stop one slow dependency taking down the whole service?

With a bulkhead, and the detail is whether it waits or refuses. A service with 10 Tomcat threads receives 30 concurrent calls to a dependency that takes 5 seconds; a second later, 10 requests to an unrelated /ping endpoint. Clients give a slow call 20 seconds and a ping 2. Every configuration ran twice on a fresh instance, with identical results:

protection                           slow calls              /ping answered
none                                 30 × 200                0 of 10
@ConcurrencyLimit(5)                 15 × 200, 15 gave up    0 of 10
@ConcurrencyLimit(5, policy=REJECT)  5 × 200, 25 × 503       10 of 10
Resilience4j Bulkhead(5, wait 0)     5 × 200, 25 × 503       10 of 10

With no protection, the slow calls held all ten threads and the service stopped answering requests that have nothing to do with the dependency. Spring's new @ConcurrencyLimit did not help with its default policy, which is BLOCK: it limited the dependency, but the waiting callers were parked on Tomcat threads, so /ping still got nothing. The same reference page describes it that way — like a pool "that blocks access if its limit is reached". Switching the policy to REJECT or using a Resilience4j bulkhead with no wait returned 503 at once and kept the service alive. A fast 503 is also something the caller can act on — serve a cached answer, degrade the page, back off — where a request parked for twenty seconds offers nothing until it fails.

With spring.threads.virtual.enabled=true the picture changed: /ping answered 10 of 10 even unprotected, so the ten-thread setting no longer capped the requests being served. The blocking @ConcurrencyLimit still left 15 of 30 callers waiting until they gave up — cheaper to park, just as slow for the user.

Liveness or readiness: which one checks the database?

Neither, by default. With Spring Boot's probes enabled and PostgreSQL stopped:

/actuator/health            HTTP 503 in 30.02s
/actuator/health/liveness   HTTP 200 in 0.004s
/actuator/health/readiness  HTTP 200 in 0.002s

The Spring Boot reference says so: "By default, Spring Boot does not add other health indicators to these groups." For liveness that is right — "The “liveness” probe should not depend on health checks for external systems", because a restart cannot bring the database back.

One trap sits in that first table: the aggregate /actuator/health did include the database. A liveness probe pointed at it, rather than at /actuator/health/liveness, would have seen a 503 — exactly the restart the documentation warns about.

Adding the database to readiness (management.endpoint.health.group.readiness.include=readinessState,db) made readiness return 503. The first check after the stop failed in 6 milliseconds. Every later one took 30 seconds, with a warning in the log:

Health contributor org.springframework.boot.jdbc.health.DataSourceHealthIndicator (db) took 30007ms to respond

That is HikariCP waiting its default connection timeout for a connection. The Kubernetes probe documentation gives timeoutSeconds as "Defaults to 1 second", so a probe with defaults would give up long before the answer arrived. Setting spring.datasource.hikari.connection-timeout=2000 brought it to 2.01 seconds. The value is milliseconds: 2s failed at startup with "failed to convert java.lang.String to long".

The order saved but the event did not. What now?

This is the dual-write question: one request writes to a database and publishes an event, and the two cannot share a transaction. Both orders of operation, measured by counting rows and received events:

commit order, then publish (consumer stopped)     HTTP 500   orders 1   events 0
publish inside the transaction, then a failure    HTTP 500   orders 0   events 1

The first leaves an order nobody downstream will hear about, and the client got a 500 for an order that exists. The second announces an order that was rolled back: putting the publish inside the transaction does not make it part of the transaction, because the HTTP call had already left the process when the rollback happened. There is no order of the two lines that is correct.

The transactional outbox writes the order and an outbox row in the same transaction, and a relay publishes unsent rows afterwards. With the consumer stopped, the checkout returned 201, and three seconds later there was 1 order and 1 unsent outbox row. Once the consumer started, it received the event within three seconds and the row was marked sent.

The part most answers skip: the outbox is at-least-once, not exactly-once. The relay sends and then marks the row sent, which is a dual write of its own. With the consumer taking 1.5 seconds against the relay's 1-second timeout:

after 10 s   consumer received 7   distinct event ids 1   outbox row still unsent

Seven deliveries of one event, in both runs. The consumer has to deduplicate by event id — the idempotency problem from the second question, one service further along.

Answering microservices interview questions out loud

Each question has a pattern name and a number, and the interviewer is listening for the number. Retries need idempotency, so a key has to be a unique constraint. Retries multiply, so one layer retries. A breaker needs a minimum number of calls, so the defaults let a hundred failures through. A bulkhead has to refuse, so a limit that blocks protects the dependency and not the service. Readiness can check the database, so bound the connection wait. An outbox never loses an event, so the consumer must expect duplicates.

Where these services run changes the probes and not the arithmetic — Docker against Kubernetes covers that.

[!TAKEAWAY] Count the calls. Sixty-four requests at the bottom of three retrying hops, four orders from one failed checkout, a hundred calls through a default circuit breaker and seven deliveries of one outbox event are the answers that separate having read about microservices from having watched them fail.

Frequently asked questions

How many retries should a microservice do?
Retry at one layer, usually the one closest to the user, and let the layers below fail fast. Measured here, three services each using Spring's default of three retries turned one request into 64 calls on the failing service and 64 seconds of waiting; retrying only at the edge made 4 calls and failed in 3.2 seconds. Retries also need an idempotent operation underneath them, or every retry of a POST is a new order.
Is an idempotency key enough to stop duplicate orders?
Only if the database enforces it. A key checked with a SELECT before the INSERT stopped sequential retries but let 20 simultaneous requests with one key create up to 6 orders, and occasionally all 20. A unique constraint on the key column produced exactly one order in 15 of 15 rounds, because the database, not the application, decides which insert wins.
What is the difference between a circuit breaker and a retry?
A retry makes more calls to a failing service in the hope one succeeds; a circuit breaker makes fewer, by refusing calls once the failure rate crosses a threshold. They pull in opposite directions, which is why retries inside a breaker must be counted carefully. Measured with Resilience4j tuned to a window of 10, a dead downstream received 10 calls out of 200; the other 190 were refused without touching the network.
Should the readiness probe check the database?
Spring Boot does not by default, and its documentation calls it a judgement call. If you add it, bound the connection wait: with HikariCP's default 30-second connection timeout, the readiness endpoint took 30 seconds to report DOWN once the pool was empty. Never put the database in liveness, where a failing check means a restart that cannot bring the database back.
Does the outbox pattern give exactly-once delivery?
No. It guarantees an event is never lost once the order commits, which the plain dual write does not, but the relay has its own gap between sending and marking the row sent. A consumer that answered slower than the relay's timeout received the same event 7 times in 10 seconds. The consumer has to deduplicate by event id, which is the same idempotency problem again.

References

  1. Resilience Features (Spring Framework reference)Spring
  2. CircuitBreakerResilience4j
  3. Spring Boot Reference: Actuator EndpointsSpring
  4. Liveness, Readiness, and Startup ProbesKubernetes
  5. The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07)IETF