Marketplace, Dispatch, and Logistics System Design Questions
Designing two-sided marketplaces and real-time operational platforms end to end, where the domain shapes the architecture: rider/driver and order/courier matching and dispatch (batched vs greedy assignment, single-assignment guarantees, re-matching, pooling), order, booking and inventory flows (holds, double-booking prevention, quote-to-checkout price honoring), surge and dynamic pricing systems (demand/supply signals, damping, caps and fairness controls, pricing rules), ETA and routing services (road graphs, time-dependent weights, map-matching, traffic fusion, degraded modes), proximity-driven matching over moving supply (geo-partitioned matchers, hotspot rebalancing, boundary effects), and real-time location tracking. Covers the canonical ride-hailing, food/parcel delivery, and booking marketplace case studies and the consistency, latency, and scale challenges they share. Generic building blocks used by these designs (caching, rate limiting, sagas, stream-processing internals, spatial index primitives, observability, deployment) are covered by their own topics.
You're designing a marketplace reservation system where users can browse listings, hold inventory, and book a time slot. In which parts of the flow would you prefer strong consistency, and where would eventual consistency be acceptable? Explain the trade-offs in terms of correctness, user experience, and scalability.
Sample Answer
Direct answer
Use strong consistency only where a wrong answer creates a conflicting commitment: placing a hold, confirming a booking, and taking payment. Everything the user merely looks at (search results, listing pages, availability calendars, reviews, host dashboards) can be eventually consistent, as long as the write path re-checks the truth before committing. The rule of thumb: reads that inform a decision may be stale; the write that makes the decision may not.
Terms used below
- Strong consistency: every read sees the latest committed write; in practice, the operation goes to the single authoritative copy of the data (the primary database for that listing) and is atomic.
- Eventual consistency: copies (caches, search indexes, read replicas) may lag the latest write briefly, but converge if writes stop.
- Hold: a short-lived reservation that blocks a slot while the guest completes payment.
- Read replica: a copy of the database that serves reads and receives changes from the primary with some delay.
- Read-your-writes: a guarantee that a user immediately sees the effect of their own write, even while other users might still see a slightly stale copy for a little longer.
Stage by stage
| Stage | Consistency | Why | What staleness costs |
|---|---|---|---|
| Search and browse | eventual (search index a few seconds behind) | the highest-volume path, and results are a starting point, not a promise | a listing shown as available that was booked seconds ago |
| Listing detail page | eventual (cache with a short TTL, time-to-live) | metadata such as photos and descriptions rarely changes | an old description for a minute |
| Availability calendar display | eventual, but kept fresh (event-driven cache invalidation: the cache entry is deleted or refreshed the moment a booking event fires, instead of waiting for the TTL to expire; plus a short TTL as a backstop) | shown to many viewers, changes with every booking | the guest clicks a date that just went; the hold step catches it |
| Placing a hold | strong: atomic insert on the primary that the database rejects if it overlaps | this is where two guests collide | a double booking |
| Confirming the booking | strong: conditional state change, only if the hold is still alive | turns a hold into a commitment | confirming an expired hold that someone else now holds |
| Payment | strong and idempotent (a retry reuses the same key) | money moves | double charges |
| Notifications, host dashboards, analytics | eventual (asynchronous events) | nobody makes a conflicting decision from them | a host sees a booking a few seconds late |
| Guest's own "my trips" page right after booking | read-your-writes (read from the primary, or from the booking response) | a guest who just paid and sees nothing panics | support tickets and duplicate attempts |
Worked example
Suppose (illustrative figures) 20,000 search queries/s and 20 bookings/s: roughly 1,000 browse reads per booking. Routing all 20,000 searches to the primary database would put the whole read load on the one component that must stay fast for the 20 bookings/s. Letting search run on an index that lags by 2 seconds costs very little: the only visible symptom is that a guest occasionally clicks a slot that was taken in those 2 seconds, and the hold request answers "just booked, here are nearby dates". That is a mild annoyance. A double booking, in contrast, means a guest arrives with nowhere to stay.
Trade-offs and pitfalls
- Correctness lives on the write path. Eventual consistency on reads is safe only because the hold re-checks against the authoritative data. If any write trusts a cached "available", the whole argument collapses.
- User experience: stale reads produce "sorry, just taken" moments. Keep the calendar staleness short enough that these are rare, and make the failure pleasant (suggest alternatives, keep the guest's inputs).
- Scalability: the strong path is small (one row per hold) and partitions by listing, because a conflict only ever involves one listing, so it scales by sharding on listing id (splitting the reservation data across many database servers, each owning a range of listings) without needing cross-shard transactions (a single operation that would have to commit atomically across more than one of those servers, which is slow and hard to get right).
- Pitfall: making everything strong "to be safe". Search through the primary database caps the platform's traffic at what one database can serve, and gains nothing, because the hold must re-check anyway.
- Pitfall: forgetting read-your-writes. The guest's own confirmation page is the one read that must reflect their write immediately.
Design the concurrency model for a marketplace booking flow where inventory must never be double-booked. Compare leader-based locking, optimistic concurrency, distributed consensus, and a partitioned-ownership model: for each, explain the latency implications and the failure modes, and say when you would prefer it.
Sample Answer
Direct answer
For most marketplace bookings (a hotel room-night, a restaurant table, a seat on a class) the right model is optimistic concurrency implemented as an atomic conditional write in one strongly consistent database: "reserve one unit only if one is still free", checked and applied in a single statement, with a database constraint as a backstop. It adds no extra round trips when there is no contention and cannot double-book. Switch to partitioned ownership for extremely hot inventory (a concert on-sale) where thousands of requests hit one item, and use leader-based locking only when the critical section spans systems you cannot put in one transaction, always with fencing tokens. Distributed consensus belongs inside your database or coordination service, not in your booking code.
The four models
1. Leader-based locking
A lock service (or a single leader process) grants an exclusive lease, a lock that expires after a set time, on an inventory key; the holder reads availability, books, and releases.
- Latency: one round trip to acquire, the booking work, one to release. Under contention, requests queue behind the holder, so waiting time grows with the hold time.
- Failure modes: the holder pauses (a long garbage-collection pause, where the language runtime stops the program to reclaim unused memory and can unpredictably take seconds, or a network stall), its lease expires, a second client acquires the lock, and both write: split brain (two parties each believing they are in charge). The fix is a fencing token, a number that increases with each lease grant and that the storage layer checks, rejecting writes carrying an older token. Without it, a lock alone does not prevent double-booking. The lock service is also a dependency whose outage stops all bookings.
- Prefer when: the booking touches an external system (reserving with a third-party supplier plus your own record) that cannot share a transaction with your database.
2. Optimistic concurrency
No lock is held while the user thinks; the write succeeds only if the state is still what it expected. Two forms:
- Version check: read
version = 7, thenUPDATE ... SET ..., version = 8 WHERE id = ? AND version = 7. Zero rows updated means someone else won; re-read and retry. - Conditional atomic update:
UPDATE slot SET booked = booked + 1 WHERE id = ? AND booked < capacity. The condition is re-evaluated by the database at write time, so concurrent bookers of different units do not conflict at all. - Latency: a single statement at low contention. On a hot row the database serializes the writers on the row lock for a very short time; with the version-check form, losers must retry, and retries grow quickly with contention.
- Failure modes: retry storms on hot items (version form), and application bugs that read then write in two separate steps without a condition, which is exactly the double-booking bug.
- Prefer when: almost always, for ordinary inventory.
3. Distributed consensus
Every booking write is committed through a consensus protocol such as Raft or Paxos (two well-known leader-based consensus protocols): a leader proposes the write and it counts as committed once a quorum (a majority of replicas) has stored it, so any two majorities overlap and no two conflicting bookings can both commit.
- Latency: at least one round trip from leader to a majority per write; with replicas in different regions, one inter-region round trip per booking, plus stalls during leader election.
- Failure modes: losing a majority makes writes unavailable (this is the price of never serving a wrong answer); leader elections cause brief write pauses.
- Prefer when: you need bookings to survive a region failure with no lost confirmed booking. In practice you buy this by choosing a database that replicates with consensus internally (for example a Raft-based distributed SQL database) and then still use the conditional write from model 2 on top. Writing your own consensus for a booking flow is a mistake.
4. Partitioned ownership
Each inventory key is owned by exactly one worker (an actor: a single-threaded worker that owns one slice of state and processes its messages one at a time, also called a shard owner) at a time, and all requests for that key are routed to it and processed one at a time in memory, then persisted.
- Latency: the lowest per decision (in-memory, no lock round trips), and it absorbs a hot key gracefully by queueing rather than retrying.
- Failure modes: hot keys cap at one worker's throughput; during failover a new owner must wait for the old owner's lease to expire (a pause for that key); and without fencing on the persisted write, an old owner that did not know it lost ownership can still write.
- Prefer when: a few items receive enormous concurrent demand (ticket on-sales, limited drops), usually with a waiting-room queue in front (an entry queue that holds excess requests outside the booking system and admits them gradually, so a flash-sale spike never hits the database directly).
Comparison
| Model | Uncontended latency | Behavior under a hot key | Main failure mode | Use it for |
|---|---|---|---|---|
| Leader-based locking | 2 extra round trips | Queues behind holder | Expired lease, two holders (needs fencing) | Cross-system critical sections |
| Optimistic (conditional write) | 1 statement | Short row-lock serialization; version form retries | Retry storms; unconditioned read-then-write bugs | Default for booking |
| Distributed consensus | +1 quorum round trip per write | Serialized through the leader | Unavailable without a majority | Inside the storage layer for cross-region durability |
| Partitioned ownership | In-memory | Queues at the owner | Failover pause; stale owner without fencing | Flash-sale inventory |
Worked example: a conditional write under a real race
The script below starts PostgreSQL in Docker, creates a slot with 50 seats, and fires 80 concurrent clients, each running one conditional update plus an insert in a single statement, using a WITH ... RETURNING construct (a CTE, or common table expression: a named, temporary result computed once and reused later in the same statement). The CTE s holds one row if and only if the UPDATE actually changed a row; the INSERT ... SELECT ... FROM s that follows it then runs once for each row s produced, so a client whose UPDATE matched zero rows (the seat was already full) also inserts zero booking rows, silently, no error and no booking, instead of needing a separate check. A CHECK constraint backs the rule at the storage layer.
#!/usr/bin/env bash
set -euo pipefail
C=booking-demo
docker run -d --name $C -e POSTGRES_PASSWORD=pw postgres:16-alpine >/dev/null
# The official image runs a transient server for initdb, shuts it down, then starts the
# real server; a plain "psql -c select 1" probe can succeed against that transient
# instance and then fail moments later while the real server is still coming up. Wait
# for the "ready to accept connections" line to appear twice (temp instance, then real).
until [ "$(docker logs $C 2>&1 | grep -c 'database system is ready to accept connections')" -ge 2 ]; do
sleep 0.5
done
docker exec -i $C psql -U postgres -q <<'SQL'
CREATE TABLE slot (
id int PRIMARY KEY,
capacity int NOT NULL,
booked int NOT NULL DEFAULT 0,
CHECK (booked <= capacity)
);
CREATE TABLE booking (id serial PRIMARY KEY, slot_id int NOT NULL, user_id int NOT NULL);
INSERT INTO slot VALUES (1, 50, 0);
SQL
# 80 clients race for 50 seats at the same moment.
rm -rf /tmp/booking-demo && mkdir -p /tmp/booking-demo
for u in $(seq 1 80); do
docker exec $C psql -U postgres -qtA -c "
WITH s AS (
UPDATE slot SET booked = booked + 1
WHERE id = 1 AND booked < capacity
RETURNING id)
INSERT INTO booking (slot_id, user_id) SELECT id, $u FROM s RETURNING id" \
> /tmp/booking-demo/client-$u.out &
done
wait
echo "clients that got a seat: $(cat /tmp/booking-demo/client-*.out | grep -c .)"
docker exec $C psql -U postgres -qtA -F ' ' -c "
SELECT 'slot.booked =', booked FROM slot WHERE id = 1
UNION ALL SELECT 'booking rows =', count(*) FROM booking"
docker rm -f $C >/dev/null
Output, with the readiness wait above (5 real runs, this exact script, unchanged otherwise):
clients that got a seat: 50
slot.booked = 50
booking rows = 50
Every run lands on the same three numbers, because the conditional UPDATE is correct regardless of exactly how many of the 80 clients happen to race each other; capacity, not timing, decides the count. (A naive readiness probe such as psql -c "select 1" looks correct in testing but is not: the official Postgres image starts a temporary server for initdb, shuts it down, and starts the real one, and a probe that only checks "did psql connect" can pass against the temporary instance, so the script then races the real server's startup. Across 5 runs with that naive probe this raced visibly: one run aborted before creating any tables, one run logged roughly two dozen client connection failures before still landing on the correct 50/50/50, and three ran clean. The fix above waits for the ready message to appear twice, which removed the race across 5 further runs.) Exactly 50 of 80 clients got a seat, and the other 30 received zero rows (a clean "sold out", not an error). This works because under PostgreSQL's default isolation level (READ COMMITTED: the rule that controls what concurrent, not-yet-committed changes a transaction is allowed to see), a second writer on the same row waits for the first to commit and then re-checks booked < capacity against the new value before updating. This re-check is specific to READ COMMITTED: under the stricter REPEATABLE READ or SERIALIZABLE levels, the second writer would instead abort with a serialization error once it tried to commit, and the application itself would have to retry the whole transaction; the single-statement conditional UPDATE stays correct under any of the three, but only READ COMMITTED turns the race into a silent, zero-row "sold out" instead of a visible error to handle. The naive version, SELECT booked in one statement and UPDATE slot SET booked = <value read + 1> in another, has no such re-check under any isolation level and is how double-bookings happen.
For a multi-unit booking (three consecutive hotel nights), the same idea applies per night inside one transaction: update all three rows conditionally and roll back if any returns zero rows. Update the rows in a fixed order (by date) so two concurrent transactions cannot deadlock (each one waiting forever for a lock the other transaction holds, with neither able to proceed) by locking nights in opposite orders.
Holds and the user's think time
Users pick a room and then spend minutes on payment. Do not hold a lock for that. Insert a hold row with the same conditional write and an expires_at (for example 10 minutes); confirmation converts the hold into a booking; a sweeper, or a condition that ignores expired holds, returns unconfirmed units. This keeps every model above working on short operations only.
Trade-offs and pitfalls
- A distributed lock alone is not safe. Without a fencing token checked by storage, a paused holder can write after its lease expired.
- Version-check retries on hot items multiply load exactly when load peaks; prefer the conditional update, or move the hot item to partitioned ownership with a queue.
- Consensus is not free at the booking layer: every cross-region confirmation pays an inter-region round trip; keep an item's writes in one home region unless region-failure durability is a hard requirement.
- Caches and search indexes are not the source of truth. Showing "available" from a cache is fine; the conditional write decides.
Drivers are moving objects sending location updates. How would you decide the location update frequency, and what client- and server-side strategies can you use to reduce update volume while preserving matching quality? Explain trade-offs between freshness, battery/network use, and compute load.
Sample Answer
Direct answer
Derive the update rate from how much position error matching can tolerate, not from habit. A car at 10 m/s (36 km/h) moves 40 m between 4 s updates; if dispatch compares drivers who are typically 1 to 3 km from the rider, 40 m of staleness barely changes the ranking, while 300 m (30 s updates) sometimes picks the wrong driver. So the rate should follow the driver's state and speed: frequent when a rider is watching or a pickup is close, sparse when idle or parked. On the phone, send on movement rather than on a timer and batch fixes into fewer uploads; on the server, estimate between updates (dead reckoning) and only touch the geo index when a driver changes cell: the service area is divided into a grid of cells, and the geo index is the lookup that maps each cell to the drivers currently inside it, so a ping that stays inside the same cell needs no index update.
Deciding the frequency
Work backwards from the error budget:
| Update interval | Distance moved at 10 m/s | Pings per hour | Suits |
|---|---|---|---|
| 1 s | 10 m | 3,600 | Final 200 m of a pickup, live navigation |
| 4 s | 40 m | 900 | En route to pickup, rider watching the car |
| 10 s | 100 m | 360 | On trip (rider is inside the car) |
| 30 s | 300 m | 120 | Idle and waiting for a request |
Two tests confirm the choice: replay historical dispatch decisions with positions artificially aged by the candidate interval and measure how often the chosen driver changes (matching quality); and measure the ETA (estimated time of arrival) error at pickup (did the driver arrive when promised). Pick the sparsest interval where both stay within tolerance for each state.
Client-side strategies
- State-driven rate: idle, en route to pickup, on trip and offline each get their own interval (as in the table). The server tells the app which state it is in, so the rate changes instantly when a dispatch offer is accepted.
- Movement triggers instead of a pure timer: send when the driver has moved more than a distance threshold (say 50 m), when the heading has changed by more than 30 degrees (a turn), or when a maximum silence (say 15 s) has passed, whichever comes first. A parked driver then sends almost nothing, and a driver taking a turn is reported promptly.
- Batching: record fixes frequently but upload them together, for example a fix every 2 s uploaded in one message every 10 s. The expensive part on a phone is waking the cellular radio, not taking a GPS fix, so batching cuts battery and data far more than it cuts accuracy, and it gives the server a dense trail for map-matching (snapping points to roads).
- Use the OS location modes: lower-accuracy or "significant change" modes when idle; high accuracy only when en route or near pickup.
- Compact payloads: small binary encoding and deltas from the previous fix keep each upload tiny.
Server-side strategies
- Dead reckoning: estimate the current position from the last fix plus speed and heading (optionally snapped to the road), so the server can answer "where is the driver now" between sparse updates. Keep an uncertainty radius that grows with time since the last fix, and prefer drivers with fresh fixes when the ranking is close.
- Index only on cell change: overwrite the latest position in memory on every ping, but update the spatial index (cell to drivers) only when the driver crosses into a new cell.
- Drop and coalesce: discard duplicate or out-of-order pings by sequence number, and if processing falls behind, keep only each driver's newest ping.
- Request a fresh fix on demand: when a driver becomes a top candidate for a dispatch and their fix is stale, push a "send location now" message instead of raising everyone's rate.
Worked example
A city with 100,000 online drivers:
- Fixed 4 s for everyone: 100,000 / 4 = 25,000 updates per second.
- State-driven, using the table's own intervals: 45% idle at 30 s, 20% en route at 4 s, 35% on trip at 10 s:
45,000 / 30 + 20,000 / 4 + 35,000 / 10 = 1,500 + 5,000 + 3,500 = 10,000 updates per second.
That is a 60% cut in server load, and it lands on the drivers who matter least for matching quality (idle ones already far from any active decision; on-trip ones whose position the rider sees from inside the car). Drivers en route keep the 4 s rate because both the rider's map and the pickup ETA depend on them.
Trade-offs
| Force | More frequent | Less frequent |
|---|---|---|
| Freshness and matching quality | Better, especially near pickup | Wrong-driver choices, ETA surprises |
| Phone battery and data | Worse: radio wake-ups dominate | Better |
| Server compute and storage | Linear increase in ingest, index work, storage | Lower |
| Map smoothness for the rider | Smoother | Needs client-side interpolation |
Recommendation: state-driven intervals, movement-triggered sends with a maximum silence, batching, and on-demand refresh for dispatch candidates. It captures most of the savings while protecting the two moments users notice: choosing the driver and watching them arrive.
Pitfalls
- Setting one global rate: it is either too expensive for idle drivers or too stale for pickups.
- Treating a missing update as "not moving" rather than "unknown": the uncertainty must grow with silence.
- Throttling so hard that the OS or the app loses the connection in the background, which looks like the driver went offline.
You need to combine multiple traffic data providers with differing latencies, costs, and historical accuracies to produce a single ETA. Describe an algorithm to weight and fuse these sources in real time, including handling missing or conflicting data and scoring provider reliability over time.
Sample Answer
Direct answer
Fuse at the level of road segments (or corridors), not whole-trip ETAs (estimated times of arrival), and treat every provider as a noisy sensor with a learned bias (does it run systematically fast or slow?) and a learned error variance (how wrong is it typically?). Combine the bias-corrected estimates with inverse-variance weighting, where a source's weight is 1 divided by its expected squared error (statisticians call this the variance: the average of the squared gap between what a source reports and the truth, so a source that is occasionally very wrong scores worse than one that is mildly wrong just as often), and inflate a source's variance by how old its data is, so stale data automatically counts for less. Score reliability continuously against ground truth from our own fleet: the travel times our drivers actually take on those segments a few minutes later. Missing sources simply drop out of the weighted sum; conflicting sources are checked against the consensus of the others and excluded when they disagree far beyond their own stated error.
Why segments, and why inverse-variance
Providers differ in coverage (one is strong on highways, another on downtown streets), latency (seconds to minutes) and price. Fusing per segment lets each provider win where it is actually good. Inverse-variance weighting is the natural rule because, for independent unbiased estimates, it produces the combined estimate with the smallest possible variance, and it gives a fused uncertainty for free, which the ETA service can turn into a confidence range.
For segment travel time, provider i reports an estimate e_i. With learned bias b_i, learned error variance σ_i², and data age a_i seconds:
σi,eff2=σi2+(κai)2 wi=σi,eff21,t^=∑iwi∑iwi(ei−bi),σt^=∑iwi1κ (seconds of error added per second of data age) is itself learned: fit how fast a provider's error grows with the age of its reading. Our own fleet's live probe data (map-matched traversal times, meaning each driver's raw GPS trace has already been snapped onto the road segment it most likely followed, from drivers on the road right now) enters the same formula as one more source, usually the best one on busy segments and absent on quiet ones.
Worked example
One downtown segment, κ = 0.1 s per second of age:
| Provider | Reported (s) | Age | Learned σ | Learned bias | Corrected (s) | Effective σ (s) | Weight share |
|---|---|---|---|---|---|---|---|
| A | 120 | 30 s | 15 s | 0 | 120 | √(225 + 9) = 15.3 | 76.9% |
| B | 150 | 5 min | 25 s | +10 s (runs slow) | 140 | √(625 + 900) = 39.1 | 11.8% |
| C | 100 | 10 s | 40 s | 0 | 100 | √(1600 + 1) = 40.0 | 11.2% |
Fused estimate: 120.1 s, fused σ = 13.4 s. Note that the fused σ is smaller than the best single source (15.3 s): B and C add information even though each alone is poor. B's five-minute-old reading contributes little because age inflated its variance from 625 to 1,525.
Missing data. If provider A times out, fuse B and C alone: 120.5 s with σ = 27.9 s. The estimate barely moves but the uncertainty doubles, and that uncertainty should flow through: the rider sees a wider ETA range, and dispatch adds margin. When every provider is missing, fall back to the historical profile for that segment, time of day and day of week, with its historical variance.
Conflicting data. For each source, compute how far it sits from the fused value of the other sources, in units of its own effective σ: this ratio, the gap divided by σ, is the source's z-score. In this example, all three are within about half a σ, so nothing is excluded. If provider C had instead reported 280 s (implausibly slow, for example a feed bug replaying a stale road-closure or detour reading), compare it against the fused value of A and B alone: their weights are 1/15.3² ≈ 0.00427 and 1/39.1² ≈ 0.00065, giving a fused estimate of (0.00427×120 + 0.00065×140) / (0.00427+0.00065) ≈ 122.7 s. C's z-score, using its own effective σ of 40.0 s, is (280 − 122.7) / 40.0 ≈ 3.9: past the 3-sigma cutoff, so it is excluded for this cycle. A milder miss does not necessarily clear that bar: if C had reported 20 s instead, the same calculation gives (122.7 − 20) / 40.0 ≈ 2.6, below 3, so it would not be excluded by this rule alone. That is a real limitation worth stating plainly: a provider with a wide effective σ, from a history of being consistently unreliable, is hard to catch on any single bad reading, because the exclusion rule is scaled by exactly the noisiness that makes it unreliable in the first place. Repeated exclusions trip a per-provider circuit breaker (after enough consecutive exclusions, stop calling that feed and fall back to the others for a cooldown period) so a broken feed stops being called even when no single reading clears the 3-sigma bar.
Scoring reliability over time
Ground truth arrives later: once our drivers traverse the segment, the map-matched traversal time is the truth for that time window. For each provider, per road class and time-of-day bucket, update exponentially weighted moving averages (EWMA: a running average where each new observation gets a fixed share α and older ones decay):
- Bias: b ← (1 − α) b + α r, where r is the provider's error on that observation.
- Variance: σ² ← (1 − α) σ² + α r².
Example with α = 0.05: provider A starts with σ = 15 s and bias 0. A single 40 s miss raises its variance from 225 to 0.95 × 225 + 0.05 × 1,600 = 293.75, so σ rises from 15.0 s to 17.1 s. Separately, one observation where A said 130 s and truth was 100 s (a 30 s overestimate) moves its bias from 0 to 0.05 × 30 = 1.5 s. With α = 0.05 an observation's influence halves after about 13.5 further observations, so a provider that degrades is down-weighted within a busy hour, while one bad reading cannot wreck its score.
Guard the scoring loop:
- Only score where truth is solid: segments with enough of our own traversals in the window; otherwise skip rather than learn from noise.
- Exclude our own probe data from the truth it is scored against, or the fleet source always looks perfect.
- Keep per-bucket scores: a provider good on highways at night can be poor downtown at rush hour.
Cost and latency
Providers differ in price per request, so do not poll every provider for every segment. Poll the cheap or streaming sources broadly; call an expensive source only for segments on active routes (segments that some in-flight trip or pending dispatch will traverse in the next 15 minutes) where the fused σ is above the target. That is an example of a value-of-information rule: pay only when the extra source would materially shrink the uncertainty that users actually see, since the added accuracy is worth its cost only in that case.
For latency, fusion must never wait for the slowest provider: use each provider's last good reading, with its age reflected through κ, and update asynchronously as new data lands. The ETA request path reads the already-fused segment table.
Trade-offs and pitfalls
- Averaging without bias correction: a provider that is consistently 10% slow drags every estimate; bias correction fixes that, weighting alone does not.
- Assuming independence: two providers that license the same upstream feed have correlated errors; inverse-variance weighting then double-counts them. Detect it from the correlation of their residuals and merge them into one source.
- Ground truth selection bias: our drivers avoid segments we believe are jammed, so jammed segments get fewer truth samples. Weight the scoring or supplement with provider-independent sources.
- Recommendation: inverse-variance fusion with EWMA-learned bias and variance is simple, explainable and debuggable. Move to a learned model (gradient-boosted trees: an ensemble of many small decision trees, each trained to correct the errors the previous trees left behind, over provider readings, ages and context) only when you have enough truth data to beat it on held-out traffic (real segments and time windows kept aside from training, used only to check whether the model generalizes rather than having memorised the training data), and keep the simple fusion as the fallback.
Your marketplace API has slow listing-page loads because the same listing metadata, host profile, and availability summary are requested repeatedly. How would you introduce caching without serving dangerously stale availability or breaking correctness during booking? Discuss cache keys, TTLs, invalidation, and what should never be cached blindly.
Sample Answer
Direct answer
Cache the three things differently, according to how often they change and what a stale copy can break. Listing metadata and host profiles change rarely: cache them with long TTLs (time-to-live, the lifetime of a cache entry) and invalidate on change events. The availability summary changes with every booking: cache it with a short TTL plus event-driven invalidation, label it as advisory, and never let any booking decision read it. The hold and booking path always reads the authoritative database, so a stale cache can cost a guest a "just booked" message, but never a double booking.
Terms used below
- Cache key: the identifier a cached value is stored under; it must include everything that changes the value (locale, currency, version).
- Invalidation: deleting or overwriting a cache entry when the underlying data changes, instead of waiting for it to expire.
- CDC (change-data-capture): streaming every committed database change as an event, so caches can react to writes they did not make.
- Cache stampede: many requests missing the same expired key at once and all hitting the database together.
- Hit rate: the share of reads served from cache.
What to cache, and how
| Data | Cache key | TTL | Invalidation | Staleness risk |
|---|---|---|---|---|
| Listing metadata (title, photos, amenities) | listing:{id}:v{version}:{locale} | 24 h | version in the key: an edit bumps the version, so new reads miss the old entry automatically | none meaningful |
| Host profile (name, photo, response rate) | host:{id} | 10 min | delete on profile-change event | a new photo shows a few minutes late |
| Availability summary (open dates this month, minimum price) | avail:{listing_id}:{yyyy-mm} | 60 s | delete on every hold, booking, cancellation or host calendar change event (via CDC) | a date shows open after it was taken |
| Search result pages | not cached per query at first; the search index is itself an eventually consistent copy | n/a | index updated from the same events | as above |
The version inside listing:{id}:v{version}:{locale} comes from a small version column on the listing's own database row, incremented on every edit. A reader still does one cheap, indexed read of that single column to build the key (it does not skip the database entirely), just not the expensive read of everything else on the page; that is where the saving comes from.
The page is assembled from these pieces, so an edit to the host's profile does not invalidate every listing they own.
What must never be cached blindly
- The hold and booking decision. The availability check that gates a booking runs against the database in the same atomic operation that writes the hold.
- The final price at checkout. Show the cached price while browsing, then re-quote at checkout from live rules and tell the guest if it changed.
- Payment and booking status. A guest who just paid must see their own booking (read from the primary database or the booking response).
- Anything per-user in a shared key. A key without the user id that stores "your saved lists" or a discount leaks one user's data to another.
- Authorisation decisions (is this user allowed to see this private listing?).
Correctness during booking
The flow deliberately separates advisory reads from authoritative writes:
- The calendar shows 12 October open, from a cache entry up to 60 s old.
- The guest clicks Reserve; the hold request goes to the database, which atomically checks and inserts.
- If 12 October was booked 20 s ago, the hold fails with "just booked", the service deletes
avail:{listing}:2026-10so the next viewer sees the truth, and the guest gets alternatives.
Nothing about the cache can produce a double booking, because the cache is never consulted when a commitment is made.
Worked example: load and staleness
Assume (illustrative) 5,000 listing page views/s, each needing metadata, host profile and availability summary: 5,000 × 3 = 15,000 lookups/s. With hit rates of 99%, 98% and 90% respectively:
5000×(0.01+0.02+0.10)=650 database reads per seconddown from 15,000, and availability, the most volatile item, accounts for 500 of those 650.
Staleness: with event invalidation working, a booking reaches the cache in the time the change stream takes (typically a second or two). If the invalidation event is lost, the 60 s TTL is the backstop. On a popular listing viewed 50 times a minute, that is the difference between a handful of viewers and up to 50 viewers seeing a date that is gone, which is why the short TTL stays even with invalidation in place.
Protecting the database on misses
- Request coalescing: one request per key refills the cache while others wait briefly for that result, preventing a stampede when a popular listing's entry expires.
- TTL jitter: add a random 0 to 10% to TTLs so entries written together do not expire together.
- Stale-while-revalidate for metadata only: serve the old description while one background request refreshes it. Never do this for availability used in a decision.
Trade-offs and pitfalls
- Short availability TTL versus load: 60 s with invalidation is the commitment here. Shorten it for listings with high booking velocity; lengthen it for rarely booked ones.
- Pitfall: deleting cache entries before the database write commits. The race, step by step: (1) the update handler deletes the cache entry first, (2) before its transaction commits, a concurrent reader misses the cache, reads the still-old row from the database, and writes that old value back into the cache, (3) the transaction finally commits the new value, but the cache still holds the stale value written in step 2 and nothing deletes it again. Invalidate after commit, from the change stream, so step 1 cannot happen before the new value even exists to be read.
- Pitfall: one giant cached "listing page" object. Every tiny change invalidates everything and the hit rate collapses.
- Pitfall: using the availability cache to "pre-check" the hold. It works until it is stale; then it either blocks a free date or waves through a taken one to a path that assumes it was checked.
Unlock Full Question Bank
Get access to all 19 Marketplace, Dispatch, and Logistics System Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.