How to Scrape Realtor.com Listings and Agent Data
Learn how to scrape Realtor.com listings at scale: extract properties, prices, agents, and market data from its hidden JSON and beat PerimeterX blocks.

To scrape Realtor.com listings at any real scale, parsing is the easy part. Two things decide whether your pipeline works: where Realtor.com hides its structured data, and how you survive its bot wall. The site ships every property, price, and agent record as JSON inside the page and behind a private GraphQL backend, and it runs PerimeterX, the same behavioral wall you meet on Zillow. This guide stays on the keyboard. You'll get the exact field map for properties, prices, and agents, where the data actually lives in the page, how to page through a full metro without slamming into the result window, how to clear the "Press and Hold" challenge, and working Python against SparkProxy's Scraping API. It's the Realtor.com-specific companion to our general guide to scraping real estate listings, so here we go deep on what makes this portal different.
What you'll build
- A field map for list price, beds, baths, sqft, lot size, agent, brokerage, and MLS source
- A parser that reads Realtor.com's embedded
initialReduxStateJSON instead of fighting selectors- A search sweep over
realestateandhomes-searchthat survives the ~10,000-result window- Card extraction that anchors on
data-testid, not class names that change every build- An anti-bot escalation ladder so a Press and Hold wall doesn't end the run
Scrape responsibly: public data, MLS licensing, and Fair Housing
Set the boundary before any code. Scraping listing data that any anonymous visitor can load on a public Realtor.com page sits on reasonably defensible ground, and US courts have repeatedly declined to treat access to public web pages as unauthorized access under the CFAA (the hiQ v. LinkedIn line of cases is the usual reference). That is not a blank check. Four rules keep you clear:
- Public pages only. Don't touch anything behind a login, a saved-search account, or an agent dashboard. Bypassing authentication is a separate legal category with real exposure.
- Realtor.com is an MLS licensee, not the source. The site is operated by Move, Inc., a News Corp subsidiary, and its inventory is licensed from hundreds of regional Multiple Listing Services under RESO and MLS rules. Those licenses restrict redistribution. Using price and inventory data for internal analysis is one risk profile. Rebuilding a competing listings portal from scraped MLS rows is a very different one.
- Fair Housing attaches the moment data drives decisions. If scraped listings, prices, or neighborhood signals feed a model that screens tenants, sets rents, or targets housing ads, the Fair Housing Act and its disparate-impact standard apply. Keep protected-class proxies, including raw neighborhood demographics, out of any automated decision.
- Agent contact data is personal data. Realtor.com attaches agent and broker names, phones, and emails to most listings. Sweeping those into a cold-outreach list runs into CAN-SPAM, state privacy law, and the site's own terms. Store what you need for provenance, not a marketing database.
The rest of this guide assumes you're collecting public listing data for internal analysis. For the broader compliance framework on MLS licensing and Fair Housing when scraped data feeds automated systems, see our real estate data aggregation guide. For anything you plan to publish or resell, get legal sign-off first.
What a Realtor.com listing exposes
Decide your schema up front. Retrofitting a field after you've scraped 200,000 pages means re-scraping. Realtor.com's internal data uses a vesta schema whose field names look nothing like Zillow's camelCase, so here's the reference most Realtor.com pipelines converge on, with the type and the gotcha that bites people.
| Field | Data type | Example value | Notes |
|---|---|---|---|
| `property_id` | string | `M1234-56789` | Realtor.com's stable id (the `M...` token in the detail URL). Your primary key. |
| `listing_id` | string | `2960884471` | The current MLS listing instance. It changes on relist, so key on `property_id`, not this. |
| `list_price` | integer | `459000` | Current asking price. Null on some "contact for price" or off-market rows. |
| `status` | string | `for_sale` | `for_sale`, `ready_to_build`, `sold`, `off_market`, `other`. Drives whether the row is live. |
| `description.beds` | integer | `3` | Bedroom count. Missing on land. |
| `description.baths_consolidated` | string | `2.5` | Baths arrive as a string with the fraction. Keep the half bath, it matters for comps. |
| `description.sqft` | integer | `1840` | Interior area. Do not confuse with `lot_sqft`. |
| `description.lot_sqft` | integer | `7405` | Lot size in square feet. Convert to acres yourself. |
| `description.year_built` | integer | `1998` | Missing on new construction and raw land. |
| `description.type` | string | `single_family` | `single_family`, `condos`, `townhomes`, `multi_family`, `land`, `mobile`. Already normalized. |
| `location.address` | object | `{line, city, state_code, postal_code}` | Street, city, two-letter state, ZIP. |
| `location.coordinate` | object | `{lat, lon}` | Exact coordinates, straight from the JSON. No geocoding needed. |
| `list_date` / `last_update_date` | ISO datetime | `2026-06-14T...` | Days-on-market math and change detection. |
| `flags` | object | `{is_price_reduced, is_pending, is_new_listing, is_foreclosure}` | Boolean demand signals in one bag. |
| `advertisers` | array | `[{name, type, email, phones, office}]` | The agent and brokerage records. `type` is `agent`, `office`, or `builder`. Your "agents" payload. |
| `source` / `mls` | object | `{name, abbreviation, plan_id}` | Which MLS the row came from. Provenance you'll want for licensing and dedupe. |
| `price_history` | array | `[{date, event_name, price}]` | Listing, sold, and price-change events. The seller-motivation signal. |
| `tax_history` | array | `[{year, tax, assessment{building, land, total}}]` | Annual tax and assessed value. A value anchor. |
| `schools` | array | `[{name, rating, distance_in_miles}]` | Assigned and nearby schools with ratings. |
Two clusters carry more weight than the rest. The advertisers array plus source.mls is the reason to scrape Realtor.com over Zillow specifically: you get the listing agent, the brokerage, and the originating MLS attached to each home, data Zillow largely strips from public view. If your goal is agent market-share analysis or lead-source attribution, that provenance is the whole point. The second cluster is price_history and tax_history together, which give you two independent value anchors per property. A home listed 15% above its last assessed value, or one cut twice in 60 days, is a different negotiation than a fresh listing at assessed value even at the same sticker.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Where Realtor.com keeps its data: __NEXT_DATA__ and the RDC GraphQL backend
Here's the thing that trips up first-timers. A Realtor.com detail page is a Next.js React app. The HTML you see is a shell, and the real fields (price, agents, coordinates, history) arrive as a large JSON object embedded in a tag, hydrated client-side. You don't need to reverse-engineer anything. The stable target is that embedded JSON.
Grab and parse it:
import json
from bs4 import BeautifulSoup
def extract_next_data(html):
"""Realtor.com ships page data inside <script id='__NEXT_DATA__'>."""
soup = BeautifulSoup(html, "html.parser")
tag = soup.find("script", id="__NEXT_DATA__")
if not tag or not tag.string:
return None
return json.loads(tag.string)
On Realtor.com the property object lives under the Redux preload, not a separate Apollo cache. Walk into props.pageProps.initialReduxState and pull the node that holds a property_id:
def property_from_next(next_data):
"""Detail-page property lives under the Redux preload."""
state = (next_data.get("props", {})
.get("pageProps", {})
.get("initialReduxState", {}))
# the exact detail key has shifted across builds; walk for the property_id holder
for value in state.values():
if isinstance(value, dict):
if value.get("property_id"):
return value
for inner in value.values(): # some builds nest one level deeper
if isinstance(inner, dict) and inner.get("property_id"):
return inner
return None
This is where Realtor.com differs from Zillow in a way that saves you an hour. Zillow double-encodes its property object inside a gdpClientCache string you have to parse twice. Realtor.com keeps it as live JSON under initialReduxState, so a single parse gets you there. Archive one raw blob during development. When a key name moves on a new front-end build, you diff the saved blob, find the new path, and update a string. That's a five-minute fix, not a rewrite.
Behind that embedded state sits the RDC GraphQL backend, the same vesta schema that powers Realtor.com's mobile app. The frontend calls it internally (the app hits an endpoint under www.realtor.com/api/v1/ with client_id=rdc-x and schema=vesta), and it returns the exact field names you already see in __NEXT_DATA__. You can call that GraphQL layer directly for higher throughput, but it's a moving target: the persisted query hash and required headers change without notice, and it's guarded harder than the HTML pages. Treat the embedded __NEXT_DATA__ JSON as your stable primary path and the GraphQL backend as an optimization you reach for only after you've cleared the bot wall. If you go that route, our guide to scraping GraphQL APIs covers persisted-query mechanics, and the general pattern is in scraping hidden JSON API endpoints.
Quickstart: one detail page through the Scraping API
Realtor.com combines three hard problems at once: JavaScript-rendered content, aggressive per-IP rate limits, and PerimeterX behavioral fingerprinting. A managed Scraping API folds proxy rotation, headless Chromium, and anti-bot into one call, so you send a URL and get back rendered HTML. If you want the build-versus-buy math first, see web scraping API vs self-managed proxies.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and it authenticates with an X-API-Key header. The simplest call, rendering a detail page through a US residential IP:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.realtor.com/realestateandhomes-detail/123-Main-St_Austin_TX_78704_M1234-56789" \
--data-urlencode "render_js=true" \
--data-urlencode "country_code=US" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "wait_for=script#__NEXT_DATA__"
The same thing in Python, where the rest of this guide lives:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": "https://www.realtor.com/realestateandhomes-detail/123-Main-St_Austin_TX_78704_M1234-56789",
"render_js": "true", # the page hydrates via JS
"country_code": "US", # US-only inventory, pin a US exit IP
"premium_proxy": "true", # Realtor.com blocks datacenter ranges fast
"wait_for": "script#__NEXT_DATA__", # hold until the JSON blob exists
},
timeout=90,
)
resp.raise_for_status()
html = resp.text
Two parameters matter more than the rest. wait_for points at the __NEXT_DATA__ script so the API captures after hydration, not before, which is the difference between a full record and a half-empty one where the price and agents come back missing. And premium_proxy=true routes through residential IPs, because Realtor.com flags datacenter ranges within a handful of requests. A subset of detail pages do clear on datacenter with stealth, and the escalation ladder later shows how to try that first, but residential is the honest default here.
Extracting Realtor.com listings: properties, prices, and agents
With the property object in hand, mapping fields is direct. The prices and physical facts sit on the property and its description, and the agents live in the advertisers array:
def parse_listing(prop):
desc = prop.get("description") or {}
loc = prop.get("location") or {}
addr = loc.get("address") or {}
coord = loc.get("coordinate") or {}
record = {
"property_id": prop.get("property_id"),
"listing_id": prop.get("listing_id"),
"status": prop.get("status"),
"list_price": prop.get("list_price"),
"beds": desc.get("beds"),
"baths": desc.get("baths_consolidated") or desc.get("baths"),
"sqft": desc.get("sqft"),
"lot_sqft": desc.get("lot_sqft"),
"year_built": desc.get("year_built"),
"prop_type": desc.get("type"),
"street": addr.get("line"),
"city": addr.get("city"),
"state": addr.get("state_code"),
"zip": addr.get("postal_code"),
"lat": coord.get("lat"),
"lng": coord.get("lon"),
"list_date": prop.get("list_date"),
}
# agents and brokerage: the advertisers array is the piece most guides drop
record["agents"] = [
{"name": a.get("name"),
"type": a.get("type"), # agent | office | builder
"email": a.get("email"),
"phone": (a.get("phones") or [{}])[0].get("number"),
"office": (a.get("office") or {}).get("name")}
for a in (prop.get("advertisers") or [])
]
# MLS provenance: which service the row came from
src = prop.get("source") or {}
record["mls_name"] = src.get("name") or (prop.get("mls") or {}).get("name")
# price history for seller-motivation signals
record["price_history"] = [
{"date": h.get("date"), "event": h.get("event_name"), "price": h.get("price")}
for h in (prop.get("price_history") or [])
]
return record
The advertisers block is the field most Realtor.com tutorials skip, and it's the one worth having. Each entry is one agent, office, or builder tied to the listing, with the contact fields Realtor.com chooses to publish. Aggregate agents[*].office across a metro and you have brokerage market share by active inventory, a number brokerages pay real money for and you can compute from public pages. Keep the ethics section in mind: share analysis and provenance are fine, a scraped cold-call list is not.
Market data proper needs no special endpoint. Roll up list_price, list_date, and flags.is_price_reduced across every listing in an area and you can compute the median list price, median days-on-market, and the share of inventory carrying a price cut, the same headline metrics Realtor.com publishes in its monthly housing reports, built from the raw rows instead of a summary.
Search results: pagination and the 42-per-page grid
Scraping one detail page is the easy 5%. The real job is finding every home in an area, and Realtor.com's search lives at a predictable URL:
https://www.realtor.com/realestateandhomes-search/Austin_TX/pg-2
Filters slot into the path: /type-single-family-home, /price-na-500000, /beds-3. The frontend renders 42 property cards per page and pages with /pg-N.
You have two ways to read a results page. The clean one: the same __NEXT_DATA__ blob on the search page carries the card list under initialReduxState, so parse it exactly like a detail page and skip the DOM entirely.
def listings_from_search(html):
nd = extract_next_data(html)
if not nd:
return []
state = nd.get("props", {}).get("pageProps", {}).get("initialReduxState", {})
cards = []
for value in state.values(): # walk for the results list
if isinstance(value, dict):
results = value.get("results") or value.get("listings")
if isinstance(results, list) and results and isinstance(results[0], dict):
cards = results
break
return [
{"property_id": c.get("property_id"),
"href": c.get("href") or c.get("permalink"),
"list_price": c.get("list_price"),
"beds": (c.get("description") or {}).get("beds"),
"status": c.get("status")}
for c in cards
]
The fallback, when you do parse HTML, has one rule that saves you weekly breakage: anchor on data-testid and data-label attributes, never on class names. Realtor.com ships hashed CSS classes like BasePropertyCard_propertyCardWrap__abc12 that regenerate on every front-end build, so a selector written against them dies within days. The data-testid hooks stay stable across builds. That single habit is the difference between a scraper you patch monthly and one you patch on release day.
Beating Realtor.com's 10,000-result search window
Now the wall you hit at metro scale. Realtor.com will happily serve /pg-2, /pg-3, and so on, but the underlying result set is bounded: a single search location exposes only about the first 10,000 matches, and the grid stops paging well before a dense market is exhausted. A hot metro can hold far more active and recently-sold inventory than one query returns, so a naive city-level sweep quietly misses homes.
The fix is the same shape as any bounded search: don't ask for more pages, ask narrower questions. Realtor.com hands you clean facets in the URL path, so slice the query until each slice returns under the window:
- Property type. Run
single-family-home,condo,townhome,multi-family, andlandas separate sweeps. Each is a fraction of the whole. - Price bands. Split price into ranges (
price-na-300000,price-300000-500000,price-500000-800000,price-800000-na) and sweep each. This alone clears most metros. - Beds, then ZIP. If a single price band in a hot market still pins the window, add a
beds-Nfacet, or drop from city to individual ZIP codes.
Dedupe by property_id because facets overlap at the edges. Here's a facet sweep that pages each slice and unions the results:
import time, random
def build_search_url(city_state, page=1, prop_type=None, price_lo=None, price_hi=None):
parts = [f"https://www.realtor.com/realestateandhomes-search/{city_state}"]
if prop_type:
parts.append(f"type-{prop_type}")
if price_lo is not None or price_hi is not None:
lo = price_lo if price_lo is not None else "na"
hi = price_hi if price_hi is not None else "na"
parts.append(f"price-{lo}-{hi}")
if page > 1:
parts.append(f"pg-{page}")
return "/".join(parts)
def sweep_area(fetch, city_state, types, bands, max_pages=25):
"""fetch(url) -> html. Union listings across type x price facets."""
seen = {}
for prop_type in types:
for lo, hi in bands:
for page in range(1, max_pages + 1):
url = build_search_url(city_state, page, prop_type, lo, hi)
cards = listings_from_search(fetch(url))
if not cards:
break # ran off the end of this facet
for c in cards:
if c.get("property_id"):
seen[c["property_id"]] = c
time.sleep(random.uniform(1.5, 3.5))
return seen
Price banding plus property type is usually enough. Reach for ZIP-level splitting only on the bands that still overflow, so you spend requests where the coverage gap actually is.
Getting past Realtor.com's anti-bot (Press and Hold)
Realtor.com runs PerimeterX (now HUMAN Security), the same behavioral defense Zillow uses. When it flags you, you don't get a clean 403, you get a page with a "Press and Hold to confirm you are a human" button and a px-captcha element. Plain requests calls trip it within a handful of hits, which is why an API layer that handles proxies, rendering, and TLS fingerprinting earns its keep here.
The mistake is reaching for the most expensive proxy tier on every request. The right move is an escalation ladder that starts cheap and upgrades only what gets blocked.
- Detail pages: residential with JS render. Realtor.com is stricter on datacenter than most targets, so
premium_proxy=truewithrender_js=trueis the sane default. A minority of detail pages clear on datacenter withstealth=true, so you can try that first and fall back. - Add
stealth=true. It hardens the browser fingerprint and adds realistic timing for pages that sniff headless Chromium. - Vary
device. Rotatedesktopandmobileso repeated hits don't share one fingerprint. - Slow down. Rate is the lever people ignore. Even with rotation, 200 requests a minute looks nothing like a person browsing. Hold each IP to a modest rate and add jitter.
Map symptoms to fixes so you're not guessing mid-run:
| Symptom | Likely cause | Fix |
|---|---|---|
| `px-captcha` / Press and Hold page | IP flagged by PerimeterX | `premium_proxy=true`; add `stealth=true` |
| `403` on every request | Datacenter range blocked outright | Residential IP plus `country_code=US` |
| `429` after N requests | Per-IP rate too high | Slow down, widen delays, lean on rotation |
| Empty price or agents | Captured before hydration | `render_js=true` plus `wait_for=script#__NEXT_DATA__` |
| `__NEXT_DATA__` present, no property | New build shifted the state key | Walk `initialReduxState` for the `property_id` holder |
The PerimeterX-specific tactics live in how to bypass PerimeterX, and the general playbook is how to avoid getting your proxy blocked. The discipline that keeps costs sane: let the block response decide when to escalate, rather than paying for the strongest tier on every page.
A complete production Realtor.com scraper
Here's the whole thing wired together: a session, a request helper with retry, backoff, and datacenter-to-residential escalation, the facet search sweep, the __NEXT_DATA__ detail extractor, and a runner that writes clean rows to CSV.
import requests, json, time, csv, random
from bs4 import BeautifulSoup
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY" # from your SparkProxy dashboard
session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})
def api_get(url, render=True, premium=True, wait_for=None):
"""One call with retry, backoff, and datacenter->residential escalation."""
params = {"url": url, "render_js": "true" if render else "false",
"country_code": "US", "stealth": "true"}
if premium:
params["premium_proxy"] = "true"
if wait_for:
params["wait_for"] = wait_for
for attempt in range(4):
try:
r = session.get(API, params=params, timeout=90)
except requests.RequestException:
time.sleep(2 ** attempt); continue
blocked = r.status_code in (403, 429) or "px-captcha" in r.text[:5000]
if r.status_code == 200 and not blocked:
return r.text
if blocked and not params.get("premium_proxy"):
params["premium_proxy"] = "true" # escalate this URL only
time.sleep((2 ** attempt) + random.uniform(0, 1.5))
return None
# ---- extraction ----
def extract_next_data(html):
tag = BeautifulSoup(html, "html.parser").find("script", id="__NEXT_DATA__")
return json.loads(tag.string) if tag and tag.string else None
def property_from_next(nd):
state = nd.get("props", {}).get("pageProps", {}).get("initialReduxState", {})
for value in state.values():
if isinstance(value, dict):
if value.get("property_id"):
return value
for inner in value.values():
if isinstance(inner, dict) and inner.get("property_id"):
return inner
return None
def listings_from_search(html):
nd = extract_next_data(html)
if not nd:
return []
state = nd.get("props", {}).get("pageProps", {}).get("initialReduxState", {})
for value in state.values():
if isinstance(value, dict):
results = value.get("results") or value.get("listings")
if isinstance(results, list) and results and isinstance(results[0], dict):
return [{"property_id": c.get("property_id"),
"href": c.get("href") or c.get("permalink")}
for c in results if c.get("property_id")]
return []
# ---- search sweep ----
def build_search_url(city_state, page, prop_type, price_lo, price_hi):
parts = [f"https://www.realtor.com/realestateandhomes-search/{city_state}"]
if prop_type:
parts.append(f"type-{prop_type}")
lo = price_lo if price_lo is not None else "na"
hi = price_hi if price_hi is not None else "na"
parts.append(f"price-{lo}-{hi}")
if page > 1:
parts.append(f"pg-{page}")
return "/".join(parts)
def collect_ids(city_state, types, bands, max_pages=25):
seen = {}
for prop_type in types:
for lo, hi in bands:
for page in range(1, max_pages + 1):
html = api_get(build_search_url(city_state, page, prop_type, lo, hi),
render=True)
cards = listings_from_search(html) if html else []
if not cards:
break
for c in cards:
seen[c["property_id"]] = c["href"]
time.sleep(random.uniform(1.5, 3.5))
return seen
# ---- detail ----
def scrape_detail(href):
if href and href.startswith("/"):
href = "https://www.realtor.com" + href
html = api_get(href, render=True, wait_for="script#__NEXT_DATA__")
if not html:
return None
nd = extract_next_data(html)
prop = property_from_next(nd) if nd else None
if not prop:
return None
desc = prop.get("description") or {}
addr = (prop.get("location") or {}).get("address") or {}
coord = (prop.get("location") or {}).get("coordinate") or {}
ads = prop.get("advertisers") or []
return {
"property_id": prop.get("property_id"),
"list_price": prop.get("list_price"),
"status": prop.get("status"),
"beds": desc.get("beds"),
"baths": desc.get("baths_consolidated") or desc.get("baths"),
"sqft": desc.get("sqft"),
"lot_sqft": desc.get("lot_sqft"),
"year_built": desc.get("year_built"),
"type": desc.get("type"),
"street": addr.get("line"),
"city": addr.get("city"),
"state": addr.get("state_code"),
"zip": addr.get("postal_code"),
"lat": coord.get("lat"),
"lng": coord.get("lon"),
"agent": ads[0].get("name") if ads else None,
"brokerage": (ads[0].get("office") or {}).get("name") if ads else None,
"mls": (prop.get("source") or {}).get("name"),
"href": href,
}
def run(city_state, types, bands, out="realtor.csv"):
ids = collect_ids(city_state, types, bands)
print(f"Found {len(ids)} listings")
cols = ["property_id", "list_price", "status", "beds", "baths", "sqft",
"lot_sqft", "year_built", "type", "street", "city", "state", "zip",
"lat", "lng", "agent", "brokerage", "mls", "href"]
with open(out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols); w.writeheader()
for i, (pid, href) in enumerate(ids.items(), 1):
row = scrape_detail(href)
if row:
w.writerow(row)
if i % 25 == 0:
print(f" {i}/{len(ids)} scraped")
time.sleep(random.uniform(2.0, 5.0)) # conservative per-IP pacing
if __name__ == "__main__":
TYPES = ["single-family-home", "condo", "townhome", "multi-family", "land"]
BANDS = [(None, 300000), (300000, 500000),
(500000, 800000), (800000, None)]
run("Austin_TX", TYPES, BANDS, out="austin.csv")
This survives the failure modes that stop naive scrapers. It escalates only blocked requests to residential, detects the px-captcha wall in the body rather than trusting the status code alone, dedupes by property_id, waits for __NEXT_DATA__ before capture, walks initialReduxState instead of hard-coding a shifting key, and paces itself. Swap the location string and facets for your metro and it runs.
Cost per request: pick the cheapest mode that works
The Scraping API bills in credits, and the mode you pick per request is the biggest lever on cost. Rendering JavaScript and routing through residential IPs cost more, so match the mode to what Realtor.com actually forces rather than defaulting to the strongest option.
| Request mode | Credits | Use it for |
|---|---|---|
| Rotating datacenter, no JS | 1 | Almost nothing on Realtor.com; it blocks datacenter fast |
| Rotating datacenter + JS render | 5 | Detail pages worth one cheap attempt before you escalate |
| Premium (residential), no JS | 10 | Rare here; most pages need rendering to hydrate |
| Premium (residential) + JS render | 25 | Your realistic default on Realtor.com |
| Add-on: `stealth`, `country_code` | +5 each | Layer only when the target requires it |
Realtor.com pushes you toward the residential-plus-JS tier harder than Zillow does, so the escalation ladder matters more, not less. Run detail pages on datacenter-plus-JS first, upgrade only the ones that come back with a Press and Hold, and you keep a slice of traffic at 5 credits instead of 25. At metro scale that slice is the gap between a workable bill and a painful one. Because a single residential search call returns dozens of listings at once, discovery is cheap. The per-detail fetches dominate the total, which is exactly why escalating only the blocked ones pays off.
Frequently asked questions
FAQ
Scraping publicly displayed listing data that any anonymous visitor can view is generally defensible under current US case law, which has declined to treat access to public web pages as unauthorized access. The limits come from elsewhere: don't log in or bypass authentication, don't harvest agent personal data in bulk, and don't republish MLS-sourced listings as a competing portal, since Realtor.com's inventory is licensed from MLSs under RESO rules. The safest footing is internal analysis of public data, with legal sign-off before you publish or resell anything.
Realtor.com is a Next.js React app that embeds page data as JSON inside a tag, then hydrates the page from it. The property record lives under props.pageProps.initialReduxState, held as live JSON, so a single parse reaches it (unlike Zillow, which double-encodes its data). Behind the page sits the RDC vesta GraphQL backend that the mobile app uses, returning the same field names.
The listing agent, office, and builder live in the advertisers array on the property object inside __NEXT_DATA__, each entry carrying name, type, and the contact fields Realtor.com exposes. Read advertisers[].name for the agent and advertisers[].office.name for the brokerage, and pair them with source.name to know which MLS supplied the row. Keep bulk agent contact collection inside the ethics and CAN-SPAM limits covered above.
Yes, with the right setup, though Realtor.com runs PerimeterX behavioral detection that shows a "Press and Hold" challenge rather than a plain 403. Route through US residential IPs with JavaScript rendering and stealth enabled, and detect the px-captcha element in the response body so you can retry instead of saving a broken page. The larger lever is rate: hold each IP to a modest request pace with high-variance delays, not just proxy quality.
A single search location exposes only about the first 10,000 matches, and the frontend shows 42 cards per page, so a city-level query silently misses homes in a dense metro. To cover a full market, split the search by property type and price bands, then drop to ZIP codes on any facet that still pins the window. Dedupe by property_id because facets overlap at the edges.
For most of it, yes. Realtor.com blocks datacenter ranges within a handful of requests, so premium_proxy=true with render_js=true is the realistic default, unlike some targets where datacenter clears fine. You can still try datacenter-plus-stealth on individual detail pages and escalate only the ones that return a Press and Hold, which keeps part of your traffic on the cheaper tier.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

How to Scrape Yandex Search Results in 2026
Scrape Yandex search results reliably: lr region codes, Cyrillic queries, SmartCaptcha detection, the official XML API, and working SparkProxy code.

How to Scrape Vinted Listings
Scrape Vinted listings across vinted.fr, .de and .co.uk: the internal JSON API, cookie bootstrapping, per-market catalogue IDs, and GDPR-safe resale analytics.

How to Scrape TikTok Public Data With Proxies
Scrape TikTok public data with proxies: read the hydration JSON blob, use the Research API and oEmbed, detect fake 200s, and cut credits per good page.
