← Back to blog

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.

An NFL Pick'em Among Friends

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.
ParticipantTeams
Player 1Chiefs, Dolphins, Cardinals
Player 2Bills, Buccaneers, Saints
Player 3Rams, Commanders, Colts
Player 4Ravens, Jets, Vikings
Player 5Lions, 49ers, Steelers
Player 6Broncos, Chargers, Falcons
Player 7Packers, Bengals, Titans
Player 8Seahawks, Jaguars, Bears
Player 9Eagles, Cowboys, Panthers
Player 10Texans, 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.

🌐 Read in Spanish
← Back to blog