Guide

Designing Reliable Saved Searches and Listing Alerts

How to build saved searches and listing alerts that do not miss, duplicate or misfire: storage, matching, dedupe, scheduling, unsubscribe and delivery.

Cover: a saved search and a listing change meeting in a matcher that feeds an instant alert and a daily digest.

Five ways a listing alert fails

Saved searches look like a small feature: store the filters, email the user when something new matches. The failures show up after launch.

  • The missed alert. A home matched, and the buyer heard about it from another site. Usually the change was processed while the matcher was down, or the listing came to match through a price drop and only new listings were being checked.
  • The duplicate. The same home arrives twice, because a job was retried, because two of the user's searches matched, or because two MLSs carry the same property.
  • The 3 a.m. alert. The sync ran overnight and the notification went out the moment it finished, in the server's time zone.
  • The alert for a home that is gone. The digest was assembled in the morning from matches made the day before; the listing went under contract or was withdrawn in between.
  • The alert that cannot be stopped. The unsubscribe link needs a login, or stops one search while four others keep sending.

A reliable alert system produces exactly one notification per qualifying change per user, at a time that user would accept, about a listing that may still be shown, to someone who still wants it. Each part of that sentence is a component. Search itself is covered in designing a real estate search engine with Elasticsearch, and the sync that produces the changes in the MLS data pipeline architecture guide.

The flow from a listing change to a delivery status

Saved-search matching and alert delivery flowA listing change from the sync pipeline goes to a matcher, which reads stored saved-search criteria and finds the searches the listing matches. A dedupe step keeps one alert per search, listing and change, and the result is stored as an alert event. A scheduler then decides between instant and digest sending using the user's time zone and quiet hours. A send-time gate checks that the listing is still displayable and the user still subscribed. The channel sends by email, push or SMS with an idempotency key, and the delivery status is recorded. Bounces, complaints and unsubscribes feed a suppression list that the gate reads.MATCHINGDELIVERYListing changenew, price or statusfrom the sync pipelineMatcherwhich saved searchesmatch this listing?Dedupeone alert per search,listing and changeAlert eventsstored beforeanything is sentSaved searchesstructured criteriaunique key: search +listing + event + versionSchedulerinstant or digest;time zone, quiet hoursSend-time gatelisting still displayable?user still subscribed?Channelemail, push or SMS;idempotency key per sendDelivery statusdelivered, delayed,bounced, complainedstops: removed listing,license scope, opt-outbounces, complaints and unsubscribes update the suppression list the gate reads
Matching runs left to right on the top row; delivery runs right to left on the bottom row. The gate is checked at send time, not at match time.

The same flow as a list:

  1. Listing change. The sync pipeline reports that a listing is new, changed price or changed status.
  2. Matcher. It reads the stored saved-search criteria and finds the searches this listing matches.
  3. Dedupe. A unique key of search, listing, event type and change version allows one alert per change.
  4. Alert events. The match is stored before anything is sent.
  5. Scheduler. It decides between instant and digest, using the user's time zone and quiet hours.
  6. Send-time gate. It checks that the listing is still displayable, the license allows it, and the user has not opted out or been suppressed.
  7. Channel. Email, push or SMS, each send carrying an idempotency key.
  8. Delivery status. Delivered, delayed, bounced or complained. Bounces, complaints and unsubscribes update the suppression list the gate reads.

Storing a saved search: criteria, not a URL

The shortcut is to save the search page's URL. It works until the filter UI changes: a parameter is renamed, price steps change, a property type is split in two. Every stored URL then means something slightly different or nothing at all, and there is no way to query "which saved searches include condos".

Store the search as structured criteria: a document with typed fields for price range, beds, property types, status set, area (a named area ID or a GeoJSON polygon) and keywords. The RESO Data Dictionary has a resource for the idea, SavedSearch, defined as saved search criteria and related data. Four properties make the stored form hold up:

  • A criteria version. Each document records the schema version it was written in. When filters change, a migration rewrites old documents or a reader upgrades them on load.
  • Canonical form. Sort arrays, drop empty fields, normalize units. Two users who saved the same search then have byte-identical criteria, and a hash of it lets the matcher evaluate the search once for both.
  • Absolute where it matters. "Listed in the last 7 days" stays relative. A drawn polygon is stored as coordinates, not as a map viewport.
  • Separate from preferences. What to match is one thing; how and when to notify (frequency, channel, paused) is another, and changes more often.

