SysDesignPrep.com
Interviewer kit

Design a Price Tracker

Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.

The candidate should have a blank page and a whiteboard, not this.

Open with this

Track 500 M products' price history and alert 50 M users when a price drops below their target, in time to buy. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.

The clock

  • 4 min: functional requirements and scope
  • 4 min: non-functional requirements, with numbers
  • 5 min: back-of-envelope estimates
  • 16 min: high-level design and one or two flows
  • 16 min: deep dives and the close

Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.

Requirements · 8 min

Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.

Functional (7)
  • Watch a product with a target price: From a pasted link or the extension's button. The link is canonicalised to a listing (marketplace plus product id) and the target is in that listing's currency.
  • Price history chart: Every change since we started tracking, per series (new, used), with gaps where the price was unknown.
  • Drop alerts by push or email: When the price reaches the target, fast enough to buy before a deal ends. Once per crossing, not on every check while the price stays low.
  • Browser extension: Shows the chart on the product page and a one-click watch. Opt-in: reports the price the page shows, which we treat as a hint, never as the truth.
  • Manage the watch list: List, edit the target, pause, delete. An edit takes effect at the next price change, not at the next batch run.
  • Multiple marketplaces: The same product on amazon.com and amazon.de is two listings, with separate prices, currencies and histories. Conversions are for display only.
  • Out of scope: Crawling mechanics (the existing crawler), notification delivery (the existing notification system), price prediction, deal forums and affiliate accounting.
Non-functional (8)
  • Fixed check budget (~15 k checks/s): Set by the retailers' API quotas and by crawl politeness, not by us. This is the requirement that drives the hardest tradeoff: every check spent on one listing is freshness taken from another.
  • Alert latency once a change is seen (p95 < 60 s): From the observation to the hand-off to the notification system. The bigger term is detection lag, set by the check interval: 2 to 5 min for the hot tier, about an hour warm, a day for the long tail.
  • Alert correctness (one per crossing · no glitch alerts): Exactly one alert per watch per crossing, through retries and crashes, and never on an implausible price until a second source has confirmed it. A false $1 alert to a million people costs more trust than a missed deal.
  • Scale (500 M listings · 1 B watches): 50 M users with ~20 watches each. Heavily skewed: the median watched listing has 2 or 3 watches, the hottest ~2 M.
  • Chart latency (p99 < 200 ms): The extension draws on the retailer's page while it loads; a slow chart feels broken. One partition read behind a CDN.
  • Honest history (append-only, gaps explicit): The chart is what users trust. Never lose a change point, record the source of each, and show unknown periods (out of stock, not checked) as gaps rather than a straight line.
  • Extension privacy (product pages only): Reports carry an install id, not a user id, and come only from supported product pages. No browsing history leaves the browser.
  • Availability (99.9 %): When a source is down, checks queue and alerts are late. The pipeline never guesses a price to stay on time.

Estimates · 5 min

Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”

