Winning the Midnight Court Race
London's public tennis courts release their best slots at midnight, seven days ahead. The contested ones — a 7 PM weeknight at a popular park — are gone within seconds. My other project, Time for Tennis, tells you the moment a slot appears. But knowing isn't booking: you still had to be awake at 00:00, fast fingers ready. So I built the other half — a bot that runs the midnight race for me, autonomously, and lets me shape the night from my phone before I go to sleep.
It sounds like a simple form-filler. It isn't. The interesting parts are all about what happens under load, at exactly midnight, when the booking server is at its slowest and a slot is won or lost in the gap between two HTTP requests.
The whole night, end to end
Here's the full flow — from the approval card at 10 PM to a booked court (or a graceful fail) a few hundred milliseconds after midnight.
pay_init is the whole reliability story.The non-obvious part: what actually holds a slot
The first thing I got wrong — and the lesson I'd keep from the whole project — is that the UI lies about when you've secured something. Adding a court to the basket logs a cheerful Secured: <courtId>, but it holds nothing. The slot is still free for anyone else to grab and pay for.
The slot is only locked when a call to pay_init returns a real Stripe PaymentIntent — and then only for about fifteen minutes. No PaymentIntent, no hold. Which means the entire window from "add to basket" to a successful pay_init is an unprotected race, and any latency there directly loses contested slots. Trust the payment intent, not the cart — the rest of the design follows from taking that seriously.
Failing fast under load
At midnight the server crawls — page loads that take 200 ms at noon take 13–23 seconds under release-time load. My first version re-scraped the booking page on every retry to "check" availability, burning two minutes across a few rounds and losing the slot anyway. The fix was to stop asking the page questions and start trusting the API's own verdict.
The booker now branches on the pay_init response, and this single split is the reliability story:
- 4xx → the request was rejected, so nothing is held. Fail fast, skip the expensive page scrape entirely, and move to the next candidate.
- 5xx, or a 200 with no PaymentIntent → ambiguous. A midnight 5xx can still have created a ~15-minute hold whose response got lost. So hand off to a headless Playwright session that opens
/accountand pays for whatever is genuinely held — or confirms nothing is, and lets the cascade continue.
Because the booker now owns hold-verification, a plain failure provably means nothing is reserved — which let me delete an unconditional 15–32 second account check that used to run between every candidate. Fast path stays fast; the slow, careful path only runs when the outcome is genuinely uncertain.
Degrading gracefully: the fallback cascade
A prime slot is often gone before you reach it, so the booker doesn't bet on one outcome. It walks a cascade — preferred court, then alternate courts, then a fallback time, then a different venue — and the first success stops it. The result is that a full 7 PM at one park still lands you a 8 PM there, or a 7 PM somewhere nearby, instead of nothing.
Driving it from your phone
Every night at 10 PM the bot sends a Telegram approval card. From the lock screen I can edit the venue, time, court order, and whether to pay with a banked coupon or a card — then approve, or fire a one-off override that supersedes the standing rules for just that night. No redeploy, no config files.
Because the booking happens at a moment I'm not watching, every decision is persisted to SQLite before midnight. If the process restarts at 23:59, it recovers the payment mode and any override and races anyway. A midnight job that can't survive a restart isn't really automated.
Stack & shape
TypeScript on Node, got for the fast HTTP path with a Playwright fallback for the ambiguous one, SQLite for crash-recoverable state, node-cron for the schedule, and a Telegram bot for the whole interface. Around 226 tests cover the failure branches, since those are the parts that only ever run once a night under conditions you can't reproduce on demand. It's deployed on Fly and pairs with Time for Tennis: that service watches and notifies, this one acts.
By the numbers
It's been running nightly in production against real money and real contention. As of early July 2026, from the Fly deployment's own logs and booking history:
- 16 courts booked fully autonomously since mid-May 2026 — 11 of them the contested 7 PM weekday slot that's gone within seconds of release.
- Fastest court secured ~26 seconds after midnight, ~60 seconds on average — pushing through page loads that balloon to 13–23 seconds each under release-time load.
- Runs unattended on a single 1 GB Fly machine, one race a night, no babysitting.
- It doesn't win every night, and that's the point: when the slot is genuinely gone, the cascade falls back or the run fails cleanly — it never reports a booking it didn't make. The early misses were integration bugs shaken out during bring-up; the rest are slots that were simply already taken.
What I'd take from it
- Trust the system's source of truth (the payment intent), not the UI's optimistic story (the cart).
- Classify failures and act differently — a 4xx and a 5xx mean opposite things about whether you hold a resource.
- Keep the fast path fast; only pay for the slow, careful verification when the outcome is genuinely uncertain.
- Degrade gracefully — a cascade of acceptable outcomes beats one all-or-nothing bet.
- If a job runs unattended at a fixed moment, it has to survive a restart at the worst possible second.