# Vercel vs Cloudflare: TTFB and Lighthouse benchmark from Nairobi (2026-10-16)

Open data behind the blog post **[Vercel vs Cloudflare for a static Next.js site: what I measured from Nairobi](https://kaikhalid.com/blog/vercel-vs-cloudflare-nextjs-nairobi)**.

- **When:** 16 October 2026 (about 07:00–09:10 UTC, 10:00–12:10 EAT)
- **Where:** one Mac on one network in Nairobi, Kenya. The supplementary multi-region probe used public check-host.net nodes.
- **What:** the same build of a Next.js site on two hosts:
  - **Cloudflare:** https://kaikhalid.com (static export on Workers static assets, the live site)
  - **Vercel:** a Vercel deployment of the same code (plain `next build`; its hostname is withheld and appears as `vercel-deployment` in the files)
- **Pages:** home `/`, blog post `/blog/start-the-answer-early`, plus one static AVIF image (`avif`, TTFB only).
- **Licence:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)

## Pre-registered rule and verdict

The hypothesis, metrics and decision rule were written down in `PREREGISTRATION.md` before any measurement was taken.

- **H1:** "The site runs faster on Cloudflare than it did on Vercel."
- **ACCEPT** if the P1 (TTFB) CI favours Cloudflare by more than 20 ms on both pages, AND P2 (LCP) favours Cloudflare by more than 100 ms in at least 3 of 4 cells with no cell where Vercel is significantly faster, AND Cloudflare's P1 p95 is no more than 50 ms worse than Vercel's. **REJECT** if Vercel wins beyond the threshold on P1, or on P2 in at least 2 of 4 cells, or every difference falls inside the thresholds. Any other outcome is **PARTIALLY SUPPORTED**.

**Verdict: PARTIALLY SUPPORTED.** Cloudflare was faster on connection setup and TTFB (median about 250–260 ms better) and on mobile LCP (simulated 4G, +531 to +675 ms). Vercel was faster on simulated desktop LCP by about 77–79 ms. Cloudflare cleared the LCP bar in 2 of 4 cells, not 3.

| Headline medians | Vercel | Cloudflare |
|---|---|---|
| TTFB excl. DNS, round 1, home | 325 ms | 56 ms |
| TTFB excl. DNS, round 1, post | 328 ms | 59 ms |
| LCP home mobile (Lighthouse, simulated) | 2954 ms | 2280 ms |
| LCP post mobile | 3094 ms | 2563 ms |
| LCP home desktop | 529 ms | 607 ms |
| LCP post desktop | 519 ms | 595 ms |

## Method

- **P1, TTFB (`ttfb.csv`):** `curl` (via `timing.py`) on a fresh connection for every request, with `Accept-Encoding: br, gzip`. Each round has 100 pairs per page and the order within each pair is randomised. There were two rounds about an hour apart (round 2 came after the Lighthouse runs). The primary metric is `ttfb_excl_dns_ms` = time_starttransfer − time_namelookup. The statistic is the median paired difference (Vercel − Cloudflare) with a bootstrap 95% CI (`analyse_timing.py`).
- **P2, Lighthouse (`lighthouse.csv`):** Lighthouse 13.5.0 in headless Chrome, using the default simulated throttling with mobile and desktop (`--preset=desktop`) form factors. Each cell has 10 runs and the host order is interleaved at random. The statistic is the difference of medians with a bootstrap 95% CI (`analyse_lighthouse.py`).
- **Exploratory Lighthouse experiments** (outside the decision rule):
  - `aa_control`: an early script bug sent **both** labels to Cloudflare (kaikhalid.com). The 80 runs are kept as a same-host A/A control that shows the noise floor. In this dataset `host` is always the host actually requested, so both labels read `cloudflare`, and the original label is in `script_label`.
  - `no_beacon`: Cloudflare only, with the Cloudflare Web Analytics beacon blocked (`--blocked-url-patterns=*cloudflareinsights*`). The Vercel runs to compare against are the `main` runs.
  - `provided_throttling`: `--throttling-method=provided`, so no simulation is applied and the numbers reflect this Mac's real network.
- **Multi-region (`multiregion.csv`):** the check-host.net HTTP check, 3 cold requests per node and host. The report's comparison uses the 34 nodes where both hosts got at least 2 successful requests (`in_report_comparison = True`). On those nodes Cloudflare was faster in 23 and Vercel in 11, and the median difference was 49 ms in Cloudflare's favour.

## What this does not show

- One network and one location. The large gap is partly geography: Vercel's nearest edge to Kenya is Cape Town, Cloudflare's is Nairobi. Elsewhere the gap shrinks or reverses.
- The pre-move Vercel build used `/_next/image`; the Vercel copy tested here uses the build-time WebP images. The 2026-10-15 baseline (3 runs, kaikhalid.com on Vercel) agrees in direction (server response 80 vs 44 to 58 ms) and is not part of the statistics.
- Lighthouse's simulated desktop profile and the unthrottled run disagree on desktop; I have not pinned down why.
- Cache state after long idle, load, and cost were not tested. The Vercel copy is on a different hostname from the former custom domain, so DNS differs; the timings exclude DNS.
- No data from Safari, Firefox or real visitors.

Also note: every Cloudflare number was measured while Cloudflare was injecting its Web Analytics beacon into the HTML. That injection was switched off later the same day.

## Files

