SysDesignPrep.com
System design interview question

Design a Price Tracker

Track 500 M products' price history and alert 50 M users when a price drops below their target, in time to buy.

Last updated 2026-10-26. Difficulty: hard. Patterns: adaptive-scheduling, change-detection, time-series, range-queries, alerting. Reported at Amazon, Google, Meta, Microsoft, Shopify, Instacart.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Amazon · Google · Meta · Microsoft, run as that company’s round is reported to go.

The short answer

How to design Price Tracker in a system design interview, in five steps:

  1. Scope it. Watch a product with a target price, price history chart, drop alerts by push or email, browser extension, manage the watch list, multiple marketplaces.
  2. Set the targets. Fixed check budget: ~15 k checks/s; Alert latency once a change is seen: p95 < 60 s; Alert correctness: one per crossing · no glitch alerts; Scale: 500 M listings · 1 B watches.
  3. Do the numbers. Checks per second (the budget): ~14.4 k/s; Uniform interval at that budget: ~10 h; Product API calls: ~1 k calls/s.
  4. Draw the design. Retailers, Price fetchers, Check scheduler, Notification system, Latest price, Change detector, Listing events, Alert matcher, API gateway.
  5. Go deep where it is hard.
    • Getting prices: API, scraping, extension → Product API first, scraping for the rest, extension reports as hints that trigger a check.
    • Spending a fixed check budget → Value-weighted adaptive intervals with one global budget factor.
    • Price history: store only the changes → Change points in a wide-column store, one partition per listing, series and year.
    • Finding the watches a change crosses → Index by (listing, target), written only by a matcher that consumes one ordered stream per listing.
    • One alert per crossing, never on a glitch → Per-watch state with hysteresis, epoch-derived notification keys, confirmation before alerting on suspicious drops.
    • Prime Day: one drop, 600 k alerts → Sub-partitioned range read, per-user collapse, shuffled waves at the notification quota, a price check before each wave.

Functional requirements

  • 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 requirements

  • 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.

Back-of-envelope estimates

  • 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.

Components

  • 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.

