Measuring small-business web presence without Google Places.
I measured the web presence of every HVAC service firm in Mobile and Baldwin County, Alabama, without touching Google Places. The frame came from Overture Maps; the counties came from a spatial join against Census boundaries. Then I broke my own first answer three times. Each correction is documented below with the before and after, because the corrections are worth more than the rate.
N = 119 firms · Mobile 53 · Baldwin 66 · measured August 14, 2026 · 95% Wilson intervals · aggregate data CSV
Five numbers, with their error bars attached.
At N = 119 the interesting quantity is not any single rate. It is how far each rate moved when I fixed a measurement defect. The table below is the corrected position; the sections after it are how each correction was found and what it was worth.
| Measure | Count | Rate | 95% Wilson interval |
|---|---|---|---|
| Firms with no website of their own A floor, not a point estimate — see the frame-bias section. | 26 / 119 | 21.8% | 15.4% – 30.1% |
| Sites that do not load, among firms that have one After all three liveness corrections. The uncorrected figure was 18.3%. | 9 / 93 | 9.7% | 5.2% – 17.4% |
| Firms with no working web presence (none, or one that fails to load) | 35 / 119 | 29.4% | 22% – 38.1% |
| Origins with any Chrome UX Report field data 96.8% of these origins are below Google's traffic threshold for field data. | 3 / 93 | 3.2% | 1.1% – 9.1% |
| Sites missed by the first pass and recovered by an independent second search A null result. Zero recovered, from 26 firms re-searched independently. | 0 / 26 | 0% | 0% – 12.9% |
Every interval on this page is a Wilson score interval at 95%. Wilson rather than the textbook normal approximation because several of these proportions sit close enough to a boundary that the normal interval runs below zero, which is not a defensible thing to print.
96.8% of these origins have no field data at all.
Google publishes the Chrome User Experience Report: real performance data from real Chrome users, aggregated per origin. An origin only appears in it once it clears a traffic threshold. Of the 93 origins in this study, 3 have CrUX data. 90 do not — 96.8% [90.9–98.9], with the reason returned by the API being, in every case, insufficient traffic.
That reframes the entire study. I went in expecting to find slow websites. What the data mostly shows is websites nobody visits. A site can be perfectly built and still sit below the threshold, because the constraint is demand, not code. Rebuilding a site that nobody reaches produces a faster site that nobody reaches.
This is the finding that most directly undercuts what I sell. I am publishing it in the second section rather than a footnote because a study that only produces conclusions flattering to its author is not a study.
What I will not claim
I am not publishing a Core Web Vitals failure rate for this population, and neither should anyone else working from this kind of data. Core Web Vitals is officially assessed on field data. 96.8% of these origins have none. The assessment is not bad, or borderline, or failing — it does not exist. Lab LCP from a Lighthouse run is a diagnostic signal about one simulated load, not a verdict on a metric that is defined as the 75th percentile of real users.
The three origins that do carry field data are reported in the dataset with their LCP, CLS, INP and TTFB at the 75th percentile. Three is not a sample. It is three.
21.8% have no website. Read that as a floor.
Overture Maps is assembled from business-listing feeds. A company enters this frame by having been listed somewhere first. That is a selection rule, and it selects in exactly the direction that matters here: a firm with no website, no listing, and no online footprint of any kind cannot appear in the denominator, because nothing put it there.
So the 26 of 119 firms with no site are the ones who are listed somewhere and still have no site. The firms invisible to the frame are disproportionately the ones with the least digital presence. 21.8% is a lower bound on the true rate among all operating HVAC contractors in these two counties, and the gap between the two is a quantity this method cannot estimate.
Fold in the sites that exist but do not load and 35 of 119 firms — 29.4% [22–38.1] — have no working web presence at all. That figure inherits the same floor property, for the same reason.
The interval — 21.8% [15.4–30.1] — does not help with this. Wilson intervals describe how much a number would move under resampling from the same frame. They are silent about whether the frame is the right one. On this study the frame bias is almost certainly the larger error, and it points in a known direction, which is the most useful thing I can say about it.
Naive liveness checking overstated breakage by 89%.
The first pass did what most audits do: one HTTP request per site, score anything that is not 2xx or 3xx as broken. That produced 17 broken sites out of 93, or 18.3%. The final figure, after three corrections, is 9 out of 93 — 9.7%. The uncorrected number was 89% too high.
| Stage | Broken | Rate | 95% CI | What the stage adds |
|---|---|---|---|---|
| 1. Status code only | 17 / 93 | 18.3% | 11.7–27.3% | One unretried GET per site with a default user-agent. Anything that isn't 2xx or 3xx is scored broken. |
| 2. + browser headers, www/apex, http/https | 15 / 93 | 16.1% | 10–24.9% | Retry with a full browser header set, then try the www/apex counterpart and the other scheme. Recovered two sites — one 406, one 404 that resolved on its host variant. |
| 3. + bot-gate classification | 11 / 93 | 11.8% | 6.7–20% | A 403 carrying a WAF fingerprint (cf-mitigated header, challenge body) is a live site refusing a robot, not a dead site. Four reclassified. |
| 4. + PageSpeed Insights as independent arbiter | 9 / 93 | 9.7% | 5.2–17.4% | Real Chrome on Google's network, a second client with a different IP, TLS stack and rendering engine. Overturned two more — both sites load fine and score 100 and 97. |
The fourth stage is the one worth stealing. PageSpeed Insights runs real Chrome from Google's network. Pointing it at the same URL gives a second, genuinely independent client: different IP, different TLS stack, different rendering engine, run by a party with no stake in my answer. Where the two clients disagree, that disagreement is information, and it is free.
| Artifact | What was actually happening |
|---|---|
| A bot gate that returns HTTP 200 | One origin answered 200 OK with a 1,954-byte body titled “Checking your browser” — a JavaScript interstitial. Every status-code-only check scores that site perfectly healthy. Nothing about the response distinguishes it from a real homepage except reading the body. |
| A TLS stack that invents failures | The first liveness pass ran on macOS system Python against LibreSSL and returned TLSV1_ALERT_PROTOCOL_VERSION on 6 hosts. curl, on the same machine, reached all 6 on its unretried first request — 3 served a normal page, 3 served a bot challenge. Zero were actually broken. The measurement tool was the defect. |
| Bot walls are the single largest source of false failures | That same first pass recorded 20 hosts as 403 Forbidden. All 20 were live: 18 answered normally once a browser header set was attached, and the remaining 2 were bot-gated pages on functioning sites. |
| DNS failures, by contrast, were real | 8 hosts failed name resolution on the first pass. curl failed on all 8 too. When two independent clients agree a domain does not resolve, it does not resolve — the correction below is not a licence to explain away every negative. |
| One firm's two brands, joined by a 301 | hansenair.com returns a 301 to hansensupertechs.com. Two apparently distinct records in the source data are one company. That merge was found by following redirect chains in the liveness pass, not by any string-matching rule — which is the point: some duplicates are only visible from the network layer. |
The arbiter disagreed in both directions
Reporting only the disagreements that lower my number would be cherry-picking, so here are both. PageSpeed Insights overturned 2 sites my checker called broken — they load fine and score 100 and 97. It also contradicted 2 sites my checker called live, returning NO_FCP: Chrome loaded them and nothing painted.
The rule I committed to before seeing the results is that a site counts as live if either independent client reached it, so those two stayed in the live column. Under the stricter rule — broken if either client fails — the rate would be 11/93 = 11.8%. Both figures are in the dataset. Pick the rule that suits your question, but pick it before you look.
Place data is one record per listing, not one per business.
The query returned 175 candidate records. After exclusions, 145 records remained. Those 145 records are 119 businesses. One firm appeared 9 times — one record per service-area pin across six localities.
Counting records instead of firms inflates N by 47%. That alone would be a citation-integrity problem. The worse part is that it also biases the headline in a specific direction, because duplication is not random.
| Source records for the firm | Firms | Share with a website |
|---|---|---|
| 1 | 105 | 75.2% |
| 2 | 8 | 100% |
| 3 | 5 | 100% |
| 9 | 1 | 100% |
Every one of the 14 multi-listing firms in this frame has a website. 75.2% of the 105 single-listing firms do. Multi-location operators duplicate the most and are the most likely to have a site, so record-weighting quietly overstates web presence: the no-website rate comes out at 17.9% per record versus 21.8% per firm. Nearly four points, in the flattering direction, from a decision most people never notice they made.
How the linkage works, and the trap inside it
Union-find over three keys: last-ten-digits phone, a normalised name with legal suffixes and branch qualifiers stripped, and registrable domain. Records sharing any key collapse into one firm.
Manufacturer and directory domains have to be excluded from the domain key. Otherwise any two records carrying the same manufacturer URL collapse into one business and the frame silently loses a firm. This is not hypothetical here: trane.comappears on two candidate records — one contractor, one manufacturer supply office — and without the exclusion they would have merged. The same list does double duty: it is what identifies the listings whose website field points at something that is not the firm's site.
One merge could not be found by any string rule. hansenair.com returns a 301 to hansensupertechs.com — two brands, one company, visible only from the network layer. It surfaced while following redirect chains during the liveness pass. String matching gets you most of the way and then stops.
The websites field is wrong in both directions at once.
Every place dataset ships a website field, and it is the obvious thing to count. Counting it gets the headline wrong twice over.
| Error | Count | Effect on the headline |
|---|---|---|
| Blank, but the firm owns a working site | 8 of 29 blanks (27.6%) | Overstates the no-website rate. |
| Populated with a manufacturer or directory URL | 5 of 119 firms (4.2%) | Understates it, and pollutes any liveness or speed measurement taken from the field. |
29 of 119 firms (24.4% [17.5–32.8]) had a blank website field. A structured search pass — name, city, phone, likely domain guesses, chamber and BBB profiles — found real, firm-owned sites for 8of them. Every recovered domain had to clear a verification step before it counted: fetch the homepage and the contact page, then match the listing's phone digits, street number and distinctive name tokens against the page text. Guessing a plausible domain is not evidence that it belongs to the business.
In the other direction, 5listings carried a URL that is not the firm's website at all: ruud.com, rheemac.com, trane.com, americanstandardair.com, and an hvacnearyou.com profile page. Score those as “has a website” and you have credited a contractor with a manufacturer's marketing site. Run a speed test on them and the number you get describes a national brand's CDN, not a local business.
After both corrections the resolved position is 93 firms with an own website (85 from the listing field, 8 recovered by search) and 26 without one.
Getting the category filter right is harder than it looks.
The intuitive way to enumerate a trade is a keyword filter over the category field. It fails in both directions, and both failures are measurable here.
False positives. A regex including duct — a reasonable token for an HVAC filter — matches 61 records inside the bounding box. 2 of them are HVAC. The rest are broadcasting_media_production (27), music_production (17), beauty_product_supplier (6), and so on down. “Duct” is a substring of “production” and “products.” That is a 96.7% false positive rate on one token.
False negatives. Filtering on the primary category alone is the other trap, because the source data miscategorises real contractors. 10 of the 119 firms in the final frame — 8.4% — carry a primary category that is not hvac_services. One HVAC contractor is filed under electrician, another under plumbing, one under truck_dealer. A primary-category-only filter drops all of them.
What I did instead: select on the primary category, the alternate-category list, and a short set of adjacent primaries, then adjudicate every record from the wider tiers by hand with a written reason. Thirty records were excluded that way, and eleven were affirmatively kept. The reason codes ship in the dataset so anyone who would draw the line differently can say exactly where.
| Exclusion code | Records | Rule |
|---|---|---|
DISTRIBUTOR | 13 | Wholesale supply house selling to the trade, not a contractor a customer hires. |
NOT_HVAC | 7 | The HVAC tag is spurious; the core business is another trade or a consultancy. |
NON_BUILDING | 6 | Automotive, marine, transport refrigeration, or ice — does not service building HVAC. |
MANUFACTURER | 2 | Makes equipment; not a local service business. |
CHAIN | 1 | National brand operating on a corporate site. |
RENTAL | 1 | Temporary climate-equipment rental. |
A median mobile score of 66, and a verdict I am not entitled to.
PageSpeed Insights ran against all 93 resolved sites on the mobile strategy, Lighthouse 13.4.1, one run each. 82 returned a score; 11 failed, which is itself a measurement (7 could not load the document at all, 3 loaded and painted nothing).
| Statistic | Value |
|---|---|
| Minimum | 21 |
| 25th percentile | 50.25 |
| Median | 66 |
| 75th percentile | 73 |
| 90th percentile | 89.5 |
| Maximum | 100 |
| Mean | 63.6 |
| n | 82 scored of 93 attempted |
Percentiles by linear interpolation between order statistics.
| Lighthouse band | Sites | Share | 95% CI |
|---|---|---|---|
| 0–49 (Lighthouse “poor”) | 20 / 82 | 24.4% | 16.4–34.7% |
| 50–89 (“needs improvement”) | 53 / 82 | 64.6% | 53.8–74.1% |
| 90–100 (“good”) | 9 / 82 | 11% | 5.9–19.6% |
Distribution in tens: 20–29 → 2 · 30–39 → 11 · 40–49 → 7 · 50–59 → 10 · 60–69 → 16 · 70–79 → 22 · 80–89 → 5 · 90–100 → 9
The number I am deliberately not turning into a headline
75 of 82 scored sites — 91.5% [83.4–95.8] — recorded a lab LCP above 2,500 ms, with a median of 6,172 ms and a 75th percentile of 9,423 ms.
That is not a Core Web Vitals failure rate.It is a single simulated load under Lighthouse's mobile throttling, and it says nothing certain about what real users experience. Writing “91.5% fail Core Web Vitals” would be the most quotable sentence in this article and it would be false, for the reason in section 02: the assessment is field-based, and this population has no field data.
Lab CLS tells a different and more useful story — median 0.001, 75th percentile 0.031, with only 13.4% above the 0.1 threshold. Layout stability is broadly fine here. What fails is load time — images and render-blocking work, not layout.
The recall check found nothing. That is worth reporting.
A no-website finding is only as good as the search that failed to find a website. So all 26 firms in the no-site set went through a second, independent pass: fresh searches on name, city and phone in several formats, domain guesses, BBB and chamber-of-commerce profiles, Facebook and Nextdoor, averaging 3.4 distinct sources per firm.
It recovered 0 additional websites. 25 firms returned a confirmed not-found — several with a BBB profile that explicitly lists no website — and 1 had a domain listed in a chamber directory that no longer resolves. 19 of the 26 verdicts were recorded at high confidence, 7 at medium.
I expected the second pass to claw several back. It clawed back none. Miss rate 0%, 95% interval [0–12.9] — a zero numerator still leaves real room for error at n = 26, and the interval says so. The result makes the no-website finding more robust, and I would have published it either way.
The honest limit on that null
Both passes were performed by an agent, not a human. They share a failure mode: each can only see what a search index surfaces, and neither can telephone a business and ask. A firm whose site no index has crawled would be missed twice, and this check would not detect it. Two correlated passes are weaker evidence than two independent ones, and I am not going to pretend otherwise.
Promoting this pilot to a full study means calling the no-website set on the phone. That is the step that would turn the floor into an estimate.
What this study cannot support.
The frame is biased toward businesses that already have a digital presence
Overture Maps is assembled from business-listing feeds. A company enters this frame by having been listed somewhere first. A firm with no website, no listing, and no online footprint is invisible to the method entirely — it cannot appear in the denominator. That pushes the measured no-website rate DOWN. Read 21.8% as a floor. The true rate among all operating HVAC contractors in these two counties is higher by an amount this study cannot estimate.
The confidence intervals cover sampling error and nothing else
Every interval on this page is a Wilson score interval at 95%. It answers one question: if this frame is the population, how much would the number move under resampling? It does not cover frame bias, category-assignment error in the source data, or my own inclusion judgments. The frame bias is almost certainly larger than any interval shown.
The recall check was performed by an agent, not a human
The second-pass search for missed websites was run by a language agent with web search and fetch, averaging 3.4 independent sources per firm. Both passes share the same failure mode: they can only find what a search index surfaces. Neither can phone a business and ask. A business with a site that no index has crawled would be missed twice, and the null result below would not detect it. A full study requires human phone validation of the no-website set.
N = 119 is a pilot, not a census of anything larger
One trade, two counties, one day. The width of the intervals is the honest read: the no-website rate is somewhere in a 15-point band. This study is worth publishing for its method, not for its headline rate — the corrections are the transferable part.
Every inclusion and exclusion is a judgment I made
Thirty records were removed by hand, each with a written reason code. Eleven Tier B and C records were affirmatively kept for the same reason. Someone else drawing the contractor/distributor line differently would get a different N. The reason codes are published in the CSV so the disagreement can be specific.
One speed run per origin, homepage only
Lighthouse lab scores vary run to run. A single run per site is enough for a distribution and not enough for any individual verdict. Only homepages were measured; interior pages are frequently slower.
Everything needed to run it again.
The frame cost nothing and required no account. Overture Maps publishes its releases as Parquet on an anonymous S3 bucket; DuckDB reads them in place. The bounding-box pull returned 36,852 places in roughly 8 seconds on a laptop.
| Parameter | Value |
|---|---|
| Source | Overture Maps Foundation, theme = places overturemaps.org |
| Release | 2026-07-22.0 |
| Access | Anonymous S3, no key, no account, no rate limit |
| Engine | DuckDB 1.4.5 with the spatial extension |
| Bounding box | lon −88.50 to −87.35 · lat up to 31.40 Returned places span −88.49986 to −87.35004 lon and 30.22489 to 31.39923 lat. Three of the four edges are clamped by the filter. The southern edge is not: it sits offshore in the Gulf, so any ymin at or below 30.22 reproduces the identical set. |
| Places in the box | 36,852 Pulled in roughly 8 seconds. |
| County assignment | Spatial join against TIGER/Line 2024 county polygons STATEFP 01; COUNTYFP 097 (Mobile) and 003 (Baldwin). Not Overture's locality strings. |
| Join result | Mobile 18,805 · Baldwin 15,168 · outside both 2,879 The 2,879 (7.8%) are inside the rectangle but outside both counties — Mississippi, Florida, and the Gulf. A locality-string filter would have kept an unknown share of them. |
| Record filters | operating_status ≠ permanently_closed; confidence ≥ 0.6 |
| Speed measurement | PageSpeed Insights API, mobile strategy, Lighthouse 13.4.1, one run per origin |
| Field data | CrUX API, origin-level query, 28-day window ending 2026-08-13 |
The county assignment is worth isolating, because it is the step people skip. Overture records carry a locality string, and using it is tempting. I did a point-in-polygon join against TIGER/Line 2024 county boundaries instead. 2,879 of the 36,852 places in the rectangle — 7.8% — fall outside both county polygons. They are in Mississippi, in Florida, or in the water. A locality-string filter would have kept an unknown share of them and quietly changed the denominator.
-- 1. Pull the bounding box straight from Overture's public S3 bucket.
INSTALL spatial; LOAD spatial;
CREATE TABLE bbox_places AS
SELECT id, names.primary AS name,
categories.primary AS cat_primary,
categories.alternate AS cat_alt,
confidence, websites, phones, addresses,
operating_status,
ST_X(ST_GeomFromWKB(geometry)) AS lon,
ST_Y(ST_GeomFromWKB(geometry)) AS lat
FROM read_parquet(
's3://overturemaps-us-west-2/release/2026-07-22.0/theme=places/type=*/*',
hive_partitioning = 1)
-- The southern edge sits offshore, so it does not bind: any ymin at or
-- below 30.22 returns the identical 36,852 places.
WHERE bbox.xmin > -88.50 AND bbox.xmax < -87.35
AND bbox.ymin > 30.10 AND bbox.ymax < 31.40;
-- 2. Assign county by POINT-IN-POLYGON against TIGER/Line 2024,
-- not by trusting the locality string on the record.
CREATE TABLE bbox_county AS
SELECT p.*, c.county_name AS county_tiger
FROM bbox_places p
LEFT JOIN counties c
ON ST_Contains(c.geom, ST_Point(p.lon, p.lat));
-- 3. Candidate selection. Three tiers, each adjudicated by hand:
-- A = primary category is hvac_services
-- B = hvac_services appears in the ALTERNATE categories
-- C = an adjacent primary category
SELECT * FROM bbox_county
WHERE county_tiger IS NOT NULL
AND (operating_status IS NULL OR operating_status <> 'permanently_closed')
AND confidence >= 0.6
AND ( cat_primary = 'hvac_services'
OR list_contains(cat_alt, 'hvac_services')
OR cat_primary IN ('air_duct_cleaning_service',
'hvac_supplier',
'commercial_refrigeration') );Downstream of the query, five scripts do the rest: frame construction and record linkage, two-client liveness measurement, domain verification for search-recovered sites, the PageSpeed Insights and CrUX collection, and the assembly step that computes every interval on this page. A sixth is kept deliberately — the first liveness pass, LibreSSL bug intact, because the artifact it produced is half of section 04. They are not polished. They are the scripts that produced these numbers. If you want them for a replication, email me and I will send them.
Take the data.
The aggregate dataset behind every number on this page is a single CSV: one row per measured quantity, with section, numerator, denominator, value, unit, and both bounds of the 95% Wilson interval where a proportion is involved.
Download cds-hvac-web-presence-pilot-2026-08-14.csv
Why the per-firm data is not published
The working files name 119 real companies alongside a judgment about whether their website works and how fast it is. Publishing that as a list would be a reputational claim about named small businesses, made by someone who sells them websites, with no right of reply and no legal review. The published file is aggregate only, and no individual firm is identifiable in it. Every finding on this page holds at the aggregate level, which is the level the findings are about.
Cite this
Campbell, Peyton. “Measuring small-business web presence without Google Places: Three corrections, and what each one was worth.” Campbell Digital Studio, 2026-08-14. www.campbelldigitalstudio.com/research/measuring-small-business-web-presence.
Dataset and article licensed CC BY 4.0. Reuse anything here with attribution, including the numbers, the corrections, and the limits — particularly the limits.
Frame data © Overture Maps Foundation, release 2026-07-22.0, used under its own licence terms. County boundaries from US Census TIGER/Line 2024. Performance measurements from the Google PageSpeed Insights and Chrome UX Report APIs.
Measured and published August 14, 2026. Corrections and replications welcome — if a number here is wrong, I would rather know.
The same measurement discipline, applied elsewhere.
The public grading rubric, a market-wide audit run under it, and the sourced statistics pages that follow the same rule about what a number is allowed to claim.
Want this run on your market?
The method transfers to any trade in any county — the frame is free, the measurement is free, and the corrections are the same ones. If you want the same audit for your industry and your market, or you want to argue with the numbers above, send the details. The second one is more interesting to me.