Reliable integrations

Benchmark Property Lookup Latency for Your Application

A benchmark harness that separates live lookups, cache hits, failures and client timeouts, reports percentiles only from your own runs, and publishes the method with the numbers.

8 min read

Measure latency on your own workload

Response time decides how you design a property feature. It determines whether a lookup can sit inside a page request, needs a background job, or belongs in an overnight batch. A number from someone else's test cannot make that decision for you, because your inputs, network path, concurrency and mix of repeat requests are different.

This article is a measurement project, not a speed claim. It gives you a harness that records every request with its outcome, a summary that keeps those outcomes apart, and a template for reporting the result with its method. It does not publish percentiles for PropertiesAPI and it does not compare vendors. Our documentation states a historical measurement for live lookups and a recommended client timeout; treat those as context for setting timeouts, not as your result.

If you compare services, run the same harness, inputs and conditions against each one, and publish the method alongside the numbers. Otherwise the comparison measures the test, not the services.

Keep every outcome in its own bucket

A single latency distribution that mixes live lookups, cache hits and errors describes nothing useful. A live lookup collects the property page at request time. A cache hit returns an earlier collection and does very little work. An error can return early, after a long wait, or not at all. Average them together and the result moves whenever the mix changes, even if nothing got faster or slower.

The harness labels each request with one outcome and the summary reports each separately. Cache status comes from meta.cached in the response body, with the x-cache header recorded as a cross-check. Errors keep their error code, because a rate-limit response and an upstream timeout tell you different things.

OutcomeHow it is identifiedHow to report it
live_successSuccess with meta.cached false.Count, median, a high percentile and maximum.
cache_hitSuccess with meta.cached true and x-cache HIT.Separately from live lookups, never blended.
error with codeNon-success response with an error code in the body.Count per code; durations only if useful.
client_timeoutYour client gave up before a response arrived.Count only. The true duration is unknown.
transport_errorConnection or protocol failure before a response.Count and exception type.

Record client time and server time

The harness measures wall-clock time around each request with a monotonic, high-resolution performance counter, not the system clock, which can jump. That is the latency your users experience from your servers. The response also includes meta.took_ms, the time the API spent handling the request. The gap between the two is network, TLS and queueing on your side, which is often the part you can change.

Client timeouts need care. When your client gives up, you know only that the request took longer than the limit. Recording the timeout value as if it were the duration drags percentiles toward an arbitrary number. The summary counts timeouts and excludes them from the duration statistics, and your report should state the timeout you used.

A timeout does not prove the server did nothing. The lookup may have completed after your client disconnected. Reconcile timeouts against account usage before you assume they were free, and do not retry them blindly in a benchmark.

Design the run before you start it

Pick inputs that look like production: the same mix of property types, address quality and link versus address inputs. Use properties you have a legitimate reason to look up. Write the inputs to a file and keep it with the results so the run can be repeated.

Run a first pass to measure live lookups, then a second pass over the same inputs inside the cache window to measure cache hits. Successful live lookups consume credits and cache hits do not, so size the first pass to what you are prepared to spend. Keep the request rate well inside your account's rate limit; a benchmark that triggers rate limiting measures your client's pacing, not the service.

Note where the client runs, the date and time window, the concurrency, and the timeout settings. Latency varies with time of day and with the property pages themselves, so one short run is a sample, not a characteristic of the service.

The benchmark harness

The harness reads one address per line, sends sequential requests with an explicit connect and read timeout and a pause between requests, and appends one JSON line per request to runs.jsonl. Writing each line as it goes means an interrupted run still leaves usable data. Redirects are disabled so the key is never forwarded to another host.

Each line carries the label you pass on the command line, so first and second passes, or runs from different regions, stay distinguishable in one file.

latency_bench.py
"""latency_bench.py: time property lookups and keep every outcome separate.

Usage: python latency_bench.py inputs.txt LABEL
Appends one JSON line per request to runs.jsonl. Run the same inputs twice to
measure live lookups (first pass) and cache hits (second pass inside the cache window).
"""
import json
import os
import sys
import time
from datetime import datetime, timezone

import requests

API = "https://api.propertiesapi.com/v1/zillow/property"
CONNECT_S, READ_S = 10, 140   # follow the documented client timeout guidance
PAUSE_S = 2                   # stay well inside your account's rate limit