User flows

  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. Due listings come out of the sorted sets oldest first, and a listing only leaves when its source has a token, so a quota cut stretches intervals instead of producing 429s. A check not reported back within 2 minutes is dispatched again; a duplicate costs one item of quota and the detector drops it as identical.
    2. A fetcher batches ten due listings from one marketplace into one product API call. Batching is what stretches the quota: 1 k calls a second is 10 k items. Listings are grouped by marketplace because quotas and currencies are per marketplace. A 429 puts the batch back with a short delay and lowers that marketplace’s token rate; nothing is dropped.
    3. The fetcher turns each item into an observation and sends it to the change detector. An observation is the fact plus its provenance: listing, series, price in minor units, currency, stock, source, extractor version and observed_at. Observations are routed by hash of listing id, so one detector instance usually sees a listing in order and keeps its latest price warm in memory.
    4. The detector compares with the latest price, sees $249.99 become $169.99, and advances the record with compare-and-set. This is the dedupe: about 96 % of observations match the latest price and stop here after touching last_checked. A 32 % drop is inside this listing’s range (its 90-day low is $179.99 and a sale is on), so it is not suspect. The sequence number makes racing observations safe: only one of them advances seq 42 to 43, and an observation older than the latest is ignored.
    5. The detector appends one change point to the listing's history. One row with ~13 bytes of payload, in the partition for this listing, series and year. The row key includes the timestamp, so a retried append rewrites the same cell instead of adding a second point. Nothing at all is written for the thousands of checks that saw the same price.
    6. The detector publishes price.changed on the listing's event stream. Keyed by listing id, the same key the watch CDC uses, so the matcher sees this change and any watch edit for the listing in one order. The event carries the old price as well as the new one: the matcher needs the interval between them, not just the new value.
    7. The matcher reads the watches whose target lies between the new and the old price. Targets at or above $249.99 were already met at the old price, and targets below $169.99 are still not met. Only [169.99, 249.99) can have crossed, so the read touches exactly those rows, sorted by target. Rows in state MET (alerted earlier and not yet re-armed) are skipped.
    8. For each armed watch the matcher sends an alert with a deterministic key, then marks the watch MET. The key is hash(watch_id, arm_epoch), so replaying this event after a crash produces the same keys and the notification system drops the repeats. That is why sending before recording MET is safe; recording first would risk watches marked MET whose alert never left. Channel choice, quiet hours and provider retries belong to the notification system.
    9. The matcher reports the listing's armed count and nearest target, and the scheduler sets the next check. With 1,812 watches now MET, 310 armed watches remain below the price, the nearest at $159.99, 6 % away; and a change in the last hour boosts the value, because repricing comes in bursts. The scheduler recomputes clamp(k ÷ value, 2 min, 24 h) and lands on about 4 minutes.
  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. The listing id comes from the page: the marketplace from the domain, the product id from the URL or the page’s structured data. The extension is inert everywhere else, and never sends a URL or anything from a page that is not a supported product page.
    2. The extension requests the listing's history for the last year. The response is small and the same for everyone, so it is cacheable at the edge for 60 seconds, with the listing’s history sequence as the ETag. The chart has to render well inside the page’s own load time or the extension feels broken.
    3. On a cache miss the API reads the year's change points from one partition. About 36 rows for a typical listing and a few KB even for a volatile one. Long ranges on listings repriced many times a day read a daily min/max rollup instead of every point.
    4. The page shows €164.99 but our latest price is €179.99, so the extension prepares a report. The extension reads the price with the same per-site rules our extractor uses, plus the context that makes a price personal: signed in, member-only badge, coupon checkbox, delivery country. A price that differs only because of a coupon or a member discount is not reported.
    5. The extension posts a signed report, which the API forwards to the change detector. The report is tied to an install, not a user, and rate limited per install and per IP at the gateway. Its weight comes from the install’s track record: how often its past reports were confirmed by a check.
    6. The detector does not trust the report; it asks the scheduler for a verification check now. A report is never written to history or published as a change, so a spoofed one can only cost a check. Report-triggered checks are capped at one per listing per 10 minutes, and the latest record notes that one is pending, so a hundred matching reports cost the same single check.
    7. The verification check confirms €164.99 through the product API, and it becomes an ordinary change. From here it is the drop flow: compare, append, publish, match. The crowd made the alert faster by however long the listing would have waited for its next scheduled check, which for a long-tail listing could be most of a day.
  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. Its value is at the cap, with 2 M armed watches and a sale under way, so it sits at the 2-minute floor. Checking faster buys little, because the API’s own data can be a minute or two behind the site.
    2. The detector accepts the drop, appends it and publishes price.changed. 32 % is a big step, but the listing is flagged as on sale and $169.99 is close to its 90-day low, so it is not suspect. Had it read $16.99, the matcher would not see it until a second source confirmed it.
    3. The matcher reads the crossed range from all 32 sub-partitions in parallel. 600 k rows of ~60 bytes is ~36 MB, about 19 k crossed rows per sub-partition, back in a second or two. Sub-partitions are by hash of watch id rather than by price band, because targets cluster at round numbers like $199.99 and one band would hold most of them.
    4. The matcher hands the crossing to a wave sender, which collapses it per user and shuffles it. Handing off keeps the listing’s Kafka partition moving, so the other listings on it are not delayed by this fan-out. Collapsing sends one message to a user who watches three colours of the same product. The shuffle is seeded by hash(watch_id, seq), so the order differs on every drop and nobody is always last.
    5. The sender sends waves at the 20 k/s notification quota and asks the scheduler for a re-check every 30 seconds. 600 k at 20 k a second is 30 s. Each message has a 30-minute TTL, so a push stuck behind a provider outage is dropped instead of arriving after the deal. Watches are marked MET as each wave is accepted.
    6. A re-check finds $199.99; the rise arrives on the same stream and the remaining waves skip targets below it. The sender reads the listing’s current price before each wave. Watches with targets under $199.99 are no longer met, so they are not sent and stay ARMED for the next real crossing; those between $199.99 and $249.99 still get theirs. The rise also runs the re-arm range for any MET watch it lifted clear of the band.
    7. Users who tap the alert and open the chart are served from the CDN. The push deep-links to the retailer, but a share of users open our chart first: thousands of requests a second for one listing. A 60-second edge cache turns that into about one history read a minute per edge location.
  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. The site moved its add-on offers above the main price, and the extractor’s selector now matches a $1.00 cable. Nothing about the fetch failed: status 200, valid HTML, a well-formed price. This is the failure scraping produces.
    2. The fetcher sends the observation with its source and extractor version. Provenance on every observation is what makes the next steps possible: the detector can pick a different source to confirm with, and a bad extractor version can be found and backed out later.
    3. The detector flags the price as suspect and holds it as pending, without touching history or publishing. Suspect means more than 50 % below the 90-day low, more than 70 % in one step, or a currency, unit or offer mismatch; here it is 99.7 % below. The latest record keeps $299.00 as the current price and $1.00 as pending, so the chart, the matcher and the API all still see $299.00.
    4. The detector asks the scheduler for a confirmation from a different source within a minute. A different source matters: running the same broken extractor again would confirm the same wrong number. For a site without an API the alternative is the previous extractor version or the mobile page, which has a different layout.
    5. The confirmation reads $299.00, so the pending price is discarded and counted against extractor v14. Nobody was alerted and the chart never showed $1.00. Had the confirmation also read $1.00, it would be a real pricing error: written and published as confirmed, about a minute later than an unsuspicious drop.
    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. One wrong price is noise; a rate is a broken extractor. Quarantine means its observations are ignored and the site’s checks use the fallback extractor until v14 is fixed. The same signal pages the team that owns the site’s rules, usually within minutes of a retailer’s redesign.
  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. The watch service canonicalises the URL to amazon.de:B0C1234567, dropping tracking and affiliate parameters, and stores the target in euros because that listing charges euros. The add screen already shows the chart and current price, so the UI can say at once if the target is already met.
    2. The watch service writes the watch to the user-keyed store. This store is the source of truth for what the user asked for and the only thing the watch service writes. It does not touch the watch index, which is what keeps the next step’s ordering guarantee simple.
    3. CDC publishes watch.created onto the listing's event stream, keyed by listing id. Same topic and key as price.changed, so the matcher sees this watch and any price change for the listing in one order. CDC rather than a second write from the service means the event exists if and only if the watch committed.
    4. The matcher inserts the watch into the index and evaluates it against the price it last saw for the listing. €150 is below the current €179.99, so it goes in ARMED with epoch 1. Had a drop to €149 arrived just before this event, the matcher would already hold €149, insert the watch and fire it at once: the race between creating a watch and a price change cannot lose the alert.
    5. The matcher tells the scheduler that the listing now has a closer target. The nearest armed target is now 17 % below the price instead of 30 %, so the value rises and the next check comes sooner. A listing with no known price at all is checked at once, because the user is looking at it.
    6. Weeks later the price falls to €149 and the watch fires; then it rises to €165 and the matcher re-arms it. The drop fires with key hash(watch_id, 1) and sets MET. While the price wobbles between €149 and €152 nothing happens, because re-arming needs a price above target × 1.02 = €153. The rise to €165 reads the re-arm range [149 ÷ 1.02, 165 ÷ 1.02) = [€146.08, €161.76), finds this watch MET and sets it ARMED with epoch 2, so the next drop is a new alert with a new key.

