Data Consistency and Distributed Transactions Questions
Maintaining correctness of state across services and replicas: eventual consistency, conflict resolution (last-write-wins, CRDTs, vector clocks), the saga pattern, two-phase commit, and idempotency keys for exactly-once effects. Covers when to trade strict consistency for availability and how to reason about read-your-writes and monotonic guarantees. Focuses on the application/service layer rather than storage-engine internals.
Architect a transactional outbox pattern for reliably publishing domain events when committing a write to the primary database. Include the outbox table schema, the reader design (and how you'd compare a polling reader against a change-data-capture-based reader), ordering guarantees, idempotency handling on the consumer side, and how you would scale the reader for high throughput.
Sample Answer
Direct answer: A production-grade transactional outbox service has three pieces: the application's database with an outbox table written in the same local transaction as the business change, a relay/reader that publishes unpublished rows to the broker (polling or CDC-based, CDC meaning change-data-capture), and enough retry/dedup discipline on both sides that the whole thing delivers at-least-once without ever silently losing an event.
Structured elaboration
Outbox table schema. At minimum: id (primary key, also usable as an idempotency/dedup token), aggregate_id (what business entity this event is about, often used as the broker partition key to preserve per-entity ordering), event_type, payload (serialized event body), created_at, and published (boolean, or a published_at timestamp, null until published). An index on (published, created_at) supports the "give me the oldest unpublished rows" query the reader needs.
Reader design: polling vs streaming. A polling reader periodically queries for published = false rows, simple to build and reason about, but adds latency bounded by the poll interval and puts a steady, small query load on the database. A CDC-based (streaming) reader instead taps the database's write-ahead log or replication stream (e.g. via Debezium) to be notified of new outbox rows as they're written, giving near-real-time delivery with no polling load, at the cost of running and operating CDC infrastructure and a slightly more complex failure story (the CDC connector itself can fall behind or need to resume from a checkpoint).
Ordering guarantees. If per-entity ordering matters (e.g. all events for order 501 must be delivered in the order they were created), the reader must publish using aggregate_id as the partition/routing key, so a partitioned broker (like Kafka) preserves order within that key even while allowing parallelism across different keys. Global ordering across all entities is usually not worth the throughput cost and isn't needed by most consumers.
Idempotency handling on the consumer side. Since the relay guarantees only at-least-once delivery, every event's payload includes a stable ID (the outbox row's id, or a domain-level event ID) that consumers use to deduplicate, typically via a small "seen event IDs" store with a bounded retention window matched to how long redelivery could realistically be delayed.
Scaling the reader for high throughput. A single polling reader eventually becomes a bottleneck; horizontal scaling requires either sharding the outbox table (e.g. by a hash of aggregate_id) with one reader instance per shard, or, for a CDC-based reader, letting the CDC connector's own partitioning handle parallelism. Batching reads and publishes (rather than one row per query/publish round-trip) is usually the first and cheapest throughput lever before reaching for sharding.
Worked example. An orders platform doing 2,000 writes/sec needs its outbox reader to keep pace. A single polling reader batching 200 rows per poll every 200ms caps out at 200 / 0.2s = 1,000 rows/sec, below the required rate regardless of how the rest of the system is tuned. Moving to a CDC-based reader (Debezium tailing the outbox table's WAL, write-ahead log) removes the poll-interval ceiling entirely, since new rows are picked up as they're written to the log rather than on a fixed cadence, at the cost of adding a Kafka Connect cluster and its own on-call burden that didn't exist with the simpler polling design; the actual sustained throughput and delivery latency the CDC path achieves would need to be measured against the real database's write-ahead log volume and Kafka Connect's own configured parallelism, not assumed from the pattern alone.
Trade-offs and pitfalls. Teams often under-invest in the outbox table's own cleanup: published rows accumulate forever unless there's a retention/archival job, which eventually degrades both the outbox query performance and, for CDC-based readers, the size of the write-ahead log the CDC connector has to process on any resume-from-checkpoint scenario.
Design API semantics and a contract to allow clients to retry complex 'create-with-side-effects' operations safely (for example: create-order that triggers inventory reservation and payment). Define idempotency key structure, client responsibilities, server guarantees (at-most-once vs idempotent-create), visibility of side-effects to users, and error semantics.
Sample Answer
Direct answer: For a create-with-side-effects operation like create-order (which triggers inventory reservation and payment), the API contract needs a client-generated idempotency key that scopes the ENTIRE multi-step operation, not just the first HTTP call, so a client retry after a timeout re-resolves to the same underlying order and side effects rather than triggering a second one; the server tracks the operation's progress against that key and returns a consistent result regardless of how many times the client retries.
Structured elaboration
Idempotency key structure. The client generates a key once per logical intent (e.g. a UUID generated when the "place order" button is clicked, reused on every retry of that same click, never regenerated on retry). The server stores this key alongside the operation's current state and final result once known, exactly the shape used for a single-endpoint idempotent write, but here the "operation" spans multiple internal steps (order creation, inventory reservation, payment) rather than a single database write.
Client responsibilities. Generate the key once and persist it (e.g. in local state) before the first attempt, reuse the SAME key on every retry of the same logical action, and never reuse a key for a genuinely different order (e.g. a second, separate purchase needs its own key).
Server guarantees: idempotent-create vs at-most-once. The contract promises idempotent-create semantics: retrying with the same key is guaranteed to return the result of the FIRST successful attempt (or the current in-progress status), never trigger a second inventory reservation or a second charge, this is stronger than plain at-most-once (which would just refuse to retry at all) and is what actually lets clients retry safely after an ambiguous failure like a timeout.
Visibility of side effects to users. Because the operation spans multiple steps that don't all complete instantly, the API returns a status field alongside the order/operation ID: pending (steps still in flight), completed (all steps succeeded), or failed (a step failed and compensations, if any, have run). A client polling on the same idempotency key (or the returned operation ID) gets a consistent, current view rather than each poll re-triggering work.
Error semantics. A definitive rejection (e.g. inventory genuinely unavailable) returns a failed status with a reason, and the idempotency key is now permanently associated with that failed outcome, a retry with the SAME key returns the same failure rather than re-attempting (since re-attempting the same operation with the same inputs would fail again anyway); a genuinely NEW attempt requires a new key, communicating the client's intent to try again as a fresh action, which matters if the failure was due to a transient condition the client wants to retry past.
Worked example. Client calls POST /orders with Idempotency-Key: 8f3c... and the order payload. The server hasn't seen this key before, so it starts the underlying saga (create order, reserve inventory, charge payment), stores status: pending against the key, and returns 202 Accepted with an operation ID. A network blip means the client never sees this response, and retries the SAME POST /orders call with the SAME idempotency key. The server recognizes the key, sees the saga is still pending, and returns the current status (again 202, same operation ID) WITHOUT starting a second saga. The client can now poll GET /orders/{operation_id} (itself naturally idempotent, a read) until it sees completed or failed.
Trade-offs and pitfalls. A common design mistake is treating the idempotency key as scoping only the first HTTP request rather than the whole underlying operation, if the key is discarded once the initial 202 response is sent, a later retry re-triggers the ENTIRE saga from scratch, defeating the purpose. The key must remain valid and checked for the full lifetime of the operation it protects, not just the initial acknowledgment.
Explain the differences between Lamport clocks, vector clocks, and logical timestamps in general. For each, state what it can and cannot detect (ordering versus true concurrency) and walk through a brief example of how it's updated on send and receive events.
Sample Answer
Direct answer: Lamport clocks give you a total ordering consistent with causality (if A happened-before B, A's Lamport timestamp is smaller), but they CAN'T tell you whether two events are truly concurrent, two unrelated events can end up with different Lamport timestamps that make them look ordered even though neither actually caused the other. Vector clocks fix exactly this gap: they can tell you happened-before, happened-after, AND genuinely concurrent, at the cost of a counter per replica instead of a single number.
Structured elaboration
Lamport clocks: mechanism. Each process keeps a single integer counter. On a LOCAL event, increment the counter. On SENDING a message, attach the current counter value. On RECEIVING a message with timestamp T, set the local counter to max(local_counter, T) + 1. This guarantees: if event A causally precedes event B (a happens-before relationship via a chain of local events and messages), then timestamp(A) < timestamp(B). But the CONVERSE isn't guaranteed: timestamp(A) < timestamp(B) does NOT imply A happened before B, they might be completely unrelated events that just happened to get ordered that way by the counter mechanics.
Vector clocks: mechanism. As described for causality tracking, each process keeps a vector of counters, one per process, and comparison (element-wise) tells you happened-before, happened-after, or concurrent, unambiguously. This is strictly more information than a Lamport clock provides.
What each can and cannot detect.
| Can detect happened-before? | Can detect true concurrency? | Metadata overhead | |
|---|---|---|---|
| Lamport clock | Yes (via the ordering property) | No, false ordering can appear between unrelated events | One integer per event |
| Vector clock | Yes | Yes, this is exactly what element-wise incomparability signals | One integer PER PROCESS per event |
| Logical timestamps (general term) | Depends on the specific scheme | Depends on the specific scheme | Depends |
Worked example. Two independent processes P1 and P2 never communicate. P1's local Lamport counter reaches 5 after some local events; P2's, independently, reaches 3. If you now compare timestamp(P1's event) = 5 and timestamp(P2's event) = 3, it LOOKS like P2's event happened before P1's (smaller timestamp), but there's no actual causal relationship at all, they're on completely independent tracks. A vector clock comparing {P1:5, P2:0} against {P1:0, P2:3} correctly identifies these as concurrent (neither vector dominates), the Lamport clock's single integer has no way to express that.
Send/receive update walkthrough (Lamport). P1 has local counter 2 and sends a message; the message carries timestamp 3 (2, then incremented for the send event itself). P2 has local counter 1 when it receives this message; it computes max(1, 3) + 1 = 4 and sets its own counter to 4. Any subsequent event P2 does will carry a timestamp of at least 4, correctly reflecting that it happens after P1's send (which had timestamp 3), preserving the causal ordering property even though P2's OWN prior activity (counter 1) was much lower.
Send/receive update walkthrough (vector clock). Two processes, P1 and P2, start at {P1:0, P2:0}. P1 does a local event, incrementing its own entry: {P1:1, P2:0}, then sends a message carrying that vector. P2, before receiving it, does its own local event, incrementing its own entry: {P1:0, P2:1}. When P2 RECEIVES P1's message, it merges element-wise (max per entry: {P1: max(0,1)=1, P2: max(1,0)=1}) and then increments its own entry for the receive event itself, landing at {P1:1, P2:2}. Comparing P2's pre-receive vector {P1:0, P2:1} against P1's sent vector {P1:1, P2:0} shows neither dominates, correctly flagging P1's local event and P2's local event as concurrent, exactly the distinction a Lamport clock's single integer cannot make (a Lamport clock would just assign each an arbitrary-looking total order with no way to mark them as concurrent).
Trade-offs and pitfalls. Choosing Lamport clocks when you actually NEED to detect true concurrency (e.g. for conflict detection in a replicated data store) is a real design bug, not a simplification, since Lamport clocks will silently impose a false ordering on genuinely conflicting concurrent writes rather than flagging them as needing resolution. Lamport clocks are the right, cheaper choice specifically when all you need is a consistent total order for something like event logging or debugging causality chains, not for detecting write-write conflicts.
Explain the transactional outbox pattern: what problem it solves, the usual flow (writing the business row and an outbox record in the same database transaction, then a relay reading the outbox and publishing), and how it helps achieve reliable, idempotent event delivery when the database and the messaging system are separate systems.
Sample Answer
Direct answer: The transactional outbox pattern solves the "dual write problem": you can't atomically write to your own database AND publish a message to a separate message broker, because they're two different systems with no shared transaction. The pattern collapses this into a single local transaction by writing the outgoing message as a row in an "outbox" table inside the SAME database transaction as the business write, then a separate relay process reads that table and publishes to the broker afterward.
Structured elaboration
The problem being solved. If you write your business row and then separately call the message broker to publish an event, there's a window where one can succeed and the other fail: the database commit succeeds but the process crashes before publishing (the event is lost), or the publish succeeds but the database transaction then fails to commit (a message goes out describing something that never actually happened). Since a database transaction and a message-broker publish aren't part of the same atomic unit, no ordering of the two operations makes this fully safe on its own.
The usual flow. In the SAME local database transaction that makes the business change (e.g. inserting an order row), you also insert a row into an outbox table describing the event to publish (e.g. {event_type: "OrderCreated", payload: {...}, published: false}). Because both writes are in one transaction, they're atomic with respect to each other: either both the business row and the outbox row exist, or neither does. A separate relay process then reads unpublished outbox rows, publishes each to the broker, and marks it published, decoupled from the original request's timing.
Reliable, idempotent event delivery. The relay delivers with at-least-once semantics (it might publish a row, then crash before marking it published, and re-publish it on restart), so consumers of these events must be idempotent themselves, typically by deduplicating on an event ID carried in the outbox row. This is what makes the pattern actually deliver reliably: nothing is lost (the outbox row survives any crash, it's in the database), and duplicates are handled at the consumer, rather than trying to achieve exactly-once delivery at the transport layer, which is not achievable across a database and an independent broker.
Relay implementation choices. A polling relay periodically queries for unpublished rows (simple, but adds latency proportional to the poll interval, and some polling load on the database). A change-data-capture-based relay instead taps the database's own replication/write-ahead log to notice new outbox rows as they're written (lower latency, no polling load, but requires CDC infrastructure like Debezium and a bit more operational surface).
Worked example. An Orders service processes POST /orders: within one database transaction, it inserts the new order row AND an outbox row {event: "OrderCreated", order_id: 501, published: false}. The transaction commits, atomically, both exist. A relay (polling every 500ms, say) finds this unpublished row, publishes OrderCreated to Kafka, then updates the row to published: true. If the relay crashes after publishing but before the update, it will re-publish the same row on restart, a downstream consumer that's already deduplicating on order_id (or a dedicated event ID) simply ignores the duplicate.
flowchart LR
A[Service writes<br/>business row] -->|same local transaction| B[Outbox row inserted]
B --> C[(Outbox table)]
C -->|relay reads unpublished rows| D[Relay / Poller]
D -->|publish| E[Message Broker]
D -->|mark published| C
E --> F[Downstream consumer<br/>dedup on event id]
Trade-offs and pitfalls. The pattern only solves the WRITE-side atomicity problem; it doesn't make the eventual publish instantaneous (there's always some relay lag), and it pushes the deduplication responsibility onto every consumer, which is a real, ongoing design obligation, not a one-time setup cost.
Design a multi-region user profile service that must support 100M users, 50k profile updates per second globally, and 1M reads per second. Requirements: users see their own updates immediately (read-your-writes) within a region, other users see updates eventually (within a bounded window), and 99th-percentile read latency stays low per region. Sketch the high-level architecture, replication strategy, and how you provide the read-your-writes guarantee without strong global coordination.
Sample Answer
Direct answer: At 100M users and 50k updates/sec, you need per-user data partitioned by region with a "home region" per user for writes, asynchronous cross-region replication for eventual global visibility, and a session-scoped mechanism (sticky routing to the user's home region, or a causal token) so each user sees their own updates immediately without requiring global synchronous coordination.
Structured elaboration
Partitioning and write routing. Each user is assigned a home region (based on signup location or explicit preference), and writes for that user's profile are always directed there, giving each user's writes a single, consistent, low-latency write path with no cross-region coordination needed per write. This is what makes 50k writes/sec globally distributed and still individually cheap: it's 50k INDEPENDENT single-region writes, not 50k globally-coordinated ones.
Cross-region replication. Each region's writes replicate asynchronously to every other region (a standard multi-region replication topology, e.g. a change stream fanning out from each region's primary store to the others). This is what provides "eventually visible to other users" within the required bound (here, one minute), the replication pipeline's own throughput and lag characteristics need to comfortably clear that bound under peak load, with monitoring on actual observed lag, not just a theoretical budget.
Read-your-writes without global coordination. Since the user's OWN writes always land in their home region, and their own subsequent reads can be routed to that SAME home region (sticky, based on the user's identity, not their current network location) for some bound (or indefinitely), the user always sees their own latest update, at native single-region latency, no waiting for cross-region replication for their OWN view. Other users reading this profile from a different region see it once replication catches up, within the required window, satisfying "others see it eventually within 1 minute" without that requirement touching the write or same-user-read path at all.
Achieving sub-50ms P99 read latency per region. Reads (for other users viewing a profile, not the owner's own reads) are served from the LOCAL region's replica, so they never cross a region boundary, keeping latency to local-network/local-disk numbers rather than being bounded by cross-region round-trip time (which alone would often exceed 50ms). This is only possible because the read doesn't need to be perfectly fresh, it needs to be fresh within a minute, which the async replication path already provides.
Handling the home-region-unavailable case. If a user's home region has an outage, either the user experiences degraded write availability (a real, honest trade-off of this design, since the write path is intentionally NOT cross-region-coordinated) or the system fails over the user's home-region assignment to another region (a more complex, operationally significant decision that itself needs careful handling to avoid conflicting writes from the old and new home regions during the transition).
Worked example. User in Region A updates their bio. The write commits to Region A's primary (their home region) as a purely local operation, with no cross-region network hop on the critical path, so its latency is bounded by local disk/network characteristics rather than by inter-region round-trip time, the same reasoning that keeps the P99 read latency for other users' LOCAL reads low. The response confirms success, and if the user immediately reloads their own profile, that read is also routed to Region A (sticky by user identity), showing the update instantly, own-write visibility achieved with zero cross-region dependency. Meanwhile the change replicates asynchronously to Regions B and C; a different user in Region B viewing this profile sees the OLD bio for however long replication takes (comfortably under the 1-minute bound under normal load, monitored explicitly for tail cases during regional replication backlogs).
Trade-offs and pitfalls. A common design mistake at this scale is routing the OWNER's reads based on their CURRENT network location rather than their home region (e.g. "nearest region" routing applied uniformly to all reads), which breaks read-your-writes the moment a user travels or is routed to a different region than the one their write landed in, own-write reads need identity-based (not network-proximity-based) routing specifically for this reason.
Unlock Full Question Bank
Get access to all Data Consistency and Distributed Transactions interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.