skip to content

Project write-up

The Tennis Almanac

The Tennis Almanac, Clutch Index page

The Tennis Almanac is a record of professional men’s tennis: every tour-level match from 1991 to 2024 with its statistics, every player, every ranking week. The data is Jeff Sackmann’s public ATP dataset, which I vendored when the original repository disappeared from GitHub. Almost all of the site is a frozen archive rendered once at build time. One page is not.

Decisions

Everything is static. There is no database and no server. The site prerenders 8,670 pages: 3,563 players, 5,022 tournament editions, 45 ranking weeks and the clutch pages. Exactly two routes render on request, both for the live board. Two scripts run as build gates: one fails the build if the CSV reader or the clutch data leaks into the worker bundle, the other fails it on any emoji.

A statistic that argues with the sport. Commentators quote break points saved as a measure of nerve. Across the 472 careers that qualify, it tracks ordinary serve quality with an R² of 0.81. It mostly measures how hard you serve. The Clutch Index strips that out: for each season it fits each pressure rate against the player’s ordinary play, keeps the residual, and shrinks it toward zero in proportion to how little evidence there is behind it. The four components are weighted by how much real signal each one carries, not by choice. Serving under pressure and returning under pressure carry most of it, tiebreaks some, and deciding sets none at all. The result is scaled to 50 with a standard deviation of 10 and built from 93,720 matches. Next Gen Finals are excluded because no-ad scoring makes every deuce a break point.

A live board on a free API. Today’s play comes from an API with a hundred calls a day, shared by every visitor. The board keeps one snapshot in KV and refreshes it lazily, every fifteen minutes while there is play and every hour when there is not, spending two calls per refresh and keeping twenty in reserve. The remaining budget is read from the API itself rather than counted locally, because a local tally drifts across deploys and previews sharing one key. Finished matches are behind a paywall, so results are inferred by watching matches drop off the live list, and labelled as observed rather than official.

The hard part

The site used to render on the server, and three of its pages returned 500 in production. The CSV reader called the filesystem, and Cloudflare Workers do not have one. Rather than patch around it, I made the whole site static, which removes that class of failure permanently.

Prerendering then exposed three problems that only show up at scale. The player profile scanned all 34 seasons per player, and the first full build was heading for an hour; an index brought a lookup to 3.5 milliseconds. Astro constructs data providers freely, so per-instance caches rebuilt the match index for every page, about a second each; hoisting the caches to module scope brought the whole build to 42 seconds. And a country flag component rendered 2,163 times on a ranking page; trimming it took that page from 71 to 56 kilobytes gzipped. Giving every match its own page would have meant 95,000 more of them, so matches are served as one JSON file per tournament edition and rendered on the client.

Stack

Astro 5 on Cloudflare Pages, KV for the live snapshot, satori for the Open Graph images, and the Node test runner for the score parser and the live board. Thirty-four season CSVs in the repository, about 52 megabytes.

tenis-app.pages.dev · Source

The Tennis Almanac