The numbers (8)
  • Checks per second (the budget): ~14.4 k/s. Tiered by value: 1 M hot listings every 5 min = 3.3 k/s, 20 M every hour = 5.6 k/s, the other 479 M once a day = 5.5 k/s. Total ≈ 14.4 k/s, inside a ~15 k/s budget set by API quota and crawl politeness, with the rest kept for verification checks.
  • Uniform interval at that budget: ~10 h. 500 M ÷ 14.4 k/s ≈ 34.7 k s ≈ 9.6 h between checks of each listing if the budget were spread evenly. A 6-hour lightning deal could start and end between two checks. Scheduling by value is not an optimisation here, it is the product.
  • Product API calls: ~1 k calls/s. About 70 % of checks go to marketplaces with a product API: 10 k items/s. At 10 items per call that is 1 k calls/s; the other ~4.4 k/s are page fetches through the crawler. Batching is what makes the quota go ten times further.
  • Price changes: ~600/s · ~5 k/s peak. 14.4 k × 86 400 ≈ 1.24 B checks a day; about 4 % find a change, so 50 M changes/day ≈ 580/s. On Prime Day a few hundred thousand a minute: 300 k ÷ 60 = 5 k/s, almost 9× normal and a third of all checks. Each change is one range read in the matcher, so the matcher is sized for the peak.
  • Price history growth: ~240 GB/yr. Changes only: 50 M/day × 365 × 13 B (timestamp, price in minor units, flags) ≈ 240 GB a year, ~36 points per listing per year, so a 5-year chart is ~2.4 KB. Storing every observation would be 1.24 B × 13 B × 365 ≈ 5.9 TB a year, 25× more, and almost all of it repeats.
  • Watch index: ~60 GB. 1 B watches × ~60 B (listing, target, watch id, user id, state, epoch) ≈ 60 GB before replication, 180 GB with three copies. Small in total; the problem is skew, not size: the median watched listing has 2 or 3 watches, the hottest ~2 M.
  • Hot product fan-out: 600 k alerts in ~30 s. The hottest listing has ~2 M watches; a drop from $249.99 to $169.99 crosses ~30 % of the targets, 600 k. The range read is ~36 MB across 32 sub-partitions, a second or two. At a 20 k/s producer quota on the notification system the sends take 30 s: pacing, not reading, sets the time.
  • Chart reads: ~1.2 k/s. Say 5 M active extension users each open ~20 product pages a day: 100 M reads/day ≈ 1.2 k/s, peaking 5–10× on sale days. Each is one ~2.4 KB partition read. The same page views can send up to 1.2 k price reports a second, free signal worth ~8 % of the check budget.

High-level design · 16 min

Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.

Components (15)
  • Retailers (product APIs · product pages): Amazon-style marketplaces and other large stores. Their product APIs are structured but quota-limited and can lag the site by a minute or two; their pages show what a shopper sees but change layout without notice. We are a guest on both, so they set the budget, not us.
  • Price fetchers (API batches · crawler jobs): Stateless workers that turn due checks into observations. Where a marketplace has a product API they batch 10 listings per call within the quota; otherwise they hand a page fetch to the existing crawler, which owns politeness, proxies and rendering, and run a versioned per-site extractor on the HTML. Every observation carries its source and extractor version.
  • Check scheduler (next-check time · budget): Holds a next_check_at for each of 500 M listings in sorted sets sharded by listing id, and drains due listings into per-source token buckets: the product API per marketplace, the crawler per site. After each check it recomputes the interval from the listing's value (armed watches, gap to the nearest target, change rate, sale boost) and one global factor that keeps total demand at the ~15 k checks/s budget. Verification requests jump the queue.
  • Notification system (existing · push + email): The existing notification platform: channel routing, preferences, provider retries and per-hop idempotency are its job, not ours. We send one request per alert with the idempotency key hash(watch_id, arm_epoch), high priority, and a TTL so a delayed push for an ended deal is dropped. Our producer quota on its high-priority lane, ~20 k/s, is what paces a hot listing’s fan-out.
  • Browser extension (chart on the product page): Recognises a supported retailer's product page, maps it to a listing id and draws our price history on the page, with a one-click watch button. With the user's opt-in it reports the price the page shows, signed with a per-install key and with the page context (signed in, member price, coupon shown). It sends nothing from pages that are not product pages on supported sites.
  • Latest price (KV · price, seq, last checked): One small record per listing (~40 bytes, ~20 GB in all): current price, stock and offer flags, a sequence number, last_checked and any pending suspect price. The detector updates it with compare-and-set on the sequence, so two racing observations of one listing never both publish a change. Replicated and persisted: losing it would make every next observation look like a change.
  • Change detector (dedupe · plausibility): Every observation, from a check or an extension report, comes here and is compared with the latest price. Identical observations (~96 %) only update last_checked; a real change is appended to history and published; a suspicious drop is held as pending and sent back for confirmation from another source. A crowd report never becomes a change on its own, only a verification request.
  • Listing events (Kafka · key = listing id): One topic carrying both price.changed from the detector and watch.created, updated and deleted from CDC, partitioned by listing id. The shared key is the point: for any listing, price changes and watch changes reach the matcher in one total order, so a new watch cannot slip past a drop. About 600 events/s normally, ~5 k/s at the Prime Day peak.
  • Alert matcher (range match · watch state): Consumes listing events and is the only writer of the watch index. On a drop from p_old to p_new it reads the watches with target in [p_new, p_old) and fires the ARMED ones; on a rise it re-arms MET watches that the price has cleared by the 2 % band. Large crossings go to a wave sender that paces them under the notification quota, and after every event it reports the listing’s armed count and nearest target to the scheduler.
  • Web + app (watch list · alerts): Where users paste a product link, set a target and manage their watch list, and where an alert lands when it is tapped. Shows the current price, the target and the chart. Targets are entered in the listing's own currency; any converted figure is labelled as an estimate.
  • API gateway (auth · limits · CDN for charts): Authenticates users and extension installs, rate limits reports per install and per IP, and routes watch calls to the watch service, chart reads to the history store and reports to the change detector. Chart responses are public with a 60-second max-age and an ETag from the history sequence, so a CDN absorbs the herd on a hot listing.
  • Price history (change points · per listing): Only changes, not observations: partition (listing_id, series, year), one row per change point with time, price in minor units, flags and source. About 36 rows per listing per year, ~240 GB a year in total, and a 5-year chart is one ~2.4 KB range read. Unknown periods (out of stock, not checked) are explicit points, so a chart never draws a line through a gap.
  • Watch index (by listing, sorted by target): A second copy of every watch, partitioned by (listing_id, sub) and clustered by (target_minor, watch_id), with state (ARMED or MET) and arm_epoch. A price change becomes a range read whose size is the number of watches it crosses. Listings with more than 50 k watches are split into 32 sub-partitions by hash(watch_id) and read in parallel.
  • Watch service (canonicalise · validate): Turns a pasted URL into a canonical listing id (marketplace plus product id, tracking parameters dropped), validates the target against the listing's currency, and creates, edits and deletes watches. It writes only the user-keyed watch store and never touches the index the matcher reads, which is what keeps watch changes and price changes in one order.
  • Watch store (by user · source of truth): Every watch keyed by (user_id, watch_id): listing, series, target in minor units, channels. About 1 B rows and ~150 GB, sharded by user so a watch list is one partition. Change data capture publishes every create, edit and delete onto the listing's event stream, keyed by listing id.
