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.
On this page
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
Design a price tracker for a large e-commerce site.
Where do prices come from? Can we use an official API, or do we fetch product pages, and how fast may we fetch?
No price API. You fetch pages, and you must stay under 500 requests a second.
How many products exist, and how many do our users track?
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.
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?
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.
If you spent the whole fetch budget visiting every product page in turn, how long would one full pass take?
- 500 million product pages
- At most 500 fetches a second
Show the working
- 500 × 86,400 = 43.2 million fetches a day
- 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.
Each observation is about 32 bytes. How much history does a year add if you store only the observations that change the price?
- About 73 million observations a day
- About 32 bytes each
- 1 in 10 observations is a price change
Show the working
- Every observation: 73,200,000 × 32 B ≈ 2.3 GB a day, ≈ 855 GB a year
- 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.
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
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 installThe 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.
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
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 INTHistory 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.
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
request and replyasynchronous message
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.
- 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.
- 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.
- 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.
- 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.- 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.
- 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_seenmoves, and no event. P-77's next fetch is pushed back 2 hours.- 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.
- 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.
- 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.
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:
| Tier | Products | Interval | Fetches a day |
|---|---|---|---|
| Hot: most trackers, or an alert within 5% of the price | 1 million | 2 hours | 1M × 12 = 12 million |
| Tracked | 19 million | 1 day | 19 million |
| Untracked, for history and new products | 480 million | about 40 days | the 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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Trust every report | Most 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 fetches | No 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 default | Small 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'sfired_changecolumn 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 dies | What happens | What you do |
|---|---|---|
| The site starts blocking fetches | Fetch 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 layout | The 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 service | Fetches and reports pile up. | Observations wait in the log; the service catches up from its last offset. |
| The alert matcher | Alerts are late, not lost. | Change events wait in the log; on restart it resumes from its last committed offset. |
| The notification system | Matched alerts cannot be sent. | Keep the alerts armed until sending succeeds; the idempotency key makes retries safe. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | A cron job fetches every tracked product, stores every price, and compares each with every alert. |
| Mid | Works out that the budget cannot cover the catalogue, prioritises tracked products, stores history keyed by product and time, and sends alerts through a queue. |
| Senior | Treats 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. |
How do you use the extension's reports?
They are real prices users saw, so I write them into history like the crawler's and run alerts on them.
How do you use the extension's reports?
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.
Doesn't that delay real flash sales?
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.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".