18 April 2026 · 540 words
Dripping train times past the rate limit
1088 new origin/destination pairs. A 30-second per-call public rate limit. A regen job that would take nine hours if you ran it as a single foreground script. The ops trick that turned it into a 24-hour background drip.
The problem
FOMO Sun precomputes a map of origin-city to destination-city train times so the hero and list cards can render a rough duration without a live API call on every render. Precomputation uses transport.opendata.ch, the free public SBB-adjacent feed. Lovely service. Lovely rate limit.
In practice the limit pencils out to about 30 seconds per origin/destination pair if you want to stay polite and not collect a 429. 1.9.0 added 64 destinations across 17 Swiss origins, which is 1088 new pairs. At 30s each that's 9 hours and change of blocking wall-clock for a single script — and that's the happy path. Retries on flaky pairs push it toward 12.
We tried running the regen as 1.9.3. It didn't finish. Halfway through we noticed the shape of the problem was wrong for a foreground job.
The decision not to block
The easy reflexes all had downsides:
- Run it overnight as a single script. Fine until the laptop goes to sleep, the network blips, or the tool chain crashes three hours in with no checkpoint.
- Parallelize with higher concurrency. The rate limit is the rate limit; parallelizing past it earns you 429s and a slower effective throughput.
- Pay for a higher tier. Fine in principle. Not worth the yak-shave right now — see fallback below.
The fourth option: embrace the rate limit. Build a small tool that does a chunk of the work, commits its output, and exits. Schedule it. Walk away.
The drip script
scripts/drip-train-times.mjs takes a --batch N/TOTAL flag, computes the 1/25 slice of unresolved pairs that belong to this batch (≈44 pairs), hits the API with the usual polite pacing, writes the results back to the precomputed map on disk, and exits. One batch takes roughly 3 minutes of wall-clock — well inside any reasonable cron window.
node scripts/drip-train-times.mjs --batch 1/25 node scripts/drip-train-times.mjs --batch 2/25 ...
A batch is idempotent. If it gets interrupted mid-pair, the next run re-resolves that pair and picks up the rest. No checkpointing gymnastics, no resume state — the source of truth is just "is this pair already in the map or not."
The launchd plist
The repo ships a macOS launchd plist template that fires the script every 15 minutes. 25 batches at 15-minute spacing is a little over 6 hours of cron wall-clock, but in practice with retry backoffs and network jitter the full 1088-pair backfill wraps in about 4 hours of drip, spread across roughly 24 hours of real time depending on laptop wakefulness.
No server needed. No cron on a VPS. The dev laptop does the work in the background while regular work happens. It's a technique that only really suits ops tooling for a solo-dev side project — but for exactly that context, it's dramatically less friction than standing up a real scheduler.
The fallback that made this OK
Crucially, we can afford to drip because the API doesn't block on the precomputed map. The heuristic fallback in src/app/api/v1/sunny-escapes/route.ts — haversine distance plus country modifier plus altitude adjustment — gives ±15% estimates for unknown pairs. Users see real train times where we have them and decent estimates where we don't. As the drip fills in, the precomputed pairs quietly replace estimates with exact numbers.
The lesson
Rate limits usually look like an obstacle. Sometimes they're a forcing function for better architecture. The drip design is simpler than the script-that-runs-for-9-hours — smaller surface area, trivially resumable, no long-running state. The version we would have shipped first would have worked; the version the rate limit forced us into is actually better.
Shipped 2026-04-18 as PR #43, no version bump. Full release notes in docs/RELEASES.md.