Price Alert System: Notifying People About Prices You Cannot Promise
Price alert infrastructure — polling strategies, trigger thresholds, alert fatigue, and the honesty problem when the price moves after you alert.

A price alert is a small feature with an awkward property: it is a message about a number the platform does not control, sent to a person who will act on it. Everything difficult about the feature comes from that — the polling cost, the false-positive rate, the deduplication, and the fact that the price is frequently different by the time the user acts.
The scale
a travel / ecommerce price alert service
2M active alerts
- 40% flights
- 30% hotels / accommodation
- 20% products
- 10% other
naive polling
2M alerts x hourly
= 48M source queries per day
against rate-limited third-party
APIs that allow maybe 1 req/s
so
2M alerts x daily
= 2M queries/day
still too many for most providers
The arithmetic is the first thing that decides the design. You cannot poll everything, and the polling that matters is the polling of the cheapest possible thing — a route’s lowest fare, a product’s current price — not the availability check a booking page would do. Those are different systems, and conflating them is a common and expensive mistake.
Event-driven, with polling as a backstop
the good design
1. PRICE EVENTS
- providers push price changes
(webhooks, feeds, sitemaps)
- the alert service subscribes
- on a price event, look up which
alerts match that route/product
and evaluate them
- cost: proportional to actual
price changes, not to alert count
2. POLLING BACKSTOP
- poll only the alerts that have
not received an event in a
long window
- or poll by "popularity bucket":
the top 1% of alert routes get
15-min polling, the tail gets
daily
- and a hard rule: a polling
backstop must respect the
provider's rate limit, shared
across all users of that route
3. RECOMPUTATION ON ALERT EVALUATION
- never store a price as if it were
live; store the last observed
price WITH a timestamp and an
age
The key structural insight is that alert evaluation should be indexed by what changed, not by who is subscribed. When a price event arrives for SFO-LIS on a date, the system needs to find the alerts for that route in O(1)-ish, not scan 2M alerts. That means a real index on the alert’s canonical key, and it means the alert key must be canonical — the same route expressed consistently regardless of how the user typed it, which is its own problem (airport codes vs city names, date ranges, cabin class folded into the key or not).
Polling by popularity bucket is the pragmatic bridge for providers that have no push. The tail of alerts is mostly people who set an alert and check the page anyway; polling them hourly is a waste of a rate-limited budget that the head of the distribution would use better.
The alert key, and why it is harder than it looks
what the user says
"notify me when flights from SFO
to Lisbon are under $400"
what the system must key on
origin: SFO (and possibly SJC, SFO
region?)
destination: LIS
date range: any date in June
cabin: economy (or any)
max price: 400
currency: USD
and possibly: nonstop only,
number of passengers, trip length
the canonicalisation problem
- "SFO" vs "San Francisco" vs
"SFO area" -> a region, not an
airport
- a date range alert matches any
date in the range, which is a
different query shape from a
fixed-date alert
- "under $400" with a stale FX
rate is a different number
- the provider prices a route, and
the alert is on a market
The practical resolution is to key on the same canonical entity the search uses — the same origin/destination/date/cabin/currency/pax tuple — and to make the alert evaluation a filter over that. The alternative, keying on the user’s literal typed string, produces an alert system that cannot match the search results and a support queue full of “the alert says under $400 but the page shows $410”.
An alert on a date range is fundamentally a different query, and it should probably not be built as N single-date alerts unless the range is small, because the price of a route is per-date and a “June under $400” alert is a subscription to an event that may not exist. Building it as an aggregate alert (cheapest fare in range, with the date) is more useful to the user and cheaper to evaluate.
Trigger design, which is a false-positive problem
the naive trigger
if current_price < max_price
and previous_price >= max_price
-> alert
the problems
1. a price that sits just under the
threshold oscillates across it
-> 6 alerts in an hour
2. a "threshold crossing" alert on a
route that has been under $400
for a month tells the user
nothing they did not know
3. users set $400 as a rough target,
not a hard line
the better triggers
- THRESHOLD CROSSING with
hysteresis: alert when the price
drops below the threshold AND is
at least X% below the last alert
- NEW LOW: alert when the price is
lower than any price the user has
seen for this alert
- SIGNIFICANT DROP: alert on any
drop > Y% (default 10%) within
a cooldown window
- PRICE vs HISTORY: alert when
current is meaningfully below the
typical price for this route/date
(a percentile, not an absolute)
cooldown
no more than one alert per alert
per COOLDOWN window
default 6h, user-configurable
The hysteresis idea is the important one: a threshold plus a required drop since the last alert means a price hovering at $399 against a $400 target does not produce a stream of notifications. The cooldown is a hard backstop, and it should be a real user-facing setting because some people want alerts hourly and some want weekly.
New low is the trigger that users actually value, and it is more honest than a threshold crossing. “This is the cheapest this route has been in the last 90 days” is a fact. “It’s under your threshold” is a fact that may be stale. Where price history exists, base the trigger on a percentile of the route’s recent distribution and let the message say “typical price for this route is $480, this is $290” — which is both a better notification and a better reason to act.
The message, and the honesty problem
the moment after the alert
t0 alert sent: "SFO-LIS now $320"
t+4min user clicks through
t+4min search page shows $395
-> the user now distrusts alerts,
and the feature is dead
causes
- the price moved back (fares do
this constantly)
- the alert used a cached/stale price
- the alert price was for a
different bucket than the page
(dates, cabin, pax, one-way
vs return)
- the alert showed the cheapest
date in a range; the page opened
on a different date
the mitigations, in order of value
1. make the alert price FRESH
(re-verify on send, or bound the
age visibly)
2. make the alert DEEP-LINK to the
exact result that was priced
(same date, same cabin, same
pax) — not to a generic search
3. show the age of the price IN the
message when it is more than a
couple of minutes old
4. do not use the cheapest-in-range
price for a single-date alert
Deep-linking to the exact result is the single highest-value fix and it is a product decision, not an engineering one: the alert must land on the itinerary it quoted. If the alert was “cheapest date in June is $320 on the 14th” and the landing page is a generic search, the user will do work to find the cheap date and may find it gone.
Freshness on send is the second. If a price event arrived 40 seconds ago the alert can honestly say “just now”; if the last observation is from this morning, the message should say “based on this morning’s price” or not fire at all. An alert system that sends confidently stale prices is worse than one that is occasionally late, because the lateness is visible and the staleness is not.
Deduplication and idempotency
the delivery problem
price event fires an alert
-> 500k users have an alert on
this route
-> send 500k notifications
-> but:
- many of those users have the
app uninstalled / email bounced
- some have already been notified
for this event (dedupe by
(alert_id, event_id))
- the send must be idempotent so
a retry does not double-send
- the trigger must be evaluated
ONCE per event, not once per
delivery attempt
the layering
price event -> alert fan-out
-> per-alert trigger evaluation
-> (dedupe: alert_id + event_id)
-> message build (deep link,
price history, age)
-> delivery (push/email/SMS)
-> delivery status feedback
(bounce, uninstall) ->
suppress
the feedback loop is the important part
an alert with a 60% bounce rate is
costing you deliverability (esp.
for email) and telling you nothing
about price
-> suppress hard-bounced channels
-> prefer push
-> and the "user disabled this
alert" signal is the strongest
quality signal the system has
The fan-out is a classic queue-and-worker design, and the two requirements that people get wrong are idempotency on send (with the dedupe key being (alert_id, event_id) or (alert_id, price_event_id)) and evaluation happening once per event rather than once per retry. The delivery-status feedback is what keeps the system honest over time: a suppressed list of dead channels, a preference store, and an explicit “this user turned it off” signal that stops the fan-out.
Fan-out sizing and rate limits
a big price event
500,000 alerts match
- this is not a "send 500k
notifications" situation
- it is a "stagger this" situation
the practical approach
- prioritise by user engagement
(recently active users first)
- stagger over minutes/hours
- respect per-user frequency caps
(no more than N price alerts
per user per day)
- if a single event would notify
more than X% of active users,
consider whether the message is
even useful, or just a site-wide
"prices just dropped" banner
The per-user daily cap is important and easy to forget. A genuine “everything just got cheaper” event can match alerts across the entire user base, and a user who receives 12 alerts in an hour has learned that alerts are noise. The frequency cap, applied per user, is what protects the feature from its own success.
Failure stories worth testing
Poll every alert hourly against a rate-limited provider
The provider bans you, and every alert stops firing. This is the scaling failure and it is the reason for event-driven.
Send the same alert for the same price event twice (retry without idempotency)
Duplicate notifications. Users who receive two turn off the feature.
Let a price oscillate across a user’s threshold and watch the alert volume
Cooldown plus hysteresis. Without them, the alert is a spam generator.
Send an alert for a range’s cheapest date, deep-link to a generic search page
Measure the click-to-book rate. The user has to re-find the cheap date; the alert’s value leaks.
Quote a price from 3 hours ago with no age shown
Measure post-click reverts. The user arrives and the price is different, and the feature loses them.
Alert on a price that includes a stale FX rate
The number is wrong relative to the page. Currency is part of the alert key and the alert evaluation.
Create an alert for “SFO to Lisbon” typed as a city while search keys on LIS
Measure match rate. Canonicalisation failures are silent and look like “alerts just don’t work”.
Unsubscribed 30% of a user base and check the quality of what remains
The remaining users are the ones who want alerts, so precision goes up — which is why unsubscribes should feed the trigger tuning, not just the user list.
Trigger a site-wide price drop and send 500k notifications
Compare engagement against a single banner. The flood is not the win it looks like.
Have a user’s email hard-bounce and keep alerting
Deliverability damage, on the email channel, for a user who will never read it.
Alert on a price that is only available for a 4-hour fare bucket
The user books and the fare is gone. Show fare-validity windows where you know them.
A production-ready architecture
price sources
- provider webhooks / sitemaps
- scheduled polling by
popularity bucket
|
v
+----------------------------------------------------------+
| PRICE STORE |
| - current price WITH timestamp and age |
| - price history per canonical key (percentiles) |
| - never a price without a time |
+----------------------------+-----------------------------+
|
v
+----------------------------------------------------------+
| ALERT STORE |
| - canonical key (route/product, date, |
| cabin/pax, currency, threshold) |
| - trigger config, cooldown, frequency cap |
| - suppressed channels, preferences |
+----------------------------+-----------------------------+
|
v
+----------------------------------------------------------+
| TRIGGER EVALUATION (on event, once per event) |
| - threshold crossing + hysteresis |
| - new-low vs price history percentile |
| - significant drop |
| - cooldown + per-user daily cap |
| - freshness check before send |
+----------------------------+-----------------------------+
|
v
+----------------------------------------------------------+
| FAN-OUT (staggered, priority by engagement) |
| - idempotent: dedupe on (alert_id, event_id) |
| - deep link to the EXACT result that was priced |
| - message includes price age + typical price |
+----------------------------+-----------------------------+
|
v
+----------------------------------------------------------+
| DELIVERY + FEEDBACK |
| push / email / SMS, per-channel |
| - delivery status, bounce, uninstall suppression |
| - user turned-off signal (strongest quality signal) |
+----------------------------+-----------------------------+
watch: alert -> click -> book conversion, unsubscribe rate,
duplicate rate, bounce rate, post-click revert rate
Delivery checklist:
- Drive evaluation from price-change events indexed by canonical key, and use polling only as a backstop with a shared per-route rate budget.
- Canonicalise the alert key to match the search key exactly — origin region, destination, dates, cabin, pax, currency — so an alert can never miss its own search results.
- Build the trigger around hysteresis and cooldown, and add a per-user daily frequency cap, because a threshold crossing alone is a spam generator.
- Use new-low and percentile triggers where price history exists; they are more useful and more honest than a raw threshold.
- Re-verify freshness before send and show the price age in the message when it is more than a couple of minutes old.
- Deep-link the alert to the exact result that was quoted, not to a generic search page.
- Evaluate the trigger once per price event, and dedupe delivery on (alert_id, event_id) so retries cannot double-send.
- Stagger large fan-outs by engagement priority instead of sending the whole base at once, and reconsider any event that matches most active users.
- Consume delivery status and suppress hard-bounced channels; treat “user turned it off” as the highest-value quality signal you get.
- Feed unsubscribes into trigger tuning, not just the suppression list — the users who leave tell you the trigger is too loose.
- Handle date-range alerts as an aggregate (cheapest date in range) with the date in the message, not as N single-date alerts.
- Measure the full funnel (alert → click → book) plus the post-click revert rate, because a high click rate with a high revert rate is a feature that is about to lose its users.
- Include currency in the key and evaluate at a fresh FX rate; a stale-rate alert is a wrong number.
- Bound the polling rate per route against the provider’s limit, shared across all users of that route, so a popular route cannot exhaust the budget.
Common mistakes
| Mistake | What actually happens | Better decision |
|---|---|---|
| Polling every alert on a schedule | Rate limits exhausted, alerts stop firing | Event-driven with polling backstop |
| Threshold crossing with no hysteresis | Oscillating prices spam the user | Hysteresis plus cooldown |
| No per-user frequency cap | A big price event floods everyone | Daily cap, staggered by engagement |
| Generic deep link from the alert | User re-finds the cheap date, leaks value | Link the exact priced result |
| Stale price shown with no age | Post-click reverts, feature loses trust | Re-verify on send, show age |
| Alert key not matching search key | Alerts silently never match | Canonical key identical to search |
| Retry without idempotency | Duplicate notifications | Dedupe on (alert_id, event_id) |
| Trigger evaluated per delivery attempt | Same alert fires many times | Evaluate once per price event |
| No bounce/uninstall suppression | Deliverability damage, wasted fan-out | Consume delivery status, suppress |
| Range alert shown as a single date | The cheapest date is hidden | Aggregate with the date in the message |
| Stale FX in the alert number | Price differs from the page | Fresh FX, currency in the key |
| Ignoring unsubscribes as a signal | Trigger stays too loose forever | Feed unsubs into tuning |
| Judging by click rate only | High clicks, high reverts, doomed feature | Measure the full funnel and revert rate |
| Sending 500k alerts on one event | Notification blindness | Coalesce into a banner sometimes |
| No fare-validity context | User books and the fare is gone | Show validity windows where known |
The complete story in one minute
A price alert is a small feature with one awkward property: it is a message about a number the platform does not control, sent to a person who will act on it. Almost all of the difficulty follows from that. The polling arithmetic is the first constraint — 2M alerts hourly is 48M queries a day against rate-limited third-party APIs — so alerts should be driven by price-change events indexed by canonical key, with polling as a backstop budgeted per route and shared across users, because a popular route must not exhaust the budget. And the alert key must be identical to the search key (origin region, destination, dates, cabin, pax, currency), because the most common silent failure in this product is an alert that never matches its own search results.
Trigger design is a false-positive problem, not a threshold problem. A price hovering at $399 against a $400 target will produce six notifications an hour with a naive crossing trigger, and hysteresis plus a cooldown plus a per-user daily cap is what stops the feature from becoming the thing it exists to replace. Where price history exists, new-low and percentile triggers beat raw thresholds, because “cheapest this route has been in 90 days” is a fact and “under your threshold” may be stale.
Then the honesty problem, which is the part that determines whether alerts are trusted at all: re-verify freshness before send, show the price’s age in the message, and deep-link the alert to the exact result that was quoted rather than a generic search page. Nothing destroys a price-alert feature faster than a user who clicks through, sees a different number, and concludes the whole thing is a lie. And the delivery side needs idempotency on (alert_id, event_id) — evaluation once per event, never per retry — plus delivery-status feedback and a per-user frequency cap, because a genuine “everything just got cheaper” event can match your entire base at once and a user who receives twelve alerts in an hour has learned that alerts are noise.
event-driven evaluation keyed by canonical route, polling as a budgeted backstop
hysteresis + cooldown + per-user cap; new-low and percentile triggers
re-verify on send, show price age, deep-link the exact priced result
idempotent on (alert_id, event_id); coalesce huge events
measure the whole funnel including post-click revert
The hard part was never the threshold. It was that the moment you send the message, you have made a promise about a number that the market is about to change, and the design is mostly about how honestly you can carry that promise.
What this team still owns
An alert is an observation, not a held offer. Store the exact product/itinerary key, seller, currency, mandatory-price components, eligibility, observed time, source freshness, and comparison baseline with every notification. Fetching again at click time may legitimately differ; the landing page should show that change instead of silently substituting another product. Deduplication keys belong to the alert condition and observation version, not merely the user and route.


