Pitwall is my Formula 1 stats app. It lives at pitwall.chrisdevcode.com, talks back on Telegram via @pitwallBycdc_bot, and the source is on GitHub. I built it because the public F1 APIs are great, but nobody had stitched them together with the views and the chat surface I actually wanted to use during a race weekend.
This article walks through how it's put together: the architecture, the two data sources, the rate-limit budget, the idempotent ingest, the cache invalidation trick, and the Telegram bot that shares everything with the web app.
What Pitwall Does
A short feature tour, so the rest of the article has context:
- Per-round driver and constructor standings, snapshot-per-round.
- Championship contenders math (who can mathematically still win).
- Most improved driver and constructor (second-half average minus first-half average of points per round).
- Telemetry: per-lap times, sector times, tyre stints, top speeds, fastest laps.
- A Telegram bot that exposes the same features as the web UI.
The Stack
One paragraph, no surprises:
- Django 5 with HTMX for the UI (server-rendered templates, no JavaScript framework).
- Celery + Redis for background jobs and the scheduler.
- Postgres for storage.
- Docker Compose for everything:
web,worker,beat,bot,postgres,redis. - Nginx + Certbot in front for TLS.
- jolpica-f1 as the primary data source (the Ergast successor).
- FastF1 for lap-level telemetry, layered on top.
pyTelegramBotAPIfor the bot (sync, which pairs naturally with Django's sync ORM (Object-Relational Mapper), nosync_to_asyncceremony).
Architecture in One Breath
jolpica ──► seasons/services/sync.py (idempotent update_or_create)
│
FastF1 ──► telemetry/services/sync.py (idempotent update_or_create)
│
▼
Postgres ──► analytics/services.py (pure DB reads)
──► telemetry/services/queries.py (pure DB reads)
│
▼
web/views.py (HTMX) ──► templates/web/*.html
bot/handlers.py ──► TelegramTwo writers, one database, two readers, two surfaces. The split that matters: ingest is the only thing that talks to the network, and reads are pure database queries. Views and bot handlers never call jolpica or FastF1 directly.
Two Data Sources, In Order of Trust
jolpica is truth for results, qualifying, and standings. When I want the championship standings for 2025 round 7, I read the Standing row that was written when round 7 finished. I never recompute championship points from Result rows in application code, because jolpica already publishes the official tally and any reconstruction is a guaranteed bug source.
Team attribution lives on Result.constructor, not a static driver-to-team map. This matters more than it sounds: mid-season driver swaps would silently mis-attribute team stats if I stored "Lewis drives for Ferrari" as a flat fact instead of per-result.
FastF1 is additive only. It writes to four telemetry tables (Session, SessionStat, Lap, Stint) and it never overwrites the jolpica tables. Coverage starts in 2018, which is the earliest year FastF1 has reliable telemetry for. Pre-2018 seasons stay jolpica-only.
Respecting jolpica's Rate Limits
jolpica's unauthenticated quota is generous but real:
- 4 requests per second burst.
- 500 requests per hour sustained.
- No token, no auth header, just be nice.
The client at seasons/services/jolpica.py enforces both ceilings:
INTER_CALL_SPACER = 0.3 # seconds, keeps burst under 4 req/s
HOURLY_CAP = 480 # leaves 20 req/hr of headroom under the 500 ceiling
_RECENT_CALLS: deque[float] = deque()
def _throttle():
now = time.monotonic()
while _RECENT_CALLS and now - _RECENT_CALLS[0] > 3600:
_RECENT_CALLS.popleft()
if len(_RECENT_CALLS) >= HOURLY_CAP:
sleep_for = 3600 - (now - _RECENT_CALLS[0])
time.sleep(sleep_for)
_RECENT_CALLS.append(time.monotonic())
time.sleep(INTER_CALL_SPACER)A sliding-window deque tracks the last hour of call timestamps. When the window is full, the client blocks until the oldest call ages out. On 429 Too Many Requests, it honors the Retry-After header if present, otherwise falls back to exponential backoff (1, 2, 4, 8, 16, 32, 64 seconds, up to 7 retries).
The full-history backfill (python manage.py backfill_history --start 1950) is resilient by design: a single round that fails after all retries gets logged and skipped, so one bad season doesn't blow up the whole job.
Idempotent Ingest
Every writer uses update_or_create keyed on natural keys. Re-running any sync task on unchanged data must be a no-op. This is the rule that makes the scheduler safe to over-fire.
Result.objects.update_or_create(
round=round_obj,
driver=driver_obj,
session=session_kind,
defaults={
"constructor": constructor_obj,
"position": position,
"points": points,
"status": status,
},
)For telemetry, Lap and Stint use bulk_create(update_conflicts=True) for speed, and the UniqueConstraint on (session, driver, number) is what makes the upsert correct. Telemetry syncs also prune stale rows when a corrected FastF1 response no longer contains them; otherwise old data would shadow the corrections.
Cache Invalidation Without Tears
Cache invalidation is famously hard. I sidestepped it with a single version key:
# at the end of every successful sync task:
cache.set("f1:ver", now().isoformat())
# in the view layer:
key = f"contenders:{year}:{cache.get('f1:ver', '0')}"
cached = cache.get(key)
if cached is not None:
return cachedWhen data changes, every cached view misses simultaneously. That's a touch wasteful on CPU, and it's also unambiguously correct, which is the trade I wanted. Cached entries fall out via Redis TTL after 24 hours, so stale entries from old version keys don't accumulate.
If I ever mutate data outside the regular sync path (a one-off admin command, a manual fix), I bump the version key myself so views don't keep serving stale snapshots.
The Telegram Bot, Same Brain Different Face
The bot lives under bot/ and shares everything with the web app. bot/formatters.py calls the same analytics.services.* functions that the views call. No duplicated logic.
Two transports for one set of handlers:
- Polling (dev):
python manage.py run_telegram_botblocks the process and long-polls. ThebotDocker Compose service runs this. IfTELEGRAM_BOT_TOKENis unset, the worker exits cleanly so dev environments without a token aren't broken. - Webhook (prod):
bot.views.telegram_webhookis mounted at/telegram/webhook/<secret>/. The secret path segment is constant-time-compared against the env var; a mismatch returns 404 so the existence of the endpoint isn't leaked. The view is@csrf_exemptand catches all exceptions so a malformed payload never causes a 5xx (Telegram retries on non-2xx, which would amplify the problem).
Every bot reply is prefixed with 🏎️ Pitwall so users recognize the source. Every command logs through _log_message(message, label) into a rotating bot.log, with user id, username, chat id, command label, and a truncated message body. Audit trail without leaking full content.
Docker Compose Layout
Six services on one host:
services:
web: # Django + gunicorn behind Nginx
worker: # Celery worker
beat: # Celery beat scheduler
bot: # Telegram poller (or idle if no token)
postgres: # data
redis: # broker + cacheA named volume fastf1cache is mounted into web, worker, and bot so the on-disk Parquet cache that FastF1 maintains is shared across containers. First sync of a session is slow (FastF1 downloads the timing data), every subsequent read hits the cache.
Logs are rotated by stdlib RotatingFileHandler to /var/log/pitwall/web.log and /var/log/pitwall/bot.log, with stdout handlers kept attached so docker logs still works.
The Boring Decisions That Paid Off
A few things I hesitated on, all of which I'd do again:
- HTMX over a JavaScript framework. The whole point of Pitwall is "show me a table fast". Server-rendered Django templates with HTMX swaps gave me a snappy UI in a tenth of the code a React app would have needed.
- Sync Django ORM with sync
pyTelegramBotAPI. Async would have meantsync_to_asynceverywhere or a separate async ORM. The bot is I/O bound on Telegram, not on database queries, so sync is plenty. - Lazy import of FastF1.
fastf1pulls in pandas and numpy, which are heavy. Importing it lazily inside the client wrapper means the bot and the jolpica sync path don't pay that cost just to run. - One placeholder
app/directory I never deleted. Django'sstartappcreates a full set of stub files. I keep them, even when empty, because the cost of regenerating is higher than the cost of an unused file.
What I'd Do Next
A short list, because the article should match the project's depth:
- Concurrent jolpica fetches to shorten the full-history backfill. This means revisiting the 500/hr budget, probably with a token bucket shared across worker processes through Redis.
- A Grafana board on top of Prometheus metrics from the Django app, the same setup I covered in my Django on VPS deployment article.
- More telemetry views: stint comparison, sector deltas, qualifying micro-sectors.
Try It
- Live site: pitwall.chrisdevcode.com
- Telegram bot: @pitwallBycdc_bot
- Source code: github.com/achingachris/f1-pitwall
If you build something on top of jolpica or FastF1, ping me on the bot, I'd love to see it.
Written and Authored by Chris, Edited and assisted by Claude