Flows to ask them to walk (5)
  1. A scheduled check finds a price drop: The core path from a due check to an alert. It shows why only changes are stored, why the alert is a range read over targets rather than a scan of watches, and how the result sets the next check.
    1. The scheduler takes the listings due this minute and spends the marketplace's API tokens on them.
    2. A fetcher batches ten due listings from one marketplace into one product API call.
    3. The fetcher turns each item into an observation and sends it to the change detector.
    4. The detector compares with the latest price, sees $249.99 become $169.99, and advances the record with compare-and-set.
    5. The detector appends one change point to the listing's history.
    6. The detector publishes price.changed on the listing's event stream.
    7. The matcher reads the watches whose target lies between the new and the old price.
    8. For each armed watch the matcher sends an alert with a deterministic key, then marks the watch MET.
    9. The matcher reports the listing's armed count and nearest target, and the scheduler sets the next check.
  2. The extension shows history on a product page: The main read path, and the one place users feed us prices. The chart is a single partition read behind a CDN; the report is a hint that can cost at most one verifying check.
    1. A shopper opens a product page on amazon.de and the extension recognises the listing.
    2. The extension requests the listing's history for the last year.
    3. On a cache miss the API reads the year's change points from one partition.
    4. The page shows €164.99 but our latest price is €179.99, so the extension prepares a report.
    5. The extension posts a signed report, which the API forwards to the change detector.
    6. The detector does not trust the report; it asks the scheduler for a verification check now.
    7. The verification check confirms €164.99 through the product API, and it becomes an ordinary change.
  3. Prime Day: one drop, 600 k alerts: The scale-breaking case: one listing with ~2 M watches drops below 600 k targets at once. The fan-out must finish in about a minute, stay under a shared notification quota, and stop if the price rebounds.
    1. The hot listing is checked every 2 minutes; this check returns $169.99, down from $249.99.
    2. The detector accepts the drop, appends it and publishes price.changed.
    3. The matcher reads the crossed range from all 32 sub-partitions in parallel.
    4. The matcher hands the crossing to a wave sender, which collapses it per user and shuffles it.
    5. The sender sends waves at the 20 k/s notification quota and asks the scheduler for a re-check every 30 seconds.
    6. A re-check finds $199.99; the rise arrives on the same stream and the remaining waves skip targets below it.
    7. Users who tap the alert and open the chart are served from the CDN.
  4. A $1 price that is not real: A failure path: an extractor misreads a page and reports a $299 TV at $1.00. The design must not alert on it, must not draw it on the chart, and must notice that the extractor is broken.
    1. A scheduled check for a store without an API goes through the crawler, and the extractor reads $1.00.
    2. The fetcher sends the observation with its source and extractor version.
    3. The detector flags the price as suspect and holds it as pending, without touching history or publishing.
    4. The detector asks the scheduler for a confirmation from a different source within a minute.
    5. The confirmation reads $299.00, so the pending price is discarded and counted against extractor v14.
    6. Contradictions reach 2 % of v14's observations within an hour, so the detector quarantines it and the scheduler routes the site to the fallback.
  5. Add a watch, fire, and re-arm: The asynchronous path that keeps the index right. A new watch travels through CDC on the same ordered stream as prices, so it cannot miss a drop that races it, and a crossing alerts once and re-arms only after the price clearly recovers.
    1. A user pastes an amazon.de link and sets a target of €150.
    2. The watch service writes the watch to the user-keyed store.
    3. CDC publishes watch.created onto the listing's event stream, keyed by listing id.
    4. The matcher inserts the watch into the index and evaluates it against the price it last saw for the listing.
    5. The matcher tells the scheduler that the listing now has a closer target.
    6. Weeks later the price falls to €149 and the watch fires; then it rises to €165 and the matcher re-arms it.

