Everyone has an opinion on whether their site beats the competition's. Few people have the table. A fair benchmark is easy to get wrong: comparing your home page with a rival's blog post, mixing real-user data with lab data, or ranking a rival with no data as if it came last. Below are the method, a script that ranks the results, and the Actor that produces them. Public pages and public data only.
Pick pages you can compare fairly
Choose three page types and use the same three for every site: the home page, the product or pricing page, and one blog post. They are built differently: a home page is mostly layout and images, a pricing page may load analytics and a chat widget, an article is mostly text. Comparing across types tells you little, and a site can lead on one page type while trailing on another.
For the blog post, pick one of similar length and format on each site, and skip posts built around a video player or heavy embed.
Run every page on mobile and desktop. Google Search ranks with mobile data, but your customers may mostly use desktop, and a rival can lead on one and trail on the other.
Use field data first, lab data to fill the gaps
Each audit returns two kinds of numbers.
Lab data is one Lighthouse run on a Google test device: the performance score (0 to 100) and metrics such as LCP, CLS and TBT. It exists for any page Google can load, but two runs of the same page can differ by a few points.
Field data is what real Chrome users experienced, as the 75th percentile over 28 days, for LCP, INP, CLS, FCP and TTFB. Google only has it for pages or sites with enough traffic; otherwise fieldData is null and you get lab data only.
Keep them in separate tables: lead with field data where it exists, use lab data so every page appears, and never blend them into one score. Where they disagree, fix field data first, as the README advises.
Rank fairly when rivals have no field data
Smaller rivals often have no field data. Set the rules before you look at the numbers:
- Missing is not zero and not worst. Leave it out of the ranking and show
n/a. - Rank a site only against others with data for the same page type and metric, and write 2/4, not 2. Second of four and second of six are different claims.
- To combine page types, average
rank / n, not raw ranks. - Treat origin-level data separately. When
fieldData.originFallbackistrue, the numbers are origin-level, meaning the whole site rather than that page, so the script leaves them unranked. - Make the lab tables your headline, since every audited page appears in them, and say how many rivals each field table covers.
A row full of n/a is a statement about traffic, not about speed.
Run the audits
Six sites, three pages each and two strategies make 36 audits. PageSpeed Insights Audit is an open-source Apify Actor that calls Google's official PageSpeed Insights API and returns one record per URL and strategy, so you skip the request loop and the raw report. Paste the pages into Page URLs, set Device strategy to Both and click Start, or send this input (placeholder domains):
{
"urls": [
"https://yoursite.example/",
"https://yoursite.example/pricing",
"https://yoursite.example/blog/how-we-do-x",
"https://competitor-a.example/",
"https://competitor-a.example/plans",
"https://competitor-a.example/blog/guide-to-x",
"https://competitor-b.example/",
"https://competitor-b.example/pricing",
"https://competitor-b.example/blog/x-explained",
"https://competitor-c.example/",
"https://competitor-c.example/pricing",
"https://competitor-c.example/blog/x-tips",
"https://competitor-d.example/",
"https://competitor-d.example/plans",
"https://competitor-d.example/blog/x-checklist",
"https://competitor-e.example/",
"https://competitor-e.example/pricing",
"https://competitor-e.example/blog/x-basics"
],
"strategy": "both",
"includeAuditDetails": true
}
includeAuditDetails adds the top failing audits to every record. From the API, with the JSON saved as input.json:
curl -X POST "https://api.apify.com/v2/acts/josh99smith~pagespeed-insights-audit/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" -d @input.json -o results.json
The call waits for the run, and synchronous endpoints suit short runs; for a longer list use the apify-client packages.
Rank the results with a script
The script reads results.json, matches each record's url to a site and page type, ranks every metric within a page type, and prints one table per strategy and metric plus a one-paragraph summary. Cells read value (rank/n); rows are sorted by average rank / n. Edit YOU and SITES for your own pages, with the same URLs you sent, since the match is on the url you supplied. Save it as rank.py; it needs Python 3.8 or later and no packages.
import json
import sys
from collections import defaultdict
YOU = "yoursite.example"
KINDS = ("home", "pricing", "blog")
SITES = { # one path per page type, in the order of KINDS
"yoursite.example": ("/", "/pricing", "/blog/how-we-do-x"),
"competitor-a.example": ("/", "/plans", "/blog/guide-to-x"),
"competitor-b.example": ("/", "/pricing", "/blog/x-explained"),
"competitor-c.example": ("/", "/pricing", "/blog/x-tips"),
"competitor-d.example": ("/", "/plans", "/blog/x-checklist"),
"competitor-e.example": ("/", "/pricing", "/blog/x-basics"),
}
PAGES = {f"https://{s}{p}": (s, k) for s, ps in SITES.items() for k, p in zip(KINDS, ps)}
METRICS = { # path in the record: is lower better?
("scores", "performance"): False,
("labMetrics", "lcpMs"): True,
("labMetrics", "tbtMs"): True,
("fieldData", "lcpMs", "percentile"): True,
("fieldData", "inpMs", "percentile"): True,
("fieldData", "cls", "percentile"): True,
}
def get(rec, path):
fd = rec.get("fieldData")
if path[0] == "fieldData" and (not fd or fd.get("originFallback")):
return None # no page-level field data: leave it unranked
for key in path:
rec = rec.get(key) if isinstance(rec, dict) else None
return rec
data, failed = defaultdict(dict), [] # data: (strategy, page type) -> {site: record}
for r in json.load(open(sys.argv[1], encoding="utf-8")):
if r["url"] in PAGES:
site, kind = PAGES[r["url"]]
if r["success"]:
data[r["strategy"], kind][site] = r
else:
failed.append(f"{site} {kind} {r['strategy']}")
mine = defaultdict(list) # your ranks: (strategy, is field) -> [(metric, page, rank, n)]
for strategy in ("mobile", "desktop"):
for path, lower in METRICS.items():
name = ".".join(path[:2])
rel, shown = defaultdict(list), defaultdict(dict)
for kind in KINDS:
vals = {s: v for s, r in data[strategy, kind].items() if (v := get(r, path)) is not None}
ordered = sorted(vals.values(), reverse=not lower)
for s, v in vals.items():
rank = ordered.index(v) + 1 # ties share a rank
rel[s].append(rank / len(vals))
shown[s][kind] = f"{v:g} ({rank}/{len(vals)})"
if s == YOU and len(vals) > 1:
mine[strategy, path[0] == "fieldData"].append((name, kind, rank, len(vals)))
print(f"\n{strategy}: {name}")
for s in sorted(SITES, key=lambda s: sum(rel[s]) / len(rel[s]) if rel[s] else 9):
print(f"{s:22}", *(shown[s].get(k, "n/a") for k in KINDS), sep=" | ")
parts = []
for (strategy, field), c in mine.items():
pos = lambda x: (x[2] - 1) / (x[3] - 1)
best, worst = min(c, key=pos), max(c, key=pos)
parts.append(
f"On {strategy} {'field' if field else 'lab'} data {YOU} is first in {sum(x[2] == 1 for x in c)} "
f"of {len(c)} comparisons and last in {sum(x[2] == x[3] for x in c)}; strongest is {best[0]} on "
f"{best[1]}, weakest is {worst[0]} on {worst[1]}."
)
print("\nSummary:", " ".join(parts), "This is one snapshot, not a verdict.",
"Not audited:", ", ".join(failed) or "none")
Run python rank.py results.json. Read the lab tables first, then the field tables, noting each n. The summary counts how often you rank first and last, for lab and field separately, and names your strongest and weakest comparison. Lab metrics move together, so the counts are not independent wins. Where you trail, opportunities[] in that page's record lists failing audits with savingsMs.
Cost and limits
The Actor charges a flat $0.004 per successfully audited page and strategy. For 6 sites x 3 pages x 2 strategies that is 36 audits: 36 x $0.004 = $0.144, about 14 cents. If two audits fail you pay for 34, or $0.136. Invalid URLs, pages Google cannot load and quota errors are recorded with success: false and an errorType, and cost nothing. A run also stops at the maximum cost you set.
One run is a snapshot. Rivals ship changes and Lighthouse varies by a few points, so quote the date, and for a steadier answer repeat the benchmark three times and compare medians: 108 audits, $0.432. Four weekly runs are 144 audits, $0.576.
Limits to plan around:
- Public pages only. Google must be able to fetch the page, so protected pages fail with
http-error. - By default the Actor uses a built-in Google API key shared by all its users: 25,000 requests a day and a few hundred a minute. When the quota runs out you get free
rate-limitedrecords; retry later or paste your own free key into Google API key. - Each audit takes Google roughly 10 to 30 seconds, and the README estimates 100 URLs in about 10 minutes, so expect minutes, not hours.
If this saves you time, a review on the Store page helps other people find it, and a star on GitHub helps too.
Disclosure: I built this Actor. Source: github.com/josh99smith/pagespeed-insights-audit. PageSpeed Insights and Lighthouse are Google trademarks; the Actor is not affiliated with Google.
Top comments (0)