Design a Price Tracker

Track prices on a huge shopping site: spend a limited crawl budget where people care, trust crowd reports carefully, chart history and alert once per drop.

Case study30 minLesson 6 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

You want a pair of headphones, but not at $79.99. You paste the product link into a price tracker, set "tell me under $65", and forget about it. Three weeks later your phone buzzes: $64.99. You open the tracker and see a chart of every price that product has had for two years.

Interviewers like this question because the hard part is not storage or traffic. It is a budget: the shopping site has 500 million product pages and you may only fetch a few hundred a second, so you must decide what to look at, borrow observations from users, and still never send a false alert. The question means the kind of product Keepa offers, tracking one large store. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design a price tracker for a large e-commerce site.

You

Where do prices come from? Can we use an official API, or do we fetch product pages, and how fast may we fetch?

Interviewer

No price API. You fetch pages, and you must stay under 500 requests a second.

You

How many products exist, and how many do our users track?

Interviewer

About 500 million products. 10 million users have 50 million alerts on about 20 million distinct products. We also have a browser extension with 2 million installs that can report the prices users see.

You

Then I can't watch everything often, so I'll spend fetches where alerts are close to firing and use the extension to fill gaps. My priorities: never send an alert for a price that wasn't real, send each drop once, and keep full history. How quickly must an alert follow a drop?

Interviewer

Within a couple of hours for popular products is fine.

  • Functional: track a product by link; set an alert at a target price or a percentage drop; show a price-history chart; notify once when the price falls to the target; accept price reports from the extension.
  • Non-functional: stay under 500 fetches a second to the site; check popular products every 2 hours and every tracked product at least daily; no alert on an unverified price; at most one alert per drop; chart loads under 200 ms.
  • Out of scope: other stores, price predictions, product search, user accounts.

Estimate the scale

The givens are the scenario's, not any real tracker's. A day is 86,400 seconds.

  • Fetch budget: 500 × 86,400 = 43.2 million fetches a day.
  • Observations: 43.2 million from fetching plus about 30 million extension reports ≈ 73 million a day.
  • Price changes: assume 1 observation in 10 shows a new price: about 7.3 million changes a day, about 85 a second. The alert pipeline is small.
  • Alerts: 50 million × about 100 B = 5 GB. One database holds them.
Estimate: How long to see every page once

If you spent the whole fetch budget visiting every product page in turn, how long would one full pass take?

You know
  • 500 million product pages
  • At most 500 fetches a second

Show the working
  1. 500 × 86,400 = 43.2 million fetches a day
  2. 500,000,000 ÷ 43,200,000 ≈ 11.6 days, about 12

Estimate 12 days (anything from 4 days to 40 days is a good answer)

A tracked product checked every 12 days misses most sales. Spreading the budget evenly is the wrong plan; priority is the design.

Estimate: A year of price history

Each observation is about 32 bytes. How much history does a year add if you store only the observations that change the price?

You know
  • About 73 million observations a day
  • About 32 bytes each
  • 1 in 10 observations is a price change

Show the working
  1. Every observation: 73,200,000 × 32 B ≈ 2.3 GB a day, ≈ 855 GB a year
  2. Changes only: 855 GB ÷ 10 ≈ 85 GB a year

Estimate 85 GB (anything from 30 GB to 300 GB is a good answer)

Even storing everything fits on a few machines; storing changes makes it fit in one and makes charts faster.

Your turn: Define the API

Write the calls for creating an alert, reading a chart and the extension's report. What should the report call tell the extension about what happened to its report?

Compare with a model answer

Alerts and history are ordinary REST calls. The report call returns 202 and nothing else: whether a report was trusted is decided later, and telling a cheater which fake prices passed would help them tune the next one.

Define the API

HTTP API
POST /alerts
  body: { "product_url": "https://shop.example/p/B0077", "target_cents": 6500 }
     or { "product_url": "...", "drop_percent": 15 }
  201 { "alert_id": "a-31", "product_id": "P-77",
        "current_cents": 7999, "last_checked": "2026-11-14T08:10Z" }
  404 not a product page we recognise
 
DELETE /alerts/{id}
 
GET /products/{id}/history?range=1y
  200 { "resolution": "day", "low_cents": 5999, "last_checked": "...",
        "segments": [ { "from": "2026-01-03", "to": "2026-02-11", "cents": 7999 }, ... ] }
 
POST /observations                  from the extension, signed install token
  body: { "product_id": "P-77", "cents": 6499, "currency": "USD",
          "seller": "store", "condition": "new", "seen_at": "..." }
  202 Accepted                      always, whatever is decided later
  429 too many reports from this install