Deep dives · 16 min

Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.

Getting prices: API, scraping, extension

Ask: Why not just scrape every product page? And if a million extension users see the price anyway, why check at all?

Good answers name: Product API first, scraping for the rest, extension reports as hints that trigger a check, Scrape everything, Trust extension reports directly, Retailer bulk feeds or partnerships.

Our pick: Three sources with three trust levels. The product API is primary wherever a marketplace has one: fetchers batch 10 listings per call and spend a token bucket per marketplace sized to our quota. Retailers without an API, and fields the API does not expose, go through the existing crawler, which owns politeness, proxies and rendering; our part is a versioned extractor per site that turns HTML into price, currency, condition and stock, with the version recorded on every observation. Extension reports are hints: signed by a per-install key, rate limited per install and IP, weighted by each install’s history of being confirmed, and never written to history or used to alert. A report that disagrees with the latest price schedules a verification check, at most one per listing per 10 minutes. Reports carry the page context (signed in, member badge, coupon shown), so a personal price is recognised and ignored rather than chased.

  1. Someone runs a script that posts fake $5 reports for the top 1,000 products. What happens?
    Nothing a user sees. A disagreeing report only schedules a verification check, and the per-listing cap means the whole attack buys at most 6 checks an hour per product: 6 k an hour, invisible against the ~52 M an hour we already spend. Installs whose reports keep being contradicted lose weight and then are ignored, and a burst of new installs from one IP range is throttled at the gateway. The worst case is a small, bounded cost, which is the point of treating reports as hints.
  2. The API says $199 but the scraped page says $179. Which is right?
    Often both. The API may be a minute or two stale, or the page may show a coupon or member price that the API reports separately. Record series explicitly (the retailer’s own new offer, third-party new, used) and treat coupons and member prices as flags, not as the price. If two sources keep disagreeing on the same series, the extractor is suspect and that site’s scraped prices are compared against the API on a sample. History records the source on each change point, so a bad source can be backed out later.
  3. Your API quota is cut in half overnight. What degrades?
    The scheduler’s global factor adjusts on its own: intervals stretch until demand fits the new budget. The hot tier keeps its few-minute checks because it is a small share of the total; the long tail moves from daily to every two days. Where scraping is allowed, some marketplaces shift load to the crawler, and extension reports matter more for products people are browsing. The user-facing cost is slower alerts on the long tail, which is the right thing to give up.
  4. How do you handle the same product in different marketplaces?
    Each marketplace is its own listing: amazon.com:B0C1234567 and amazon.de:B0C1234567 have different prices, currencies, sellers and histories even though the product id matches. Prices are integer minor units in the listing’s currency, and a watch’s target is in that currency too. The UI can show a converted price for comparison, but conversion is display only: an alert never fires because the euro moved against the dollar.
  5. Is scraping even allowed?
    It depends on the site’s terms, the jurisdiction and how it is done, and it is a business and legal question before it is an engineering one. The design keeps it a per-site setting: a site can be API-only, crawl-only or excluded, and the crawler honours robots.txt and rate limits. I would not build the product on scraping a retailer that forbids it; affiliate product APIs exist largely so that services like this do not have to.
