An NFL Pick'em Among Friends
From a forgotten spreadsheet to a FastAPI + React + Docker platform that pulls live results from ESPN, computes standings and refreshes every 90 seconds.
Every September the ritual repeats: the NFL kicks off and so does the pool among friends. For years the operation was artisanal — someone updated a spreadsheet by hand, scores circulated over WhatsApp, and the standings arrived late, or never. This year I decided to solve it the way I’d solve any product problem: by building a tool that does the dirty work on its own.
The result: a web platform with Material Design, fully responsive (phone, tablet, desktop), running in a Docker container and reachable from any device.
The rules of the game
The mechanics are the classic friends’ pool — simple and brutal:
- 10 participants with 3 NFL teams each, across the entire regular season (18 weeks).
- Any of your teams wins: +1 point. Loss or tie: 0 points.
- Tiebreaker: accumulated point differential of your three teams (PF − PA).
- The main view respects the sign-up order, with the top 3 spots by accumulated points highlighted at the top.
| Participant | Teams |
|---|---|
| Player 1 | Chiefs, Dolphins, Cardinals |
| Player 2 | Bills, Buccaneers, Saints |
| Player 3 | Rams, Commanders, Colts |
| Player 4 | Ravens, Jets, Vikings |
| Player 5 | Lions, 49ers, Steelers |
| Player 6 | Broncos, Chargers, Falcons |
| Player 7 | Packers, Bengals, Titans |
| Player 8 | Seahawks, Jaguars, Bears |
| Player 9 | Eagles, Cowboys, Panthers |
| Player 10 | Texans, Patriots, Raiders |
(The Browns and the Giants went unclaimed. Someone will have to explain that strategy.)
The interesting part: live results, no API key
Results are pulled from ESPN’s public API — no key or signup required. But there’s a catch: ESPN rejects “library-like” HTTP clients. When requests come from requests or httpx, the server detects the TLS fingerprint and responds as if the API didn’t exist.
The fix: curl_cffi, a library that mimics Chrome’s TLS handshake. As far as ESPN’s servers can tell, my backend is just another browser.
On top of that I added a SQLite cache with a smart TTL, designed around the NFL’s real rhythm:
- Finished weeks → refreshed every 12 hours (results don’t change).
- Future weeks → every 24 hours (only the schedule changes).
- Active week → every 90 seconds, so live scores update themselves while games are on.
And if ESPN fails or goes down, the last good cache is served: the app never shows a blank page.
The stack
backend/ FastAPI: ESPN client + SQLite cache + standings engine
frontend/ React + Vite + MUI (Material Design)
Dockerfile multi-stage: builds the frontend → slim final image
The backend exposes three endpoints: /api/health (service status), /api/standings (full standings, with ?refresh=1 to force a refresh) and /api/weeks/{n} (games and per-participant points for weeks 1 through 18). The season is configurable via an environment variable (NFL_SEASON), so next year the app keeps working without touching the code.
Reachable from any device
The app runs in Docker on a server in the local network: any phone, tablet or computer on the same WiFi can open it through the container’s port. But friends don’t live on my network — and I’m not opening router ports for a pick’em pool.
That’s what Tailscale Funnel is for: it exposes the service to the internet with HTTPS and a valid certificate, without opening a single port at home. That’s the link we share in the group chat — it works the same on mobile data or on someone else’s WiFi.
What the project taught me
What started as “a script to avoid manual updates” ended up being a mini product: a client that works around ESPN’s bot detection, a cache with per-week-state TTLs, failure fallbacks, a clean API, a responsive frontend and a reproducible Docker deployment.
It’s the same approach I bring to client work: identify the friction, automate the repetitive, and design for the real device where the tool gets used — in this case, the phone on the couch, on a Sunday afternoon, with the games live.
Next season starts on its own. So does the pool.
← Back to blog