The decision worth arguing is the 202. The extension never learns whether its report was believed. Validation happens asynchronously, against other reports and fetches, and a silent answer gives a cheater nothing to tune against.

A percentage alert is turned into an absolute target when it is created: 15% below $79.99 is $67.99. The matcher then only ever compares prices, and the user sees the exact number they will be alerted at.

Your turn: Model the data

Sketch the price history and alert records. How does the chart know the difference between "the price did not change for a month" and "nobody looked for a month"?

Compare with a model answer

Store history as segments: a price, when it was first seen, and when it was last confirmed. A long segment with a recent confirmation is a steady price; a gap between one segment's last confirmation and the next segment's start is time nobody looked. Alerts are rows indexed by product and target price.

Model the data

History, alerts and crawl state
price_segments                       -- one row per run of the same price
  product_id     VARCHAR(16)
  first_seen     TIMESTAMP
  last_seen      TIMESTAMP           -- moved forward by each matching observation
  cents          INT
  source         VARCHAR(8)          -- crawl | crowd (verified)
  PRIMARY KEY (product_id, first_seen)
 
alerts
  alert_id       VARCHAR(16) PRIMARY KEY
  user_id        BIGINT
  product_id     VARCHAR(16)
  target_cents   INT
  state          VARCHAR(8)          -- armed | fired
  fired_change   VARCHAR(16) NULL
  INDEX (product_id, state, target_cents)
 
crawl_state
  product_id     VARCHAR(16) PRIMARY KEY
  next_due       TIMESTAMP
  priority       REAL
  last_verified_cents INT

History is written in time order and read as "one product, one time range". That is the shape of a wide-column or time-series store keyed by (product_id, first_seen): one product's segments sit together, sorted, and a chart is one range scan. Alerts need a different query, "armed alerts on P-77 with a target at or above $64.99", which is an index range scan in a relational database. Crawl state is a priority queue; it lives with the crawler.

Your turn: High-level design

Sketch the boxes: how prices arrive from the crawler and the extension, where they are checked and stored, and how a price change becomes a notification.

Compare with a model answer

Two sources, fetches and crowd reports, feed one price service that validates them, writes history and emits a change event only when a verified price changes. An alert matcher consumes those events, finds matching alerts and hands notifications to a separate notification system. Users read history through API servers.

High-level design

Fetches and crowd reports meet in one price service. Only a verified change produces an event, and only events reach the alert matcher.Web / mobile app to API servers; API servers to Price history (read chart); Browser extension to Price service (price reports); Crawler to Shopping site (500/s max); Crawler to Price service (parsed prices); Price service to Price history (write); Price service to Alert matcher (changes), asynchronous; Alert matcher to Alerts DB (find, fire); Alert matcher to Notification system (send).Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystemread chartprice reports500/s maxparsed priceswritechangesfind, firesend

request and replyasynchronous message

Fetches and crowd reports meet in one price service. Only a verified change produces an event, and only events reach the alert matcher.

Two arrows are left out to keep the picture readable: the API servers write new alerts to the alerts database, and the crawler reads alert counts from it to set priorities. Both arrows into the price service pass through a log partitioned by product_id, so one instance handles each product's observations in order. Two copies of the same drop can then never both see $79.99 as the last price and emit two events. Step through a drop found by the crawler, two crowd reports, and the failures.