Spending a fixed check budget

Ask: You have 15 k checks a second and 500 M listings. Which listings do you check, and how often?

Good answers name: Value-weighted adaptive intervals with one global budget factor, Uniform round robin, Tiers by watcher count only, Event-driven only: check when a report or a deal feed says so.

Our pick: Each listing has a next_check_at in sorted sets sharded by listing id; workers pop due listings and spend tokens per source (the API per marketplace, the crawler per site). After every check the scheduler recomputes value = f(armed watches) × P(cross soon) × boost, where f grows with the log of the armed count, P comes from the gap to the nearest armed target relative to the listing’s learned change rate and typical step size, and boost covers a sale under way, a change in the last hour (repricing comes in bursts) or a disagreeing crowd report. The interval is clamp(k ÷ value, 2 min, 24 h), and k is one global factor that a controller adjusts so that the sum of 1 ÷ interval stays at the budget; when Prime Day raises everyone’s value, k rises and the long tail stretches first. Every listing is checked at least daily so charts stay honest. In steady state that is about 1 M listings every 5 minutes, 20 M hourly and 479 M daily: 3.3 k + 5.6 k + 5.5 k ≈ 14.4 k checks a second.

  1. Prime Day starts at midnight. What changes in the scheduler?
    Nothing has to be deployed. The sale flag boosts every listing in the retailer’s deal feeds and event categories, their value jumps, and k rises to keep the total at the budget, so the long tail moves from daily to every day or two for those 48 hours. I would also pre-warm: the night before, move the most-watched 1 M listings to the floor and confirm the API quotas with the retailer. Afterwards the boost decays and the intervals relax on their own.
  2. A new listing gets its first watch. When is it checked?
    Immediately: a listing with no known price has top priority, because the user is on the watch screen expecting a price and a chart. After that it starts from a prior for its category and retailer (electronics change more often than books) and learns its own rate within days. If its only watcher’s target is 40 % below the price, it settles at daily.
  3. Why the log of the watch count rather than the count?
    Linear weighting would give a 2 M-watch listing two million times the value of a 1-watch listing and try to check it every few milliseconds. The cost of missing a deal grows with watchers, but checking more often than every couple of minutes buys almost nothing when the API itself can be a minute or two behind the site. The log keeps big listings at the floor while still separating 10 watchers from 10,000. A big listing’s fan-out cost is the matcher’s problem, not a reason to check it more.
  4. How do you know the scheduler is good?
    Measure detection lag directly: for a random sample of a few thousand listings, check every minute out of band to learn when prices truly changed, and compare with when production noticed, weighted by the number of watches each change crossed. Track it per tier; the hot tier should be under 5 minutes at p95. Also track the share of checks that find a change (about 4 % overall, and much higher in the hot tier if it is well targeted) and the crossings missed entirely because a price went down and back up between two checks.
  5. The scheduler shard that holds a hot listing dies. What happens?
    Its sorted sets are replicated, so a replica takes over within seconds and its workers drain whatever became due meanwhile, oldest first. A check dispatched but never reported is dispatched again after a timeout; a duplicate costs one item of quota and produces an identical observation that the detector drops. The real danger is a burst of overdue checks on takeover, so it drains at the normal token rate and lets them catch up over a few minutes.
Price history: store only the changes

Ask: You check 1.24 B times a day. Why not store every observation in a time-series database?

Good answers name: Change points in a wide-column store, one partition per listing, series and year, Every observation in a time-series database, Relational table (listing_id, ts, price), One compressed blob per listing in object storage.

Our pick: History lives in a wide-column store such as Cassandra or Bigtable, partition key (listing_id, series, year), clustered by timestamp, each row a change point: timestamp, price in integer minor units, stock and offer flags, and source. The year in the key bounds partitions even for a listing repriced 50 times a day. The detector writes a row only when the price, stock state or offer type differs from the latest; identical observations update last_checked in the latest-price store and nothing else. Gaps are explicit: when a listing goes out of stock or misses its interval by a wide margin, an unknown point is written so the chart shows a gap instead of a straight line. Raw observations go to object storage as Parquet for 90 days, for debugging extractors, replaying history after a bug and training the change-rate model, never on the chart path. Chart responses are cached at the edge for 60 seconds, keyed by the listing’s history sequence.

  1. A listing is repriced by an algorithm 200 times a day, by a cent up and down. What does that do?
    It is 73 k change points a year, a ~1 MB partition: fine to store but noisy on the chart and a range query in the matcher every few minutes. Two fixes: coalesce steps smaller than 0.5 % within 10 minutes of the previous point before publishing, and serve long chart ranges from a daily min/max rollup. A step that crosses a target is never coalesced away, because crossing is evaluated on the confirmed price.
  2. An extractor bug wrote wrong prices for one site for six hours. How do you clean the history?
    Each change point records its source and extractor version, so the bad rows are identifiable: one site, extractor v14, between two timestamps. Mark them superseded, then replay that window’s raw observations from the Parquet log through the fixed extractor. Alerts that fired on those prices are found from their keys, hash(watch_id, epoch), so the affected users can be told. This is why raw observations are kept for 90 days.
  3. Why keep the latest price in a separate store instead of reading the last history row?
    Every observation needs the latest price, 14 k times a second, and 96 % of them only update last_checked. Writing last_checked into history would turn an append-mostly store into one rewritten on every check. A small key-value store, 500 M entries of ~40 bytes or ~20 GB, absorbs those writes, does the compare-and-set that serialises racing observations, and gives the API and scheduler the current price in one lookup.
  4. How do you show a price history in pounds for a product on amazon.com?
    Store and chart in the listing’s currency, always. If a user wants a converted view, convert each point at that day’s rate from a daily exchange-rate table and label the chart as converted. Never store converted prices: rates move, and the history must show what the retailer actually charged.
Finding the watches a change crosses

Ask: A price changes. How do you find the watches whose target it now meets, without scanning a billion watches?

Good answers name: Index by (listing, target), written only by a matcher that consumes one ordered stream per listing, Read all watches of the listing and compare each, Periodic batch join of prices and watches, In-memory sorted arrays inside the matcher.