| File | Rows | Description |
|---|---|---|
| `ttfb.csv` | 1200 | One row per curl request (2 rounds × 3 pages × 100 pairs × 2 hosts) |
| `lighthouse.csv` | 280 | One row per Lighthouse run (main 80, aa_control 80, no_beacon 40, provided_throttling 80) |
| `multiregion.csv` | 109 | One row per check-host node × host |
| `dataset.jsonld` | – | schema.org `Dataset` metadata |
| `PREREGISTRATION.md` | – | Hypothesis and decision rule, written before measuring (unchanged) |
| `REPORT.md` | – | Full results report (unchanged) |
| `timing.py` | – | The curl timing harness that produced round1/round2 (unchanged) |
| `analyse_timing.py`, `analyse_lighthouse.py` | – | The analysis scripts (unchanged; they read the original raw CSV/JSON layout) |

The raw Lighthouse JSON reports (about 170 MB) and the run logs are not published here.

**Redactions.** On 2026-10-16, before publication, the commit hash, the Vercel test hostname (now `vercel-deployment`) and the `edge_ip` column of `ttfb.csv` (CDN server addresses, not used by any result) were removed from every file, and `timing.py` reads the Vercel address from the `VERCEL_URL` environment variable. No measurement was changed.

## Data dictionary

### ttfb.csv
| Column | Type | Meaning |
|---|---|---|
| `round` | int | Timing round: 1 (before Lighthouse) or 2 (about an hour later) |
| `pair` | int | Pair index 0–99 within a round and page. One Vercel and one Cloudflare request share a pair |
| `page` | str | `home` (/), `post` (/blog/start-the-answer-early) or `avif` (static image asset) |
| `host` | str | `vercel` or `cloudflare` |
| `hostname` | str | `vercel-deployment` (hostname withheld) or `kaikhalid.com` |
| `order_in_pair` | int | 0 = requested first in the pair, 1 = second |
| `status` | int | HTTP status code |
| `http_version` | str | HTTP version negotiated |
| `dns_ms` | float | curl time_namelookup (cumulative, ms) |
| `connect_ms` | float | curl time_connect (cumulative, ms) |
| `tls_ms` | float | curl time_appconnect (cumulative, ms) |
| `ttfb_ms` | float | curl time_starttransfer (cumulative, includes DNS, ms) |
| `total_ms` | float | curl time_total (cumulative, ms) |
| `ttfb_excl_dns_ms` | float | **Primary metric P1:** ttfb_ms − dns_ms |
| `tcp_connect_ms` | float | connect_ms − dns_ms |
| `tls_handshake_ms` | float | tls_ms − connect_ms |
| `server_wait_ms` | float | ttfb_ms − tls_ms (request to first byte after TLS) |
| `total_excl_dns_ms` | float | total_ms − dns_ms |
| `bytes` | int | Bytes downloaded (compressed) |
| `cache` | str | `x-vercel-cache` / `cf-cache-status` header value |

### lighthouse.csv
| Column | Type | Meaning |
|---|---|---|
| `experiment` | str | `main` (pre-registered P2), `aa_control`, `no_beacon` or `provided_throttling` |
| `host` | str | Host actually requested, taken from the report's requestedUrl (`vercel` / `cloudflare`) |
| `script_label` | str | Label the script gave the run. In `aa_control` the `vercel` label actually hit Cloudflare |
| `requested_url` | str | URL Lighthouse loaded |
| `page` | str | `home` or `post` |
| `form_factor` | str | `mobile` or `desktop` |
| `run` | int | Run number 1–10 within the cell |
| `throttling_method` | str | `simulate` or `provided` |
| `beacon_blocked` | bool | True if cloudflareinsights requests were blocked |
| `lcp_ms` | float | Largest Contentful Paint (ms). **Primary metric P2** in `main` |
| `fcp_ms` | float | First Contentful Paint (ms) |
| `tbt_ms` | float | Total Blocking Time (ms) |
| `cls` | float | Cumulative Layout Shift (unitless) |
| `server_response_ms` | float | Lighthouse `server-response-time` audit (ms) |
| `performance_score` | int | Lighthouse performance score 0–100 |
| `lighthouse_version` | str | Lighthouse version (13.5.0) |
| `fetch_time_utc` | str | Run timestamp (ISO 8601, UTC) |

### multiregion.csv
| Column | Type | Meaning |
|---|---|---|
| `node` | str | check-host.net node id (e.g. `de1` = de1.node.check-host.net) |
| `country_code` | str | ISO 3166-1 alpha-2 country of the node |
| `country` | str | Country name |
| `host` / `hostname` | str | Host tested |
| `request1_ms`..`request3_ms` | float | check-host HTTP check response time per request (ms); blank if it failed |
| `request1_status`..`request3_status` | str | HTTP status, `error: <message>` or `no result` |
| `n_ok` | int | Number of successful requests (0–3) |
| `median_ms` | float | Median of the successful requests (ms) |
| `in_report_comparison` | bool | True if both hosts had ≥2 successful requests at this node (the 34 nodes used in REPORT.md) |

## Citation

Khalid, K. (2026). Vercel vs Cloudflare TTFB and Lighthouse benchmark from Nairobi. kaikhalid.com. https://kaikhalid.com/data/benchmark-2026-10-16

Blog post: https://kaikhalid.com/blog/vercel-vs-cloudflare-nextjs-nairobi