A price drop, crowd reports, and a crashUse the arrows or ← → keys
  1. A price drop, crowd reports, and a crash, step 1Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    P-77 last verified
    $79.99
    budget
    500/s

    1P-77 has 3,400 trackers and alerts close to its price, so it is checked every 2 hours. It is due, and the 500-a-second budget has room, so the crawler fetches it.

  2. A price drop, crowd reports, and a crash, step 2Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historynew: $64.99BrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    P-77 last verified
    $64.99
    change id
    c-501

    2The parser reads $64.99. The price service compares it with the last verified $79.99: a change. It closes the old segment and starts a new one at $64.99.

  3. A price drop, crowd reports, and a crash, step 3Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    change id
    c-501
    alerts matched
    3

    3It emits change c-501 to the event log. The matcher asks for armed alerts on P-77 with a target of $64.99 or more: 3 match, targets $70, $67.99 and $65.

  4. A price drop, crowd reports, and a crash, step 4Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    change id
    c-501
    alerts sent
    3

    4It sends 3 notifications, each keyed alert id + c-501, then marks the 3 alerts fired. A user with a $60 target is not told: $64.99 is above it.

  5. A price drop, crowd reports, and a crash, step 5Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherredeliveredNotificationsystem3 duplicates
    change id
    c-501
    alerts delivered
    3

    5The matcher crashes after sending but before marking the alerts fired. The log redelivers c-501, so it sends again with the same keys. The notification system drops all 3 as duplicates.

  6. A price drop, crowd reports, and a crash, step 6Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    P-77 last verified
    $64.99
    events
    0
    fetches saved
    1

    6A minute later an extension user sees P-77 at $64.99 and reports it. Same price, so only last_seen moves, and no event. P-77's next fetch is pushed back 2 hours.

  7. A price drop, crowd reports, and a crash, step 7Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceunverifiedAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    P-90 last verified
    $89.99
    P-90 report
    $9.99

    7Another install reports P-90 at $9.99. The last verified price is $89.99, so this is an 89% drop from one reporter. It is held as unverified: no history, no event.

  8. A price drop, crowd reports, and a crash, step 8Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensiontrust downPrice serviceAlerts DBShopping siteCrawlerAlert matcherNotificationsystem
    P-90 last verified
    $89.99
    false alerts
    0

    8The price service asks the crawler for a priority fetch of P-90. The page says $89.99. The report is discarded and that install's trust score drops. No one got a false alert.

  9. A price drop, crowd reports, and a crash, step 9Web / mobile app to API servers; API servers to Price history; Browser extension to Price service; Crawler to Shopping site; Crawler to Price service; Price service to Price history; Price service to Alert matcher, asynchronous; Alert matcher to Alerts DB; Alert matcher to Notification system.Web / mobile appAPI serversPrice historyBrowserextensionPrice serviceAlerts DBstill firedShopping siteCrawlerAlert matcherNotificationsystem
    target
    $65
    re-arm above
    $68.25
    alerts sent
    0

    9Next day P-77 rises to $66, then drops back to $64.99. The $65 alert re-arms only above $65 × 1.05 = $68.25, so it stays fired and the user gets no second message for the same dip.

1 / 9

Deep dives

Interviewers push on four things: spending the fetch budget, trusting crowd reports, storing and charting history, and alerting once.

Spending the fetch budget

The crawler is the one from Design a Web Crawler, aimed at one site: a frontier, politeness, parsers. What is different is that the frontier is not "everything, eventually" but a priority queue ordered by next_due, and the interval for each product comes from how much users care. A token bucket in front of the fetchers holds the whole fleet to 500 a second, whatever the queue says.

One plan that fits the 43.2 million fetches a day:

TierProductsIntervalFetches a day
Hot: most trackers, or an alert within 5% of the price1 million2 hours1M × 12 = 12 million
Tracked19 million1 day19 million
Untracked, for history and new products480 millionabout 40 daysthe remaining 12.2 million

Tiers simplify a score: more trackers, an alert target near the current price, a volatile price and a long time since the last look all raise priority. An alert 40% below the price does not need a fetch every 2 hours; one 2% away does.

Every verified extension report is a fetch you need not make: the product's next_due moves forward. The extension sees exactly the pages people care about, so it relieves the hot tier most.

Trusting crowd reports

Extension reports are free, but each comes from a stranger's browser. Some are wrong by accident (a member price, a coupon, a used offer, a misread page); some on purpose. The rule: a crowd report can extend a price or ask for a check; it cannot, on its own, make a big change.

What to do with a crowd report
OptionGood atBad atPick it when
Trust every reportMost coverage, fastest alerts.One script can post $9.99 for a popular product and send thousands of false alerts in minutes.Never for anything that triggers alerts.
Use reports only to schedule fetchesNo crowd price ever enters history. A report of a change just moves that product to the front of the queue.Every change still costs a fetch, so the budget saving is small.Abuse is heavy, or the extension is new and trust scores are not built yet.
Verify by size of change Usual defaultSmall changes from trusted installs are accepted; big ones need a second independent reporter or a priority fetch.Trust scores and corroboration rules to build and tune; a big real drop waits for confirmation.A mature extension with many reporters. The choice here.

Concretely: the extension reads the same fields the crawler parses (seller, condition, currency), so they mean the same thing, and anything other than the public new price is dropped. A report that matches the last verified price just moves last_seen. A change of up to 10% from an install with a good trust score is accepted. Anything bigger, or from a new install, is held until a second install on a different network reports the same price within an hour, or a priority fetch confirms it. Trust rises when an install's reports later agree with fetches and falls when they do not. Each install is rate limited, so one install cannot flood the queue.

Storing and charting history