Our pick: The watch store, keyed by user, is the source of truth for what each user asked for. CDC turns every create, edit and delete into an event on listing.events keyed by listing id, the same topic and key the detector publishes price changes to. The matcher consumes that topic, so for one listing it sees watch changes and price changes in a single order, and it is the only writer of the watch index: partition (listing_id, sub), clustering (target_minor, watch_id), each row holding user, state (ARMED or MET) and arm_epoch. A drop reads targets in [p_new, p_old) and fires the ARMED rows; a rise reads [p_old ÷ 1.02, p_new ÷ 1.02) and re-arms the MET ones. Listings with more than 50 k watches get 32 sub-partitions by hash(watch_id), read in parallel. A new watch is evaluated on insert against the price the matcher last saw for its listing, so it cannot miss a drop that raced it.

  1. Why does the order of watch creation and price change matter? Give the failure.
    Say the watch commits at 12:00:00.0 and the price drops below its target at 12:00:00.5. If a separate CDC consumer fills the index, the matcher can process the drop before the watch reaches the index, find nothing, and the user is never told, because the price is already low and the next change may be up. With both on one ordered stream per listing, either the watch is indexed first and the drop’s range read finds it, or the drop comes first and the matcher fires the new watch on insert.
  2. A user edits a target from $150 to $200 while the price is $180.
    It arrives as watch.updated on the listing’s stream. The matcher deletes the old index row, inserts the new one and evaluates it: $200 is above $180, so the target is already met. The UI said so when the user saved, so it goes in as MET with no push and re-arms when the price clears $204. If the product prefers to alert in this case, that is a one-line policy change in the same place.
  3. How big is the matcher fleet, and what is its bottleneck?
    At 5 k changes a second, almost all touching a handful of watches, the work is ~5 k small range reads a second plus the state writes: a few dozen instances, one per group of Kafka partitions. The bottleneck is the rare huge crossing, which is why big fan-outs go to a separate wave sender so that one hot listing does not hold up the other listings on its partition. Consumer lag per partition is the alert-latency metric to watch.
  4. Targets cluster at round numbers: 400 k watches at exactly $199.99. Does that break the range read?
    No: rows with the same target are ordered by watch id, and the range includes or excludes them together. It does mean one change can cross a huge number at once, which is the fan-out problem rather than an index problem. It is also why sub-partitions are by hash and not by price band: a band containing $199.99 would hold 400 k rows on its own.
  5. Why not a database secondary index on (listing_id, target) instead of a separate index?
    In a single relational database, that is exactly right for the first version: one table, a composite index, a range query. At 1 B rows sharded by user, a global secondary index is either a scatter to every shard or an asynchronously maintained copy, which is this design with less control over ordering and hot partitions. Owning the index lets us key it by listing and put it on the same stream as the prices.
One alert per crossing, never on a glitch

Ask: The price stays below my target for a week and you check it every 5 minutes. Why don’t I get 2,000 emails, and why didn’t the $1 glitch alert me?

Good answers name: Per-watch state with hysteresis, epoch-derived notification keys, confirmation before alerting on suspicious drops, Fire on the level, rate-limited to one alert per watch per day, Fire once ever, then the user re-arms manually, Alert on any observed price, retract if it was wrong.

Our pick: Each watch is ARMED or MET with an arm_epoch. A confirmed price at or below the target moves ARMED to MET and sends one notification whose idempotency key is hash(watch_id, arm_epoch). A price above target × 1.02 moves MET back to ARMED and increments the epoch, so the next crossing has a new key and the wobble in between does nothing. The matcher sends first and records MET after, in batches: if it crashes in between, the replayed event reads the same ARMED rows, regenerates the same keys, and the notification system drops the duplicates. A drop is suspect if it is more than 50 % below the 90-day low, more than 70 % in one step, or comes with a currency, unit or offer mismatch; a suspect price is held as pending in the latest-price store, neither written to history nor published, and the scheduler confirms it within a minute from a different source. Confirmed, it publishes as a normal change about a minute late; contradicted, it is dropped and counted against the extractor that produced it.

  1. The matcher sends 300 k of 600 k alerts and crashes. What happens on restart?
    The Kafka offset for that event was not committed, so the new consumer reads the same price.changed and repeats the range read. Some of the 300 k may already be marked MET and some not; either way their keys use the same epoch, so the notification system returns the existing ids and sends nothing, and the other 300 k go out normally. The cost is a burst of deduplicated calls, not a duplicate push. The notification system’s idempotency window, 24 hours, is far longer than any restart.
  2. The price drops below my target, rises 1 % and drops again. Do I get a second alert?
    No. A 1 % rise stays inside the 2 % band, so the watch stays MET and the second drop finds it MET. Had it risen 5 %, the watch would have re-armed with a new epoch and the next drop would be a new crossing with a new alert, which is what the user wants: a new chance to buy.
  3. A real pricing error: a $1,200 TV listed at $120 for eight minutes. Your confirmation delays it by a minute. Is that right?
    Yes, and it is a deliberate trade. The confirmation goes to the front of the queue with a 60-second deadline, so a real error reaches its watchers about a minute and a half after we first see it, well inside its eight minutes. Without confirmation, every extractor bug becomes a mass alert. Users chasing pricing errors would rather be a minute late than learn that our alerts are unreliable.
  4. Why not let the notification system dedupe on (user, listing, day) instead of an epoch?
    Because it guesses at the meaning. Two genuine crossings in one day, a lightning deal in the morning and a deeper one at night, would be merged, and a replay a minute after midnight would duplicate. The epoch is the identity of the crossing, assigned by the only component that knows when a crossing happened, so the key is exact.
  5. How would you know alerts are wrong before users tell you?
    Track the contradiction rate of suspect prices per extractor and per site (a jump means a layout change), the share of alerts whose price was gone at the next check (high means we alert late or on prices that do not last), and alerts per watch per crossing, which must be exactly one. Re-check a sample of alerted listings a minute after sending. And keep a per-site switch that holds alerts from scraped prices while an extractor is investigated.