The URL for the "view results" link is generated from the criteria. It is an output, not the record.

An illustrative schema

Illustrative: three tables in PostgreSQL syntax, trimmed to the columns discussed in this article.

CREATE TABLE saved_search (
  id               uuid PRIMARY KEY,
  user_id          uuid NOT NULL,
  name             text NOT NULL,
  criteria         jsonb NOT NULL,          -- structured filters, canonical form
  criteria_version int  NOT NULL,           -- schema version of "criteria"
  criteria_hash    text NOT NULL,           -- identical searches share one evaluation
  frequency        text NOT NULL,           -- 'instant' | 'daily' | 'off'
  channel          text NOT NULL,           -- 'email' | 'push' | 'sms'
  paused_at        timestamptz,
  created_at       timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE alert_event (
  id               uuid PRIMARY KEY,
  saved_search_id  uuid NOT NULL REFERENCES saved_search (id) ON DELETE CASCADE,
  user_id          uuid NOT NULL,
  listing_key      text NOT NULL,           -- originating system + key
  property_group   text,                    -- same home listed in two MLSs
  event_type       text NOT NULL,           -- 'new_match' | 'price_change' | 'status_change'
  change_version   bigint NOT NULL,         -- the listing version that caused it
  payload          jsonb NOT NULL,          -- old and new values needed to render
  matched_at       timestamptz NOT NULL DEFAULT now(),
  state            text NOT NULL DEFAULT 'pending',  -- 'pending' | 'sent' | 'dropped'
  dropped_reason   text,
  UNIQUE (saved_search_id, listing_key, event_type, change_version)
);

CREATE TABLE delivery (
  id               uuid PRIMARY KEY,        -- also used as the idempotency key
  user_id          uuid NOT NULL,
  channel          text NOT NULL,
  kind             text NOT NULL,           -- 'instant' | 'digest'
  alert_event_ids  uuid[] NOT NULL,
  scheduled_for    timestamptz NOT NULL,
  attempts         int NOT NULL DEFAULT 0,
  provider_id      text,
  status           text NOT NULL DEFAULT 'scheduled'
                   -- 'scheduled' | 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained' | 'canceled'
);

Change detection: the sync pipeline decides what happened

An alert begins with a statement like "this listing's price went from 700,000 to 640,000" (an illustrative figure). Only the sync pipeline can make it, because only it sees the record before and after. So when the pipeline writes a listing, it compares the stored version with the incoming one and emits a typed event.

Feeds do carry change fields, and it is tempting to rely on them. The RESO Data Dictionary defines ModificationTimestamp as the date/time the listing was last modified, PriceChangeTimestamp as the time the listing's price was last changed, and StatusChangeTimestamp likewise for status. The dictionary's own adoption figures, as generated on October 12, 2026, show why they cannot be the only signal: 100% of the organizations it lists provide the modification timestamp, but 92% the price change timestamp, 70% the status change timestamp and 64% the previous list price. A modification timestamp alone says that something changed, which includes a corrected typo or a reordered photo. Your own before-and-after comparison works for every feed.

Rules we would build into the detector:

  • Events are typed and carry both values. New listing, price change (old and new), status change (old and new). Decide which transitions matter: "back on market" is news; "active to pending" is news to someone who saved the home, not to someone searching.
  • Bulk operations are silent. An initial load, a backfill, a re-normalization or a search reindex touches every record. None of those is a change in the market. Tag pipeline runs and emit events only from incremental sync, or the first rebuild sends every user every listing.
  • The event follows the commit. Emit after the listing is written, and let the matcher read the committed record, so an alert never links to a page still showing the old price.
  • Each event has a version. A monotonically increasing version per listing (the feed's modification timestamp works) identifies the change. Everything downstream uses it to recognize a repeat.

Matching: re-run the searches, or reverse-match the listing

There are two ways to find out which saved searches a change concerns, and they scale in opposite directions.

Re-running each saved search on a schedule

Every N minutes, for each saved search, run its query with an extra condition, "changed since the last run", and record the hits. It reuses the search engine you already have and is easy to reason about. Its cost is proportional to the number of saved searches, whether or not anything changed, and its latency is the schedule interval. Grouping by criteria hash lets identical searches share one evaluation.

Reverse matching each changed listing

Turn the question around: store the searches as queries, and ask of each changed listing, which of these match? Elasticsearch has a field type for it: the percolator field parses a json structure into a native query and stores that query, so that the percolate query can use it to match provided documents; the percolate query can be used to match queries stored in an index. Cost is now proportional to the number of changes, and a match can be known soon after the sync writes the record. The documentation adds details that shape the design:

Reverse matching does not require a search engine. For criteria that are mostly ranges and sets, a relational table of saved searches with indexed columns can be queried with the listing's values as parameters. Keywords and polygons are where the percolator becomes the simpler tool.

Scheduled re-runReverse matching
Work grows withNumber of saved searchesNumber of listing changes
LatencyThe schedule intervalShortly after the sync writes the change
Extra moving partsNone beyond the search you haveA stored-query index kept in step with the listing mapping and the criteria
Detecting "now matches after a change"Needs a record of what was already alertedEvaluate the old and new versions and compare
FitsDaily digests; a modest number of saved searchesInstant alerts; many saved searches; frequent sync

We would run both: reverse matching for instant alerts, and a nightly scheduled pass as a safety net that catches anything the event path missed and writes it through the same dedupe key.

What "matches" means when a listing changes

The subtle case is a change that moves a listing into or out of a search. In the example above, a search with a maximum of 650,000 gains a result. For that user this is not a price change on a home they knew about; it is a new match. The matcher therefore evaluates both versions of the listing:

  • Matches now, did not before: a new match, whatever the underlying event was.
  • Matched before and still matches, and the price or status changed: a change on a known match.
  • Matched before, does not now: nothing to send, but pending alerts for it should be reconsidered at the gate.

Illustrative pseudocode for the matcher:

// Illustrative pseudocode. Runs once per batch of committed listing changes.
function matchBatch(changes):
  for change in changes:
    if change.run_type != "incremental":        // initial loads and rebuilds are silent
      continue

    listing_now    = change.after
    listing_before = change.before               // null for a brand-new listing

    if not displayable(listing_now):             // removed, opted out, outside license scope
      dropPendingAlertsFor(listing_now.key)
      continue

    now    = searchesMatching(listing_now)       // percolate, or indexed lookup
    before = {}                                  // a listing nobody could see matched nothing
    if listing_before and displayable(listing_before):
      before = searchesMatching(listing_before)

    for search in now:
      if search.paused or search.frequency == "off":
        continue

      if search not in before:
        type = "new_match"
      else if change.kind in ("price_change", "status_change"):
        type = change.kind
      else:
        continue                                 // matched before, nothing newsworthy changed

      // The unique key makes this safe to run twice.
      insertIgnoreDuplicate(alert_event,
        saved_search_id = search.id,
        user_id         = search.user_id,
        listing_key     = listing_now.key,
        property_group  = listing_now.property_group,
        event_type      = type,
        change_version  = change.version,
        payload         = { before: summary(listing_before), after: summary(listing_now) })

Deduplication at three levels

Duplicates have three different causes and need three different guards.

  • The same work done twice. A job retried after a timeout, a webhook delivered again, the nightly safety net finding what the event path already found. The unique constraint on (search, listing, event type, change version) turns the second insert into a no-op, which is why the event is stored before anything is sent. Queue-level retries and idempotent jobs are covered in background jobs with Redis and BullMQ.
  • The same home, several reasons. One user has three searches that all match, or the same property is listed in two MLSs. The user should get one mention. Collapse at assembly time: group a user's pending events by property group (or listing key where there is no group), keep one, and list the matching search names under it. Both listing records stay in your data; only the notification is merged.
  • The same home, changing repeatedly. A price corrected twice in an hour, a status that flips and flips back. For a digest, show the net change from what the user last saw to the current state, and nothing at all if they are equal. For instant alerts, a short per-listing cool-down prevents a burst.

Scheduling: instant, digest, time zones and quiet hours

Matching answers what; scheduling answers when. Keep them apart, so that a matcher that runs at 03:00 cannot send anything at 03:00.

  • Frequency is per search. A buyer may want instant alerts for one narrow search and a daily summary for a broad one. "Instant" still passes through the scheduler; it means "as soon as allowed".
  • Store a time zone name, not an offset. A fixed offset is wrong for part of the year wherever daylight saving applies. Store the IANA zone name (for example America/Chicago) on the user and let the database or library do the conversion; PostgreSQL, for one, uses the widely-used IANA (Olson) time zone database and notes that zone rules are prone to arbitrary changes, particularly with respect to daylight-savings rules. Default the zone from the market being searched when the user has not set one.
  • Quiet hours hold, they do not drop. An instant alert matched during the user's quiet window is scheduled for the window's end. If several accumulate, send them as one message.
  • Digests are assembled at send time. At the user's local send hour, gather pending events, apply the three dedupe rules, re-read each listing's current state, and build the message from that. A digest built from yesterday's snapshot is how the alert for a home that is gone gets sent.
  • Empty means silent. No "no new listings today" message. And cap the total per user per day; a search that matches hundreds of changes should produce a summary with a link.
  • Spread the load. Everyone in a time zone choosing the same hour creates a spike for your sender and your site. Scheduling within a window around the chosen hour smooths it.

Preferences and unsubscribe

Listing alerts are mail a person subscribed to, and mailbox providers have rules for that. For senders of more than 5,000 messages a day to Gmail accounts, Google's guidelines say marketing messages and subscribed messages must support one-click unsubscribe, and include a clearly visible unsubscribe link in the message body. Its FAQ clarifies that transactional messages are excluded from this requirement, with password resets and confirmations as examples; an alert about new listings is not in that group, so we would treat it as subscribed mail from the start. Yahoo asks bulk senders for a functioning list-unsubscribe header, which supports one-click unsubscribe for marketing and subscribed messages.

The mechanism is defined in RFC 8058:

Deadlines differ by source, so build to the shortest. The FTC's CAN-SPAM compliance guide for business states that for the commercial email the Act covers, you must honor a recipient's opt-out request within 10 business days; whether your alerts are commercial messages under that Act, and what else it requires of them, is a question for counsel. Yahoo says to honor unsubscribes within 2 days, and Google's FAQ lists unsubscribe requests aren't honored within 48 hours among the conditions under which delivery support or mitigations are unavailable. With a send-time gate, the practical answer is immediately: the opt-out is a row the gate reads before the next send, including for digests already scheduled.

Design choices that follow:

  • What the header unsubscribes from. We would make the one-click header stop all listing-alert email for that address; a person who unsubscribes and then receives an alert from a different saved search is likely to report spam. Finer control belongs on the preference page linked in the body.
  • Preferences without a login. The body link opens a page, authorized by the same kind of token, where each search can be paused, switched to a digest or deleted. Yahoo asks for an obvious and visible unsubscribe process that doesn't require users to log in.
  • Opt-out is per channel and outlives the search. Deleting and recreating a search must not re-subscribe an address that opted out. Push and SMS have their own consent rules, which this article does not cover.
  • Agent-created searches. Where an agent sets up a search for a client, the client is the recipient, and we would let the client's opt-out win.

Delivery failures: bounces, suppression and safe retries

Handing a message to a provider is not delivery. Providers report what happened afterwards as events. Using Resend's documentation as one example of the vocabulary: a bounce means the recipient's mail server permanently rejected the email; a delivery delay means it couldn't be delivered due to a temporary issue; and there are separate events for complaints and for mail the provider suppressed. Map whichever provider you use onto those four categories.

What must stop an alert

Between a match and a send, minutes or a day can pass. The send-time gate re-checks everything that could have changed.

Condition at send timeActionWhy
The listing was removed from the feedDrop the eventOn MLS Grid, for example, when MlgCanView changes to false the record is no longer valid for the feed type you consume and must be removed from your local data store. We would treat a queued alert as a display of that record and drop it.
The listing's status is no longer one this search or this surface may showDrop, or convert to a status-change alert if the user saved that homeWhich statuses may be displayed, and to whom, is a question for your MLS agreement
The license for the feed does not cover this useNever match it in the first place; drop if it slips throughAn email is a display surface. Whether a given license type permits listing data in email to a registered consumer is for your MLS agreement to answer; check it per feed
The seller opted the listing or its address out of internet displayApply the same display policy as the websiteWe would apply the opt-out fields to what is sent as well as to what is served; confirm the rule with your MLS
The listing no longer matches the searchDropThe price went back up, or the user edited the search
The search is paused or deleted, or the account is closedCancel pending events and deliveriesDeleting a search should take effect on the next send, not after the queue drains
The address is unsubscribed or suppressedCancelThe deadlines in the previous section

An email, once sent, cannot be recalled, so keep the message thin: a photo served from your own storage, the headline facts, and a link to the live listing page. When a listing is later removed, the image can be withdrawn and the link leads to a page that returns the right status, as covered in handling sold, expired and deleted listings.

Signals to monitor

  • Match lag: time from a listing change being committed to its alert events being written.
  • Changes processed against changes emitted, per sync run; a gap is a missed alert.
  • Inserts rejected by the dedupe key; a sudden rise means something upstream is repeating.
  • Events dropped at the gate, by reason.
  • Alert events per run compared with a ceiling; a bulk operation leaking events trips it before users see it.
  • Deliveries by final status, with bounce and complaint rates per sending domain.
  • Unsubscribes per message sent, by search type; a spike points at a noisy alert.
  • Deliveries scheduled inside a user's quiet hours: expected to be zero.
  • Time from an unsubscribe request to the last message sent to that address: expected to be zero.

Frequently Asked Questions

Is the Elasticsearch percolator required for listing alerts?

No. Re-running each saved search on a schedule works for digests and modest volumes. The percolator suits instant alerts at scale because it reverses the lookup: it can be used to match queries stored in an index against each changed listing. It needs the listing mapping on the stored-query index, since fields referred to in a percolator query must already exist in the mapping.

Do listing alert emails need one-click unsubscribe?

Treat them as subscribed mail. Google requires that, for senders of more than 5,000 messages a day, marketing messages and subscribed messages must support one-click unsubscribe, and RFC 8058 defines the headers: a List-Unsubscribe header that MUST contain one HTTPS URI and a List-Unsubscribe-Post header.

What happens to a pending alert when the listing is removed?

We would not send it. Re-check every listing at send time, not only when it matched. On MLS Grid, for example, a record whose MlgCanView becomes false must be removed from your local data store, and we treat an alert as a display of that record.

Sources were read on October 13, 2026: Elastic, RFC 8058, Google and Yahoo sender guidelines, the FTC guide, Resend, PostgreSQL, MLS Grid and the RESO Data Dictionary (whose pages were generated October 12, 2026). Canada's anti-spam rules, and the consent rules for SMS and push, were not checked and are not summarized. Sender requirements and MLS rules change; your MLS agreements decide what listing data may be sent to whom. This is engineering guidance, not legal advice. The schema and pseudocode are illustrative and were not executed.

Building saved searches and alerts on MLS data? See our MLS and IDX integration service. Every project starts with a 30-minute technical discovery call with an engineer.

About the examples. Every code sample here is illustrative. The schema was created in PostgreSQL 16 on October 13, 2026. The matcher is pseudocode and was not executed.

Technical consultation

Building Saved Searches and Listing Alerts?

Tell us how listings reach your platform and how alerts should behave. An engineer will walk through matching, deduplication, scheduling and the rules that must stop a send.

  • A 30-minute technical discovery call with an engineer, not a salesperson.
  • Fixed-scope projects, monthly retainers, or a combination, agreed after the call.
  • Small, well-defined tasks get a scoped estimate. We reply within 1 business day.

Prefer to pick a time? Book a call on Calendly

Tell us about your project

We'll review your details and reach out within 1 business day.

Book Strategy CallCase Studies