Prices are flat most of the time, so storing every observation mostly stores repeats. Store segments instead: a new row only when the price changes, and a matching observation just moves last_seen forward. That is the 10x saving in the estimate, and it keeps the chart honest about gaps: if last_seen is 10 days before the next segment starts, the chart shows a gap, not a flat line nobody checked.

A one-year chart for a product that changes daily has 365 segments: one range scan, fast. For "all time" on a product that changes hourly, precompute daily rollups (low, high, last) and read those instead, so no chart reads more than about a thousand rows. Keep the all-time low in a per-product summary row, since every product page shows it.

Alerting once per drop

Duplicates come from three places, and each has its own fix.

  • Two sources see the same drop. The crawler and an extension both see $64.99. Only a change from the last verified price emits an event, so the second observation emits nothing.
  • The pipeline redelivers. The event log delivers at least once, so the matcher can process c-501 twice. Every notification carries the key alert_id + change_id, and the notification system drops a key it has already sent. The alert's fired_change column also lets the matcher skip work it already did.
  • The price flaps. $64.99, $65.01, $64.99 in one day is two drops below $65 by the letter of the rule, and one annoying user. A fired alert re-arms only when the price climbs 5% above the target, or when the user asks. The matcher checks this on each change event, with the same index.

A very popular product can match 200,000 alerts in one change. The matcher reads them in pages from the (product_id, state, target_cents) index and hands them over in batches; if the notification system sends 2,000 a second for this, the last user hears about 200,000 ÷ 2,000 = 100 seconds after the first, well within the 2-hour promise.

Bottlenecks and failures

At 10x users the fetch budget does not grow; the site sets it. Intervals stretch, and the extension, which grows with users, matters more. History grows by less than 10x, because more observations catch more of the real changes but prices do not change more often. Even the worst case, about 850 GB a year, shards easily by product_id.

When this diesWhat happensWhat you do
The site starts blocking fetchesFetch errors climb; prices go stale.Back off at once; the token bucket rate drops. Lean on verified crowd reports, and show "last checked" honestly.
The site changes its page layoutThe parser returns nothing, or worse, the wrong number.Reject parses that fail sanity checks, alert the team when the failure rate jumps, and never emit events from a parser version that just started disagreeing with history.
The price serviceFetches and reports pile up.Observations wait in the log; the service catches up from its last offset.
The alert matcherAlerts are late, not lost.Change events wait in the log; on restart it resumes from its last committed offset.
The notification systemMatched alerts cannot be sent.Keep the alerts armed until sending succeeds; the idempotency key makes retries safe.

What a strong answer sounds like

LevelWhat the answer does
JuniorA cron job fetches every tracked product, stores every price, and compares each with every alert.
MidWorks out that the budget cannot cover the catalogue, prioritises tracked products, stores history keyed by product and time, and sends alerts through a queue.
SeniorTreats the fetch budget as the design, prioritises by how close alerts are to firing, uses the extension to save fetches but never to fire alerts alone, stores segments that show gaps, and names each source of duplicate alerts with its fix.
Using the extensionWeak answer
Interviewer

How do you use the extension's reports?

You

They are real prices users saw, so I write them into history like the crawler's and run alerts on them.

Using the extensionStrong answer
Interviewer

How do you use the extension's reports?

You

Mostly to save fetches. A report that matches the price we know just confirms it and pushes that product's next fetch back. A small change from a trusted install is accepted. A big change needs a second independent reporter or our own fetch before it enters history, because only verified changes fire alerts.

Interviewer

Doesn't that delay real flash sales?

You

By one priority fetch, a few seconds. You said a late alert beats a wrong one, and that's the trade.

Follow-up questions

How do you start tracking a product nobody has tracked?

Fetch it at once, at top priority, so the user sees a current price and a history from the untracked tier if one exists. From then on it is in the tracked tier.

How would you support many stores?

Each store gets its own budget and token bucket, its own parser, and its own frontier; the price service, history and alerts do not care where a price came from. Products sold by several stores need a matching step, which is a separate problem.

How do you detect fake drops, where the price is raised before a "sale"?

The history already shows it. Compare the "sale" price with the median of the last 90 days, not with the price the day before, and label the drop honestly in the alert.

Why not just fetch tracked products more often from many IP addresses?

Because the budget is the site's limit, not a technical one. Evading it is a fast way to be blocked entirely and to break the site's terms. Getting more coverage is what the extension and better priorities are for.

How do you know the system is healthy?

Track the age of the last verified price per tier, the parse failure rate, the share of crowd reports that fetches contradict, and the time from a change event to a sent alert. A rise in contradictions is often a layout change, not cheaters.

Self-review: price tracker0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure