How it is built
Architecture overview
A listing portal shows homes for sale or rent on a map and in a list, filtered by price, size, features and place, and tells people when something new matches. The data is small by internet standards (listings, not billions of documents) but awkward: it comes from many sources with different fields, each listing changes status and price over time, every record is tied to an address that must be turned into a point, and the same property often arrives twice. The pipeline is ingest, clean, locate, index, serve. Sources, mostly MLS systems and broker feeds, are replicated incrementally; records are normalised and matched to one property; addresses are parsed and geocoded to a point and a cell; a search index handles words, facets and map boxes; the map is drawn from tiles and vector layers; photos are resized once and served from a CDN. The one idea that holds it together: the address-to-point step is the spine. A wrong point puts a house in the wrong neighbourhood, breaks duplicate detection and misplaces every valuation, so geocoding, property matching and the map all share one canonical place per property. Zillow and Rightmove are the closed examples; the data side has an open standard (RESO) and the geo side has open building blocks (PostGIS, Pelias, libpostal, Martin).
Parts:
- Feed sync — pull changes from MLSs and brokers without refetching everything — Incremental sync cursors
- Address pipeline — free text to normalised address to coordinates —
address-parsing(no page yet),address-geocoding(no page yet) - Property identity — one record per real property across sources — Entity resolution
- Location index — map boxes, polygons, "near this point" — Geospatial cell index (geohash, S2, H3), R-tree
- Search index — text, facets, sort — Inverted index,
secondary-indexes(no page yet) - Ranking and valuation — order results, estimate a price — Learning to rank, Gradient-boosted decision trees (GBDT),
quantile-regression(no page yet),kriging-spatial-interpolation(no page yet),loan-amortization(no page yet) - Saved-search alerts — find who wants a new listing, then tell them —
boolean-expression-indexing(no page yet),email-delivery-smtp(no page yet), Mobile push delivery - Map rendering — base map, pins, labels —
map-tile-pyramid(no page yet),vector-tile-encoding(no page yet),label-placement-collision(no page yet), CDN and edge - Photos — decode once, serve many sizes —
image-codecs(no page yet), Image resampling filters - Operations — Point-in-time recovery, Primary-backup replication, Process supervision, Graceful draining, Mutual TLS, Deterministic simulation testing, Coverage-guided fuzzing,
hdr-histogram(no page yet)
MLS / broker feeds ──► sync (cursor per source) ──► raw store ──► normalise ──► parse + geocode ──► property match
▲ (versions) (fields, (point, cell id) (one id per home)
│ next cursor status) │
▼
photos ──► decode ─► resize ─► object store ─► CDN listing store (primary + standby)
│ change stream
┌───────────────────────────────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
search index (words, facets, cell ids) valuation / ranking saved-search matcher
│ features + models │
user map pan ─► bbox → cells ─► query ─► rank ─► JSON + vector-tile pins ─► app email / push alerts
How the parts fit together
Feed sync
Listings are owned by MLSs and brokers. The industry standard for sharing them is the RESO Web API (OData v4) with
the RESO Data Dictionary naming the fields; it replaces the older RETS transport, which RESO calls deprecated
(source: RESO). RESO describes replication the way any mirror works: pull everything once, then keep asking for the
latest changes (source: RESO, Web API page). That is an Incremental sync cursor per
source: the portal stores one token (in practice the largest ModificationTimestamp seen, which the Data Dictionary
defines as the time the MLS system last modified the listing) and each pull returns only records after it. Two details decide whether
it is correct. The cursor must break ties: many records can share a timestamp, so it is the pair (timestamp, key) and
the next page starts strictly after it. And a record can commit late, so each pull starts a little before the stored
cursor and discards duplicates by version. Contract: every record is written to a raw, versioned store before
anything else touches it, so a bad parser can be re-run without asking the MLS again.
Address pipeline
Feeds carry structured fields in a good case and messy text in a bad one (a unit in the street line, "St" and
"Street", a postcode in the city field). address-parsing (no page yet) splits an address into house number, street,
unit, city, region and postcode with rules or a sequence model; libpostal is the best-known open parser. Then
address-geocoding (no page yet) normalises and matches it against reference data: address points, parcels, street
segments with interpolated numbers, postcode centroids, in that order of precision. The result carries a
precision class: rooftop, interpolated, street, postcode. Everything downstream reads that class, because a
postcode centroid is fine for a region search and wrong for a pin. Parsing and fuzzy matching use the same text
tools as search, and the reference data sits in an Inverted index of address tokens keyed to the
cell index. The output is a point, a Geospatial cell index (geohash, S2, H3) id and the
precision class.
Property identity
One home is listed by an agent, relisted after a price drop, syndicated by a portal and sold later. Entity resolution decides which records are the same property: block on geocoded address or parcel id plus unit, score pairs on address similarity, size, bedrooms and photos, and cluster. Why this matters more here than in most systems: price history, days on market and valuations all hang off the property id, not the listing id. Contract: a listing keeps its source id; the property id is an assigned cluster, and merges and splits are logged so a wrong merge can be undone.
Location index
Two location queries dominate. A map box (the viewport) is a set of cell ranges in the same sorted store as the other fields, because the cell hierarchy turns a rectangle into a few integer intervals; coarser cells give the cluster counts shown when zoomed out. A polygon (school zone, neighbourhood, a user-drawn boundary) needs a different structure: an R-tree over the polygons' bounding boxes finds candidate regions for a point quickly, and an exact point-in-polygon test finishes. Which to use is simple: cells for "points in a window", an R-tree for "which regions contain this point". Region membership is stored on the listing as ids at ingest, so "in school zone X" is a facet and not a query-time computation.
Search index and facets
The search index is an Inverted index whose terms include words (description, features), facet values
(property type, bedrooms bucket, status), the cell ids at several resolutions and the region ids. Numeric ranges
(price, area, year) use range structures over sorted values. Keeping facet counts, sort keys and the primary record
consistent is the job of secondary-indexes (no page yet): when a price changes, the old index entries must go
and the new ones must come, in one transaction on the store, or the facet counts drift. The index is a
projection of the listing store's change stream, rebuilt from it on demand. Status is the hard field: "sold" or
"under contract" must leave the default results within minutes, and a stale status is the most visible bug a portal
has.
Ranking
Default order blends recency, price against the area and quality of the listing. Learning to rank trains on saves, tour requests and clicks, with Gradient-boosted decision trees (GBDT) as the production default (XGBoost documents its LambdaMART ranking objective; see XGBoost docs). Features come from the listing (photos, days on market), from the geography (price per square metre against the cell median) and from the viewer. Sort-by-price and sort-by-newest stay as pure sorts: users who ask for them expect them unmodified.
Valuation and affordability
An automated valuation is a regression on sales and listing features, again with GBDT. quantile-regression (no
page yet) trains with the pinball loss to predict a low and a high quantile (XGBoost offers it as reg:quantileerror, source:
XGBoost parameters), so the product shows a range, which is the honest way to display an estimate that is wrong
by a margin. kriging-spatial-interpolation (no page yet)
fits a variogram to the residuals and interpolates a smooth neighbourhood price surface, a feature for the model
and a map layer. loan-amortization (no page yet) computes monthly payments from price, rate and term with exact
final-payment rounding, so a search by monthly budget is a sort and a filter over a derived field, and the numbers
shown match the lender's.
Saved-search alerts
Users save criteria (price 300-500k, 3+ bedrooms, inside a polygon). When a listing arrives or changes, the portal
needs the set of saved searches it satisfies: reverse search. boolean-expression-indexing (no page yet) indexes the
conjunctions so one event finds all matching subscriptions without scanning every user's search. Matching alerts
are batched per user and sent by email-delivery-smtp (no page yet) or by Mobile push delivery,
with a frequency cap so a hot market does not send twenty mails a day. The match runs off the same change stream as
the search index, so alert and search agree on what is new.
Map and photos
The base map is a pyramid of tiles (map-tile-pyramid, no page yet) in raster or vector form
(vector-tile-encoding, no page yet: the Mapbox Vector Tile format of protobuf layers and delta-encoded geometry).
Listing pins are served as one more vector layer generated for the viewport, so the client draws pins with the same
pipeline as roads. Price labels on pins need label-placement-collision (no page yet): a collision grid, with
priority rules (favourites, newest) deciding which label survives. Tiles and photos are static and cacheable, so
CDN and edge serves both with long-lived cache headers and versioned URLs.
Photos arrive as large JPEGs from agents. image-codecs (no page yet) decodes them; Image resampling filters
produce a fixed set of sizes with a filter whose width scales with the shrink factor, so thumbnails do not alias;
the renditions are written once to object storage and served from the CDN. Doing this on first request instead
costs latency at the worst moment, the first view.
Operations
The listing store runs with Primary-backup replication and
Point-in-time recovery: a parser bug that overwrites prices should be undoable to the
minute before it ran. Services are supervised (Process supervision) and drained on deploy
(Graceful draining); internal calls use Mutual TLS. Feed parsers read other
people's data, so they are fuzzed (Coverage-guided fuzzing); the sync and merge logic
suits Deterministic simulation testing. Latency percentiles per endpoint go into
hdr-histogram (no page yet).
Data and control flow
A price change from MLS to screen:
- The sync job asks the source for records modified after its cursor and receives the updated listing.
- The raw record is stored as a new version; the normaliser maps fields to the canonical schema.
- The address is unchanged, so geocoding is skipped (cached by the normalised address); property match is by the existing id.
- The listing store commits; the change stream emits an event.
- The search index updates terms and facets; the matcher finds saved searches the new price now satisfies and queues alerts; the valuation features for the cell are marked for refresh.
- Time goes to the source's response and the batch cadence; downstream steps take seconds.
One map search:
- The viewport and filters arrive; the bounding box becomes cell ranges at a resolution chosen from the zoom.
- The index returns listings in range with facets and counts; at low zoom it returns cluster counts per coarse cell instead of points.
- The ranker orders the visible set and picks the pins to show; label collision decides which prices are drawn.
- The response is JSON for the list and a vector layer for pins, both cacheable for a short time per tile and filter.
A new address:
- Parse, normalise, geocode; the precision class is stored with the point.
- The point gives the cell id and region ids (school zone, neighbourhood).
- Property matching looks for an existing property at the same address and unit and either links or creates one.
Scaling
- Source throughput. Many sources, each slow and rate-limited. Levers: independent cursors per source, parallel pulls, incremental only after the first load.
- Geocoding cost. Each new address costs a lookup. Levers: cache by normalised address, local reference data, fall back to a coarser precision class and fix later.
- Query load on busy maps. The map pan generates many small queries. Levers: coarse caching per tile and filter set, cluster counts precomputed per cell, pre-rendered vector tiles for common views.
- Alert fan-out. One listing can match thousands of saved searches in a hot market. Levers: batching per user per day, frequency caps, expression indexing so matching is not a scan.
- Photo bandwidth. The largest egress is images. Levers: renditions at fixed sizes, modern codecs, CDN caching; bandwidth prices vary widely between CDN providers (source: the CDN and edge page).
- Index rebuilds. A new analyser or schema means a rebuild. Lever: build beside, swap, from the change stream.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Stale sold listings | homes shown after they sold | sync lag, status not propagated, cached pages | short TTL on results, status events prioritised, treat missing-from-feed as closed after a grace period |
| Wrong pin | house in the wrong street or city | ambiguous address, postcode centroid used as a point | precision class, show area instead of pin for coarse results |
| Duplicate listings | same home appears twice | multiple sources, no property match | entity resolution on address, unit and photos; log merges |
| Over-merged properties | two flats shown as one | unit missing from the address | require unit match for multi-unit buildings |
| Cursor gaps | updates missing from one source | ties on timestamp, late commits | (timestamp, key) cursor, overlap window |
| Facet drift | counts do not match results | index update not atomic | update terms in one transaction, rebuild from the change stream |
| Overlapping labels | prices unreadable in dense areas | no collision handling | collision grid with priorities, cluster at low zoom |
| Overconfident valuation | estimate far from the sale price | thin data in the cell, no interval | quantile range, show confidence, fall back to a coarser model |
| Alert flood | users mute or unsubscribe | no frequency cap, broad criteria | batch, cap, merge near-identical listings |
| Heavy photo pages | slow listing pages on mobile | originals served | fixed renditions, modern format negotiation, CDN |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| RESO Data Dictionary and Web API | the real estate data standard and OData transport | reso.org, RESOStandards | see repos | defines field names and the sync API |
| PostGIS | spatial types and indexes for PostgreSQL | postgis/postgis | GPL-2.0 (see COPYING) | polygons, R-tree style indexes, spatial queries |
| libpostal | address parsing and normalisation | openvenues/libpostal | MIT | the address-parsing step |
| Pelias | modular open-source geocoder using Elasticsearch | pelias/pelias | MIT | geocoding part |
| Nominatim | OpenStreetMap geocoder | osm-search/Nominatim | GPL-3.0 (see COPYING) | geocoding part |
| H3, S2 | cell indexes | uber/h3, google/s2geometry | Apache-2.0 | location index |
| OpenSearch / Lucene | search server and library | opensearch-project/OpenSearch, apache/lucene | Apache-2.0 | search and facets |
| XGBoost | gradient boosting incl. ranking and quantile objectives | dmlc/xgboost | Apache-2.0 | ranking and valuation |
| Martin | vector tile server for PostGIS and other sources | maplibre/martin | MIT or Apache-2.0 (see repo) | pin layer |
| Tippecanoe | builds vector tilesets from large GeoJSON | felt/tippecanoe | BSD (see repo) | offline tile build |
| imgproxy | on-the-fly image resizing server | imgproxy/imgproxy | Apache-2.0 | photo renditions |
| libvips | fast image processing library | libvips/libvips | LGPL-2.1 | photo pipeline |
| Zillow, Rightmove | production listing portals | — | — | closed |
Sources
- RESO, Web API and Data Dictionary: Web API, Data Dictionary, field ModificationTimestamp (DD 1.7).
- A. Guttman, R-trees: a dynamic index structure for spatial searching, SIGMOD 1984. doi
- Mapbox, Vector Tile Specification. spec
- H3 documentation: h3geo.org; S2 Geometry: s2geometry.io.
- R. Koenker, G. Bassett, Regression Quantiles, Econometrica 1978. jstor
- T. Chen, C. Guestrin, XGBoost: A Scalable Tree Boosting System, KDD 2016. paper; XGBoost documentation, Learning to Rank, parameters.
- S. Whang, H. Garcia-Molina, C. Brower et al., Indexing Boolean expressions, PVLDB 2009. doi