Deep dives

Getting prices: API, scraping, extension

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

Every price we know costs a request to somebody. A retailer’s product API is structured and permitted, but it is a quota: say 1 k calls a second across our keys at 10 items a call, ~10 k items a second, and only for marketplaces that offer one. Scraping reaches any site, but each price is a page fetch through the crawler under its per-site politeness limits, read by an extractor that breaks when the retailer changes its layout. Meanwhile 5 M active extension users open ~100 M product pages a day, up to 1.2 k prices a second, for free, on exactly the products people care about.

The sources differ in trust as much as in cost. An API price is what the retailer says it charges. A scraped price is what our extractor thinks the page says. A crowd report is what someone claims their browser showed, and the price may be personal: signed in, member-only, a coupon, a regional or test price. Anyone can also post fake reports to trigger alerts or vandalise a chart. So the question is not which source to use but what each source is allowed to do: be written to history, trigger an alert, or only cause a check.

  • Product API first, scraping for the rest, extension reports as hints that trigger a check chosen
  • Scrape everything rejected
  • Trust extension reports directly rejected
  • Retailer bulk feeds or partnerships situational: for the daily long-tail check where a retailer offers a feed; never for the hot tier

The answer: 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.

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.

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.

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.

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.

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

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

Spread evenly, 14.4 k checks a second visits each of 500 M listings about every 10 hours. A 6-hour lightning deal on a console with 2 M watchers would often start and end between two checks, while a phone case nobody watches would be checked just as often. The budget is fixed by API quota and crawl politeness, so every check spent on a listing that will not change, or whose change nobody is waiting for, is a deal missed somewhere else.

What a check is worth is the chance that it finds a change someone is waiting for. That depends on three things the system already knows: how many armed watches the listing has, how far the price sits above the nearest armed target, and how often and by how much this listing’s price moves. A product at $300 whose highest target is $150 needs a 50 % drop to matter; one at $102 with a target at $100 is a 2 % wobble from firing. A crawler’s freshness model has importance and change rate, but not the gap to a target, and that gap is what makes this scheduler different.

  • Value-weighted adaptive intervals with one global budget factor chosen
  • Uniform round robin rejected
  • Tiers by watcher count only situational: as the first version, and as the floor and ceiling around the value model
  • Event-driven only: check when a report or a deal feed says so rejected

The answer: 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.

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.

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.

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.

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.

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

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

An observation is ~13 bytes (timestamp, price in minor units, flags), so 1.24 B a day is ~16 GB a day and ~6 TB a year, and a 5-year chart for a long-tail product would read ~1,800 points that all say the same thing. About 96 % of observations repeat the previous one. A price is a step function: it holds for hours or months and then jumps.