def one(session, address, label):
    record = {"label": label, "input": address, "started_at": datetime.now(timezone.utc).isoformat()}
    t0 = time.perf_counter()
    try:
        response = session.get(API, params={"address": address},
                               timeout=(CONNECT_S, READ_S), allow_redirects=False)
    except requests.Timeout:
        return {**record, "outcome": "client_timeout", "elapsed_s": time.perf_counter() - t0}
    except requests.RequestException as exc:
        return {**record, "outcome": "transport_error", "detail": type(exc).__name__,
                "elapsed_s": time.perf_counter() - t0}
    elapsed = time.perf_counter() - t0
    try:
        body = response.json()
    except ValueError:
        return {**record, "outcome": f"http_{response.status_code}_not_json", "elapsed_s": elapsed}
    meta = body.get("meta", {})
    if response.status_code == 200:
        outcome = "cache_hit" if meta.get("cached") else "live_success"
    else:
        outcome = "error:" + body.get("error", {}).get("code", "unknown")
    took = meta.get("took_ms")
    return {**record, "outcome": outcome, "elapsed_s": elapsed,
            "server_s": took / 1000 if isinstance(took, (int, float)) else None,
            "x_cache": response.headers.get("x-cache"), "credits": meta.get("credits"),
            "fetched_at": meta.get("fetched_at")}


def main(inputs_path, label):
    session = requests.Session()
    session.headers["x-api-key"] = os.environ["PROPERTIESAPI_KEY"]
    addresses = [line.strip() for line in open(inputs_path, encoding="utf-8") if line.strip()]
    with open("runs.jsonl", "a", encoding="utf-8") as out:
        for address in addresses:
            out.write(json.dumps(one(session, address, label)) + "\n")
            out.flush()
            time.sleep(PAUSE_S)


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Summarize without overstating

The summary groups requests by outcome and prints the count for every group. It only computes percentiles when a group has enough samples; with fewer, it lists the raw values, because a high percentile of a handful of requests is just the slowest one with a different name. Raise the threshold if you plan to quote tail percentiles.

It also prints the credits reported by the responses. Compare that with your account usage after the run; the two should reconcile, and a difference usually points at timeouts or retries you did not account for.

summarize.py
"""summarize.py: per-outcome counts and percentiles from runs.jsonl."""
import json
import statistics
import sys

MIN_N = 20  # below this, list the values instead of quoting percentiles

rows = [json.loads(line) for line in open("runs.jsonl", encoding="utf-8")]
label = sys.argv[1] if len(sys.argv) > 1 else None
rows = [r for r in rows if label is None or r["label"] == label]

groups = {}
for r in rows:
    groups.setdefault(r["outcome"], []).append(r)

for outcome, group in sorted(groups.items()):
    times = sorted(r["elapsed_s"] for r in group)
    line = f"{outcome:32} n={len(group):<5}"
    if outcome in ("client_timeout", "transport_error"):
        print(line + " (no duration: the request did not complete)")
        continue
    if len(times) >= MIN_N:
        cuts = statistics.quantiles(times, n=20, method="inclusive")
        line += f" median={statistics.median(times):.2f}s p95={cuts[18]:.2f}s max={times[-1]:.2f}s"
        server = [r["server_s"] for r in group if r.get("server_s") is not None]
        if server:
            line += f" server_median={statistics.median(server):.2f}s"
    else:
        line += " values=" + ", ".join(f"{t:.2f}s" for t in times)
    print(line)
print(f"total requests: {len(rows)}; credits reported: {sum(r.get('credits') or 0 for r in rows)}")

Report the method with the numbers

A latency figure without its method invites the wrong decision. When you share results, internally or publicly, include the items below. If a group was too small for percentiles, say so rather than quoting one.

Validation scope: both scripts were run against a local fixture server that returned live successes, cache hits, error codes, a non-JSON response and a deliberate timeout. Field names follow the published response contract. The fixture timings are artificial, and this article reports no measured latency for any service.

  • Date, time window and where the client ran.
  • Input set and how it was chosen.
  • Concurrency, pause between requests and timeout settings.
  • Count per outcome, including timeouts and each error code.
  • Median, a high percentile and maximum for live lookups and cache hits, separately.
  • Client time versus server time, and how credits reconciled with account usage.

Sources and documentation

Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.

Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.