Price Alert
Design a price alert system: inverted-index matching against 50M alerts, a cooldown state machine that eliminates notification storms, and idempotent fan-out to email, push, and SMS.
What is a price alert system?
A price alert system watches prices across products and financial instruments and notifies users when a target threshold is crossed. The apparent simplicity hides a matching problem: when a price update arrives for one of millions of tracked items, which active alerts does it trigger? A naive scan collapses at scale. The design uses an inverted index to find candidates quickly, an event-driven dispatch pipeline, and a cooldown state machine so threshold oscillation does not create notification storms.
TL;DR
Persist alert definitions in PostgreSQL and mirror active thresholds into Redis sorted sets keyed by item and direction. Partition the price-update stream by item_id; the worker handling an item scans only the relevant threshold range, publishes trigger events, and updates the latest price.
A separate dispatcher loads alert and user details, deduplicates trigger events, and fans out to email, push, and SMS adapters. After firing, remove the alert from the active index and put it into cooldown; rearm only after the price no longer satisfies the condition. Treat Redis as a rebuildable index, Kafka as at-least-once, and provider delivery as retryable rather than assuming literal exactly-once behavior from external channels.
Scope and Assumptions
This design assumes:
- Each alert targets one tracked item, one threshold, one direction (
lteorgte), and one or more notification channels. - The price feed is authenticated, normalized, and ordered per
item_idby the ingestion pipeline. The internal price-ingest endpoint is not public. - The illustrative workload is 50M active alerts across 5M items and up to 50K price updates per second. Alert CRUD is a much smaller workload than evaluation.
- A trigger should be detected and queued within 60 seconds. Alert state and notification history may converge asynchronously, but a trigger must not be silently lost.
- Order execution, multi-condition rules, historical charts, social features, and high-frequency-trading price fidelity are out of scope; they can consume the event stream independently.
Functional Requirements
Core Requirements
- Users can create a price alert on any tracked product or stock ticker, specifying a target price and direction (price drops to or below target, or rises to or above target).
- Users receive a notification when the current price crosses their threshold.
- Users can choose which channels receive the notification: email, push notification, or SMS.
- Users can view, pause, and delete their active alerts.
Below the Line (out of scope)
- Order execution or automated trading once the alert fires.
- Complex multi-condition alerts (for example, "alert if price drops AND trading volume exceeds X").
- Price history charts, trend analysis, and historical price data.
- Social features such as sharing alerts or following other users' watchlists.
Order execution belongs to a trading system that sits downstream of the alert. Linking the two would couple notification delivery to financial settlement, which carries entirely different reliability and compliance requirements. To add it, the alert would publish an event to a trading engine asynchronously; the alert system itself stays stateless with respect to the trade.
Complex conditional alerts could be built by adding a rule evaluation layer in front of the current threshold check. Each price update would be run through a small expression evaluator. The data model would store a rule AST instead of a single target_price scalar. Deliberately deferred because it doesn't change the core ingestion and matching pipeline.
Price history is below the line because it doesn't change the ingestion or matching pipeline. To add it, capture every PriceUpdate event to a time-series DB (TimescaleDB or InfluxDB) on the write path. The alert matching pipeline reads only the latest price; the history reader would be a separate query path that doesn't touch the alert evaluation logic at all.
Social alert features don't interact with the evaluation pipeline. They'd live in a separate follow-graph service that aggregates public alert activity. The alert system would publish anonymized trigger events to a feed topic, and the social service would consume them independently. The matching and notification pipeline wouldn't change.
The hardest part in scope: When a price update for a single item arrives, the system must instantly identify which of potentially thousands of active alerts for that item to fire, then deliver each notification exactly once even if the price oscillates around the threshold for minutes.
Non-Functional Requirements
Core Requirements
- Notification latency: Alert fires within 60 seconds of a triggering price update. Most users accept near-real-time; sub-second is not required and would over-engineer the ingestion pipeline.
- Throughput: Ingest up to 50,000 price updates per second across all tracked items during peak trading hours. Supported without batching at the ingestion layer.
- Scale: Support 50 million active alerts across 5 million tracked items.
- Availability: 99.9% uptime for alert creation and management. A brief spike that delays notifications by a few minutes is tolerable; silently missing a triggered alert is not.
- Exactly-once delivery: Each alert fires at most once per trigger event. Duplicate notifications erode user trust faster than latency does.
Below the Line
- Sub-second notification delivery (would require a dedicated low-latency push path; unnecessary for this use case).
- Real-time price data fidelity for high-frequency trading (our 60-second SLA allows seconds of price lag from the feed).
Read/write ratio: Price updates arrive far more often than users create or modify alerts. For every alert creation, expect roughly 1,000 price updates across the platform. The evaluation pipeline is the hot path, so the system should optimize high-volume, low-latency matching rather than alert CRUD.
30-Second Answer
- Store alerts durably and mirror active
lteandgtethresholds into Redis sorted sets keyed byitem_id. - Ingest trusted price updates through Kafka partitioned by
item_id, so one worker serializes evaluation for an item. - Use a sorted-set range query to find only thresholds satisfied by the new price, then publish an
alert-triggerevent for each candidate. - Let a separate dispatcher deduplicate events and call channel adapters independently, so a slow email/SMS provider cannot block matching.
- Move fired alerts into cooldown, rearm only after price recovery, and rebuild Redis from active alerts if the index is lost. The key guarantees are no silent trigger loss and no duplicate notification storm.
5-Minute Explanation
The durable model contains User, Item, Alert, PriceUpdate, and Notification. Alert CRUD writes PostgreSQL and updates the Redis matching index. The index uses separate keys for directions, with target_price as the sorted-set score and alert_id as the member. This turns a price update into a range query over matching thresholds instead of a scan of every alert.
The ingestion path receives a normalized update, partitions it by item_id, stores the latest price, and evaluates the two direction-specific sets. The worker publishes a trigger event containing a stable event ID, alert ID, item, and triggering price. A separate notification dispatcher loads current channel preferences, atomically records the deduplication key, and sends to each selected adapter with retries and a dead-letter path.
The state machine prevents repeated firing. Once an alert fires, mark it triggered or cooling_down and remove it from the active sorted set. A rearm job checks the latest price after the cooldown and adds the alert back only if the threshold is no longer satisfied. For an exact crossing rule, retain the previous price and require a transition from unsatisfied to satisfied; for a level-triggered rule, document that every active match can fire once per cooldown.
The main scaling decision is to keep Alert DB off the hot evaluation path. Redis and Kafka handle matching and buffering; PostgreSQL records alert configuration and eventual status. The service must still protect the pipeline from forged prices, duplicate/out-of-order events, provider failures, Redis drift, and notification cost explosions.
45-Minute Interview Approach
Use this agenda to answer the design question and prioritize matching correctness, ordering, deduplication, and cooldown semantics:
- 0β5 minutes β Clarify the alert contract: Define crossing versus level-trigger semantics, one-shot versus recurring alerts, cooldown/rearm behavior, supported channels, price-source trust, and the 60-second freshness target.
- 5β10 minutes β Establish scale: Use 50M active alerts, 5M items, 50K updates per second, the 1000:1 update-to-alert-write ratio, item hot spots, and notification fan-out volume.
- 10β15 minutes β Define entities and APIs: Introduce Alert, Item, PriceUpdate, Notification, User, CRUD endpoints, and the internal price-ingest contract. State that the ingest endpoint is network-segmented.
- 15β22 minutes β Draw the baseline: Show a database scan per price update and quantify why it fails. Spend only enough time on the baseline to motivate the inverted index.
- 22β30 minutes β Draw matching: Add Redis sorted sets, separate directions, Kafka partitioning by
item_id, range scans, latest-price storage, and trigger events. Explain how boundary/crossing semantics are chosen. - 30β35 minutes β Draw dispatch and state: Separate channel adapters from evaluation, add deduplication, retries/DLQ, cooldown removal, and rearm after price recovery.
- 35β41 minutes β Cover reliability and security: Discuss Redis rebuilds, at-least-once replay, out-of-order data, provider outages, price-feed authentication, rate limits, PII, and SMS-cost protection.
- 41β45 minutes β Close with trade-offs: Compare SQL scans, sorted sets, and streaming state; one-shot versus recurring alerts; and regional placement by price source versus user. Recap the invariants and invite extensions.
Core Entities
- Alert: A user's price threshold for a specific item. Captures the target price, direction (lte or gte), preferred notification channels, and current firing state (active, triggered, paused).
- Item: A tracked product from a retailer catalog or a financial instrument (stock, ETF, crypto). The unit of price updates.
- PriceUpdate: The current price of an item at a point in time, sourced from a price feed. Ephemeral; only the latest price for each item needs to be retained outside the event log.
- Notification: A record of a dispatched notification message. Serves as the idempotency log; before firing, the system checks this table to prevent duplicate sends for the same alert trigger event.
- User: The account that owns alerts and holds channel credentials (email address, push token, phone number).
The primary relationship is User β Alert β Item. An item can have thousands of alerts from different users. Full schema and indexing decisions are deferred to the deep dives.
API Design
One endpoint per core functional requirement, grouped by the requirement it satisfies.
FR 1 and FR 3: Create a price alert with channel preferences:
POST /alerts
Authorization: Bearer {token}
Body: {
item_id: "AAPL",
target_price: 150.00,
direction: "lte",
channels: ["push", "email"]
}
Response 201: {
alert_id: "alrt_8fk2x",
item_id: "AAPL",
target_price: 150.00,
direction: "lte",
channels: ["push", "email"],
status: "active",
created_at: "2026-03-29T10:00:00Z"
}
direction: "lte" means "alert me when price falls to or below target". Using an explicit direction field (rather than inferring it from whether the current price is above or below target) makes the intent unambiguous and allows both "price drop" and "price recovery" alerts to coexist on the same item.
FR 4: List alerts (paginated):
GET /alerts?status=active&cursor=alrt_8fk2x&limit=25
Response 200: {
alerts: [...],
next_cursor: "alrt_9gm3y"
}
Cursor-based pagination over keyset on alert_id (ordered by created_at). Offset pagination collapses when rows are inserted between pages; cursor pagination is stable.
FR 4: Update an alert (pause, resume, modify target):
PATCH /alerts/{alert_id}
Body: { status: "paused" } // or: { target_price: 145.00 }
Response 200: { alert_id, status, target_price, ... }
PATCH over PUT because users typically update one field at a time (pause/resume or adjust threshold). A full PUT would require the client to re-send all fields.
FR 4: Delete an alert:
DELETE /alerts/{alert_id}
Response 204: (empty)
Hard delete. The alert is removed from the matching index immediately so no further notifications are evaluated. Notification history is preserved under the Notification entity.
Internal (price ingestion, write-only, not user-facing):
POST /internal/prices
Body: {
updates: [
{ item_id: "AAPL", price: 149.50, timestamp: "2026-03-29T10:01:33Z", source: "nasdaq_feed" },
{ item_id: "NVDA", price: 800.00, timestamp: "2026-03-29T10:01:33Z", source: "nasdaq_feed" }
]
}
Response 202: { accepted: 2 }
202 Accepted is correct here: the system acknowledges receipt but alert evaluation is asynchronous. The source feed gets a fast acknowledgment without waiting for match evaluation and notification dispatch. In practice this endpoint is an internal Kafka publish, not an HTTP call; showing it as HTTP makes the contract explicit for the interview.
High-Level Design
Critical flows
The critical flows are alert registration and index synchronization, price ingestion and threshold matching, notification dispatch, and cooldown/rearm. The numbered designs below separate the high-volume evaluation path from durable management and external channel delivery.
1. Users can create and manage price alerts
The write path: a user submits an alert, it lands in persistent storage, and the system is ready to match incoming price updates against it.
Components:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.