← All writing
articleAug 18, 202519 min read

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.

NotificationsPricingArchitectureData
Price Alert System: Notifying People About Prices You Cannot Promise cover illustration

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.

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:

  1. Drive evaluation from price-change events indexed by canonical key, and use polling only as a backstop with a shared per-route rate budget.
  2. 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.
  3. Build the trigger around hysteresis and cooldown, and add a per-user daily frequency cap, because a threshold crossing alone is a spam generator.
  4. Use new-low and percentile triggers where price history exists; they are more useful and more honest than a raw threshold.
  5. Re-verify freshness before send and show the price age in the message when it is more than a couple of minutes old.
  6. Deep-link the alert to the exact result that was quoted, not to a generic search page.
  7. Evaluate the trigger once per price event, and dedupe delivery on (alert_id, event_id) so retries cannot double-send.
  8. Stagger large fan-outs by engagement priority instead of sending the whole base at once, and reconsider any event that matches most active users.
  9. Consume delivery status and suppress hard-bounced channels; treat “user turned it off” as the highest-value quality signal you get.
  10. Feed unsubscribes into trigger tuning, not just the suppression list — the users who leave tell you the trigger is too loose.
  11. Handle date-range alerts as an aggregate (cheapest date in range) with the date in the message, not as N single-date alerts.
  12. 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.
  13. Include currency in the key and evaluate at a fresh FX rate; a stale-rate alert is a wrong number.
  14. 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.

Technical references

Keep reading
Browse everything