Prime Day: one drop, 600 k alerts

Ask: The most-watched product on Prime Day drops below 600 k targets at once. How do you alert them all quickly, and what if the deal ends halfway through?

Good answers name: Sub-partitioned range read, per-user collapse, shuffled waves at the notification quota, a price check before each wave, Send all 600 k at once, Send in order of watch creation, first come first served, Digest: one email an hour listing all your drops.

Our pick: The listing is hot, so its index has 32 sub-partitions; the matcher reads the crossed range from all 32 in parallel (a second or two) and hands it to a wave sender so its Kafka partition keeps flowing. The sender collapses by user (one message even for someone watching three variants), shuffles by hash(watch_id, seq) so nobody is always last, and sends waves under our 20 k/s quota on the notification system’s high-priority lane, each message with a 30-minute TTL so a push delayed past the deal is never shown. While the fan-out runs, the scheduler checks the listing every 30 seconds; a rebound arrives on the same stream, the sender reads the new price before each wave, and watches whose target is now below the price stay ARMED and unsent. Several concurrent fan-outs share the quota, weighted by size. The chart the recipients open is served from the CDN with a 60-second TTL, so thousands of taps a second become one origin read a minute per edge location.

  1. Why not just ask the notification system to send faster?
    Its high-priority lane is shared with every other producer, and our 600 k is a burst on a day when everyone is bursting. A per-producer quota is how that system stays fair, and we have to live inside it: within it we choose the order and can stop, above it we would be delaying someone else’s login codes. If 30 seconds is not fast enough, the fix is a larger quota for the event, agreed in advance.
  2. Halfway through, the price goes back up to $199.99. Who gets what?
    The 300 k already sent got a correct alert for a price that existed. The remaining waves skip watches with targets below $199.99, which stay ARMED and will alert on the next real crossing; watches with targets between $199.99 and $249.99 still get theirs, because the price is still under their target. Nobody gets a message for a price that is gone, and nobody’s watch is used up without an alert.
  3. Ten hot listings drop in the same minute.
    Each gets its own wave sender and they share the 20 k/s quota, weighted by size. Ten 600 k fan-outs then take five minutes rather than 30 s each, and the order becomes a product question: deepest discount first, or the deal that ends soonest. The detector, history and other listings are unaffected because the waves run outside the partition consumers.
  4. Should the push open our page or the retailer’s?
    The retailer’s product page, with our affiliate tag, because the user wants to buy and every extra page costs conversions; the chart is one tap away in the app. The retailer can absorb 600 k visits. Our own chart still gets a share of the taps, which is what the CDN cache is for.
  5. How do you test this before Prime Day?
    Replay last year’s biggest crossings against a staging matcher with production-sized watch partitions and a fake notification system that records timing, and check the time to first and last wave, the behaviour on a mid-fan-out rebound, and dedupe after a killed matcher. Then run a capacity drill with the notification team at the expected peak. On the day, watch wave-sender lag, the notification 429 rate, and the share of alerts whose price was gone at the next check.

Close · 5 min

Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.