So the natural record is the change point, (time, new price), meaning the price held from that moment until the next point. That makes history ~36 points per listing per year and ~240 GB a year in total. Reads are just as simple: a chart wants one listing’s series over a time range, so a series should be one partition read in one request. The metrics system’s TSDB is tuned for another shape, millions of series sampled at fixed intervals and compressed on the assumption of regular timestamps; our points are sparse and irregular.

  • Change points in a wide-column store, one partition per listing, series and year chosen
  • Every observation in a time-series database rejected
  • Relational table (listing_id, ts, price) situational: up to tens of millions of listings, or as the first version
  • One compressed blob per listing in object storage situational: for years older than the last two, rewritten once a year as an archive

The answer: 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.

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.

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.

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.

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

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

There are 1 B watches and up to 5 k price changes a second on sale days. Watches are created by users, so the natural store is keyed by user, which is useless here: a change knows its listing, not its watchers. And the distribution is extremely skewed. The median watched listing has 2 or 3 watches while the top ones have millions, and reading 2 M watches on every change of the hottest listing, every few minutes, would dominate the whole system.

A drop from p_old to p_new fires exactly the watches with p_new ≤ target < p_old: targets at or above p_old were already met at the old price. So if a listing’s watches are sorted by target, the work is a range read whose size is the number of watches that actually fire. A rise is the same in reverse and re-arms a range. The other half of the insight is ordering: if price changes and watch edits for one listing travel on one ordered stream, a new watch can never slip between a price change and the index.

  • Index by (listing, target), written only by a matcher that consumes one ordered stream per listing chosen
  • Read all watches of the listing and compare each situational: listings with a few thousand watches or fewer, which is almost all of them; the sorted layout costs nothing there either
  • Periodic batch join of prices and watches rejected
  • In-memory sorted arrays inside the matcher situational: as a cache in front of the durable index if store read latency becomes the bottleneck

The answer: 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.

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.

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.

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.

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.

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

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?

The condition price ≤ target is a level, but users want an edge: tell me when it becomes true. Firing on the level sends a message on every check while the price stays low; firing once per watch ever means no alert when the next sale comes. Prices also wobble: a listing alternating between $99.99 and $100.01 around a $100 target would alert every few minutes. And every piece of the pipeline retries: Kafka redelivers, the matcher crashes mid-batch, the notification call times out.

Wrong prices are the other failure. Extractors grab an accessory’s price, a per-ounce price or a price in the wrong currency, and retailers publish genuine errors that are corrected within minutes. An alert on a $1 price that never existed sends a million people to a page that says $299, which costs the product its credibility faster than any missed deal. So every alert needs two properties: it fires once per crossing, and the price it fires on has been seen by two sources whenever it looks implausible.

  • Per-watch state with hysteresis, epoch-derived notification keys, confirmation before alerting on suspicious drops chosen
  • Fire on the level, rate-limited to one alert per watch per day rejected
  • Fire once ever, then the user re-arms manually situational: as a per-watch "alert me once" option, not the default
  • Alert on any observed price, retract if it was wrong rejected

The answer: 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.

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.

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.

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.

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.

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

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?

The hottest listing has ~2 M watches. A drop from $249.99 to $169.99 crosses ~600 k of them in one event: read 600 k rows of ~60 bytes (~36 MB), write 600 k state changes, and hand 600 k messages to a notification system that also carries everyone else’s alerts, on a day when 5 k other prices change every second. Then a share of those 600 k people tap the alert within minutes and open our chart for the same listing.

Two clocks run against each other. Deals sell out and lightning deals end, so the last alert must go out within a minute or two or it is useless. But the price can also bounce back while we send, and an alert for a price that is gone is worse than none. So the fan-out needs pacing: fast enough to finish in well under a minute, in waves small enough that the rest can be cancelled when the price moves.

  • Sub-partitioned range read, per-user collapse, shuffled waves at the notification quota, a price check before each wave chosen
  • Send all 600 k at once rejected
  • Send in order of watch creation, first come first served situational: if the product wants a paid tier alerted first, ordering by tier uses the same mechanism
  • Digest: one email an hour listing all your drops situational: user-selected for low-priority watches, and for users with dozens of crossings at once on sale days

The answer: 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.

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.

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.

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.

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.

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.

The theory behind it

  • Time-series data: Storing and querying time-series data such as metrics, sensor readings, prices and events: write patterns, time-partitioning, compression, downsampling and retention, and choosing between time-series databases, wide-column stores and OLAP engines.
  • Web crawling at scale: How large crawlers fetch billions of pages: the URL frontier, politeness per host, prioritisation and recrawl scheduling, DNS and fetching, URL normalisation and deduplication, content fingerprints, robots.txt, traps and distributing the crawl.
  • Hot keys, hot partitions and data skew: Why real traffic is never uniform, how a single hot key or partition takes down a sharded system, and the fixes: key splitting, local caching, request coalescing, write buffering, salting, adaptive rebalancing, and skew in joins and stream processing.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview