Sharing 100 Requests With Every User You Have

Most API rate limits are a personal problem. You get your bucket of requests, and if you blow through it, you wait. Strava’s read limit is a different animal: roughly 100 requests per 15 minutes and 1,000 per day, shared across your entire application. Every user of Pedal Wrencher draws from the same bucket. One greedy sync loop doesn’t throttle one athlete, it throttles all of them.
This single detail shaped more of the rebuild’s architecture than any framework choice, and I think the pattern generalizes to any app built on someone else’s quota.
Rate limiting is an architecture problem
The usual toolkit for rate limits is a retry decorator: catch the 429, back off, try again. That works when the limit is yours alone. With a shared limit, retrying is actively harmful. The request you retry comes out of the same bucket that every other user needs, so a hot retry loop turns one athlete’s bad sync into an outage for the whole app.
Once the quota is shared, you stop thinking about retries and start thinking about spending. Pedal Wrencher’s sync worker gets an explicit request budget at the start of each run, sized well under the 15-minute window. Every Strava call decrements it. When the budget hits zero, the worker doesn’t wait or retry. It stops, mid-list if necessary, and the athletes it didn’t reach are simply first in line next run. Freshness degrades gracefully for everyone instead of collapsing for someone.
When you do get a 429
Budgets are a prediction, and predictions miss. Other things count against the app’s quota (OAuth token refreshes, someone poking the API by hand), so occasionally the worker gets a 429 anyway. Strava sends a Retry-After header, and when it’s missing the safe assumption is the full 15-minute window.
The important move is what happens with that value: it gets written to the database as a cooldown timestamp, not held in memory. The worker runs as a scheduled job, so the process that hit the limit is long dead by the time the next run starts. The next run’s first act is to check the cooldown and, if it’s still in the future, exit without making a single request. The rate limit outlives the process, so the memory of hitting it has to outlive the process too.
Why polling and not webhooks
Strava does offer webhooks, and the conventional wisdom says push beats poll. I went with polling anyway, for a reason that follows from everything above: webhooks put someone else’s thumb on your spending.
A webhook fires per event, whenever users happen to ride. Group ride season, a sunny Saturday, a big local race, and suddenly your inbound events (and the API reads they trigger to fetch full activity data) spike in exactly the pattern the shared budget can’t absorb. You end up building a queue to smooth the spikes back out, at which point you’ve reinvented polling with extra infrastructure.
A polling worker on a schedule is the queue. It processes athletes in a controlled order, spends a known budget, and stops. For a maintenance tracker, latency is irrelevant anyway. Whether your chain’s odometer updates thirty seconds or two hours after your ride, the email lands the same day. Real-time is a cost here, and I’m buying nothing with it.
The general lesson
If your app builds on a shared quota, the rate limit is a first-class design input, on par with the data model. Concretely:
- Budget requests per run, and stop when the budget is spent, rather than retrying into a shared bucket.
- Persist cooldowns somewhere that outlives the process.
- Prefer pull over push when the provider bills events against your quota, because polling lets you choose the spending rate.
None of this is clever. All of it came from reading one sentence in the API docs (“rate limits apply per application”) and taking it seriously.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.