Recipe: A Multi-Window Rate Pacer for Fitness APIs
Last verified November 5, 2026 · 10 min read
Fitness APIs publish rate limits as several windows enforced at once, and a client that watches only one of them finds the other by getting refused. Strava runs a 15-minute and a daily window, twice over, because non-upload calls have their own tighter pair. WHOOP runs a minute and a day. Wahoo runs five minutes, an hour and a day, and does not charge some calls at all. This recipe checks a request against every window that applies to it before sending, and waits for the one that is full.
The published numbers#
| Vendor | Windows | Source |
|---|---|---|
| Strava, new app | overall 200 / 15 min and 2,000 / day; non-upload 100 / 15 min and 1,000 / day | Strava guide, read Oct 17, 2026 |
| WHOOP, default | 100 / minute and 10,000 / day | WHOOP guide |
| Wahoo sandbox | 25 / 5 min, 100 / hour, 250 / day | Wahoo guide |
| Wahoo production | 200 / 5 min, 1,000 / hour, 5,000 / day | Wahoo guide |
Strava's non-upload limit covers "every endpoint except creating activities, uploads and media uploads"; its windows reset at 0, 15, 30 and 45 past the hour and at midnight UTC. Wahoo says authentication, token refreshes and file downloads do not count. WHOOP reports its windows in X-RateLimit-Limit, with the example 100, 100;window=60, 10000;window=86400, and the recipe parses that header so the policy can follow what the server says rather than what was true when this page was written. Higher tiers raise these numbers; pass your own policies when yours differ.
What the recipe does#
- Checks every applicable window and waits for the worst.
reserve(req)either records the request or returns the window that is full and how long until it has room. - Fixed where documented, sliding otherwise. Strava's windows are epoch-aligned; WHOOP's and Wahoo's are sliding logs, which can never let N+1 requests into any W-long stretch.
- Charges a request once it is sent. A 429 still counts, as Strava documents for its daily window.
- Scopes policies to requests.
appliesTokeeps uploads out of Strava's non-upload windows and Wahoo's uncounted calls out of every Wahoo window. - Adopts server-reported usage for fixed windows, so another process on the same app's quota is not invisible.
The implementation#
The canonical file is cookbook/multi-window-rate-pacer.mjs in this site's repository, and cookbook/multi-window-rate-pacer.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and needs Node 20 or newer; the clock and the sleep are injectable, which is how the tests run a simulated day in milliseconds.
/**
* multi-window-rate-pacer.mjs — spend a vendor's documented request budget
* on purpose, across every window it enforces at once, instead of finding the
* limit by collecting 429s.
*
* WHAT THIS IMPLEMENTS
* `createPacer({ policies })` checks each request against every policy
* that applies to it before it is sent. `reserve(req)` either records the
* request and returns `{ ok: true }`, or returns `{ ok: false, waitMs,
* policy }` naming the window that is full and when a slot opens. `run(req,
* fn)` waits and then calls `fn`.
*
* Two kinds of window:
* fixed resets on a clock boundary. Strava documents its 15-minute
* window resetting at 0, 15, 30 and 45 past the hour and its
* daily window at midnight UTC.
* sliding no boundary documented. A sliding log of N per W is the safe
* reading: if no W-long stretch holds more than N requests, no
* aligned window can either, wherever the vendor puts it.
*
* Every request a policy covers counts once it is sent, whatever the
* response. Strava says requests that break the short-term limit still
* count toward the daily one, so a 429 is not a refund.
*
* Presets carry only the numbers the vendors publish, as quoted on the
* site's integration guides:
* STRAVA (new app) overall 200 / 15 min and 2,000 / day; non-upload
* 100 / 15 min and 1,000 / day (every endpoint except
* creating activities, uploads and media uploads)
* WHOOP (default) 100 / minute and 10,000 / day
* WAHOO sandbox 25 / 5 min, 100 / hour, 250 / day
* WAHOO production 200 / 5 min, 1,000 / hour, 5,000 / day; auth,
* token refreshes and file downloads do not count
* `parseWhoopLimitHeader` reads WHOOP's X-RateLimit-Limit ("100,
* 100;window=60, 10000;window=86400") so the policies can follow what the
* server reports rather than what was true when this file was written.
*
* PAGES THIS SUPPORTS
* https://aifitnessapi.com/integrate/strava-api
* https://aifitnessapi.com/integrate/whoop-api
* https://aifitnessapi.com/integrate/wahoo-api
* https://aifitnessapi.com/cookbook/rate-limit-fetcher (what to do on a 429)
*
* Clock and sleep are injectable. No dependencies. Node 20+.
*
* MIT — from aifitnessapi.com/cookbook
*/
const MIN = 60_000;
const DAY = 86_400_000;
/** @typedef {{ name: string, limit: number, windowMs: number, kind: "fixed"|"sliding", appliesTo?: (req: object) => boolean }} Policy */
const isStravaUpload = (req) => req.upload === true;
const wahooCounts = (req) => !["auth", "refresh", "file"].includes(req.kind);
export const PRESETS = Object.freeze({
STRAVA: [
{ name: "strava overall 15 min", limit: 200, windowMs: 15 * MIN, kind: "fixed" },
{ name: "strava overall daily", limit: 2000, windowMs: DAY, kind: "fixed" },
{ name: "strava non-upload 15 min", limit: 100, windowMs: 15 * MIN, kind: "fixed", appliesTo: (r) => !isStravaUpload(r) },
{ name: "strava non-upload daily", limit: 1000, windowMs: DAY, kind: "fixed", appliesTo: (r) => !isStravaUpload(r) },
],
WHOOP: [
{ name: "whoop minute", limit: 100, windowMs: MIN, kind: "sliding" },
{ name: "whoop day", limit: 10000, windowMs: DAY, kind: "sliding" },
],
WAHOO_SANDBOX: [
{ name: "wahoo 5 min", limit: 25, windowMs: 5 * MIN, kind: "sliding", appliesTo: wahooCounts },
{ name: "wahoo hour", limit: 100, windowMs: 60 * MIN, kind: "sliding", appliesTo: wahooCounts },
{ name: "wahoo day", limit: 250, windowMs: DAY, kind: "sliding", appliesTo: wahooCounts },
],
WAHOO_PRODUCTION: [
{ name: "wahoo 5 min", limit: 200, windowMs: 5 * MIN, kind: "sliding", appliesTo: wahooCounts },
{ name: "wahoo hour", limit: 1000, windowMs: 60 * MIN, kind: "sliding", appliesTo: wahooCounts },
{ name: "wahoo day", limit: 5000, windowMs: DAY, kind: "sliding", appliesTo: wahooCounts },
],
});
/**
* WHOOP's X-RateLimit-Limit: the first value is the current quota, then one
* `limit;window=seconds` item per window.
* @returns {{ limit: number, windowMs: number }[]}
*/
export function parseWhoopLimitHeader(value) {
if (typeof value !== "string") return [];
const out = [];
for (const item of value.split(",")) {
const [count, ...params] = item.trim().split(";");
const window = params.map((p) => p.trim()).find((p) => p.startsWith("window="));
if (!window) continue;
const limit = Number(count);
const seconds = Number(window.slice("window=".length));
if (Number.isInteger(limit) && limit > 0 && Number.isFinite(seconds) && seconds > 0) out.push({ limit, windowMs: seconds * 1000 });
}
return out;
}
/** Policies for WHOOP from a header value, falling back to the published defaults. */
export function whoopPoliciesFromHeader(value) {
const parsed = parseWhoopLimitHeader(value);
if (!parsed.length) return PRESETS.WHOOP;
return parsed.map((p) => ({ name: `whoop ${p.windowMs / 1000}s`, limit: p.limit, windowMs: p.windowMs, kind: "sliding" }));
}
/** Start of the fixed window holding `t` (epoch-aligned, so UTC quarter hours and UTC midnight). */
export function fixedWindowStart(t, windowMs) {
return Math.floor(t / windowMs) * windowMs;
}
/**
* @param {{ policies: Policy[], now?: () => number, sleep?: (ms: number) => Promise<void> }} opts
*/
export function createPacer({ policies, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
if (!Array.isArray(policies) || !policies.length) throw new TypeError("give at least one policy");
for (const p of policies) {
if (!(p.limit > 0) || !(p.windowMs > 0) || !["fixed", "sliding"].includes(p.kind)) throw new TypeError(`bad policy ${p.name}`);
}
/** Per policy: sent timestamps (sliding) or { start, count } (fixed). */
const state = policies.map((p) => (p.kind === "sliding" ? { log: [] } : { start: -1, count: 0 }));
function waitFor(i, t) {
const p = policies[i];
const s = state[i];
if (p.kind === "fixed") {
const start = fixedWindowStart(t, p.windowMs);
const count = s.start === start ? s.count : 0;
return count < p.limit ? 0 : start + p.windowMs - t;
}
while (s.log.length && s.log[0] <= t - p.windowMs) s.log.shift();
return s.log.length < p.limit ? 0 : s.log[0] + p.windowMs - t;
}
function applicable(req) {
return policies.map((p, i) => [p, i]).filter(([p]) => !p.appliesTo || p.appliesTo(req));
}
function reserve(req = {}) {
const t = now();
let worst = null;
for (const [p, i] of applicable(req)) {
const w = waitFor(i, t);
if (w > 0 && (!worst || w > worst.waitMs)) worst = { ok: false, waitMs: w, policy: p.name };
}
if (worst) return worst;
for (const [p, i] of applicable(req)) {
const s = state[i];
if (p.kind === "sliding") s.log.push(t);
else {
const start = fixedWindowStart(t, p.windowMs);
if (s.start !== start) Object.assign(s, { start, count: 0 });
s.count++;
}
}
return { ok: true };
}
/** Wait until every applicable window has room, then send. */
async function run(req, fn) {
for (;;) {
const r = reserve(req);
if (r.ok) return fn();
await sleep(r.waitMs);
}
}
/**
* Adopt a server-reported usage for a fixed window (e.g. Strava's
* X-RateLimit-Usage "15-minute, daily" pair) when it is higher than ours:
* other processes sharing the app's quota are invisible otherwise.
*/
function observeUsage(policyName, used) {
const i = policies.findIndex((p) => p.name === policyName);
if (i < 0 || policies[i].kind !== "fixed") return;
const start = fixedWindowStart(now(), policies[i].windowMs);
const s = state[i];
if (s.start !== start) Object.assign(s, { start, count: 0 });
s.count = Math.max(s.count, used);
}
/** How much each policy has left right now. */
function remaining() {
const t = now();
return policies.map((p, i) => {
waitFor(i, t);
const s = state[i];
const used = p.kind === "sliding" ? s.log.length : s.start === fixedWindowStart(t, p.windowMs) ? s.count : 0;
return { policy: p.name, remaining: p.limit - used };
});
}
return { reserve, run, observeUsage, remaining };
}
/** Strava's usage headers are a "15-minute, daily" pair. */
export function parseStravaUsage(value) {
if (typeof value !== "string") return null;
const [short, daily] = value.split(",").map((s) => Number(s.trim()));
return Number.isFinite(short) && Number.isFinite(daily) ? { short, daily } : null;
}
How to adapt it#
Share one pacer per quota, not per worker. Strava's and WHOOP's limits are per application; a pacer per process multiplies your budget on paper and nowhere else. Run the pacer where the quota lives, or feed every response's usage headers into observeUsage.
Keep a 429 handler underneath. The rate-limit fetcher handles the refusals the pacer cannot predict.
Tag requests. Pass { upload: true } for Strava uploads and { kind: "auth" | "refresh" | "file" } for Wahoo calls that do not count.
The contract the tests hold it to#
WHOOP's documented header example parses to its two windows. Strava's non-upload window fills at 100 while uploads still have room; a window filled at 10:14 opens at 10:15; the daily window turns over at midnight UTC; a higher server-reported usage closes the window early. A sliding window never admits more than N requests in any W-long stretch. Wahoo's uncounted calls pass a full window, and a sandbox day admits exactly 250. A simulated burst of 350 WHOOP calls through run never puts 101 requests inside a minute.
What it deliberately does not do#
It does not share state across processes (that needs a shared store such as Redis, with the same arithmetic), it does not retry, and it does not know Oura's or Polar's limits, which depend on tier or user count; give it those as policies.
Frequently asked questions
Why pace a fitness API client-side when the server already returns 429?
Because a 429 is not a refund. Strava says requests that break the short-term limit still count toward the daily one, so a client that learns its limit by hitting it spends daily budget on refusals, and several workers sharing one app's quota will all hit it together. Pacing before the request keeps every window under its limit by construction and turns the limit into a wait you can schedule around. A reactive 429 handler is still needed underneath, for usage you cannot see, such as another deployment on the same credentials; the site's rate-limit fetcher recipe is that layer.
When do Strava's API rate-limit windows reset?
Strava documents a 15-minute window that resets at 0, 15, 30 and 45 minutes past the hour and a daily window that resets at midnight UTC, as the site's Strava guide quotes them from October 17, 2026. They are fixed windows, so 100 non-upload requests sent at 10:14 leave you waiting one minute, not fifteen. The recipe computes each window's start by flooring the epoch time to the window length, which lands on UTC quarter hours and UTC midnight, and it tests both boundaries. Usage headers report a 15-minute and a daily count, and the pacer can adopt them when they are higher than its own.
How should a rate pacer handle a vendor that does not say when its windows reset?
Pace it as a sliding window. If no stretch of W milliseconds ever contains more than N of your requests, then no window of length W can contain more than N either, wherever the vendor anchors its boundaries. That costs some throughput at the edges compared with a fixed window you knew about, and buys correctness without a fact you do not have. The recipe uses sliding windows for WHOOP's per-minute and per-day limits and Wahoo's five-minute, hourly and daily limits, and fixed windows only for Strava, whose boundaries are documented.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Corrections that change a page are logged, and credited by name if you ask: contributors.
Cite this page
- Title
- Recipe: A Multi-Window Rate Pacer for Fitness APIs
- Publisher
- AIFitnessAPI
- URL
- https://aifitnessapi.com/cookbook/multi-window-rate-pacer
- Last verified
AIFitnessAPI. "Recipe: A Multi-Window Rate Pacer for Fitness APIs." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/multi-window-rate-pacer
@misc{aifitnessapi-cookbook-multi-window-rate-pacer,
author = {{AIFitnessAPI}},
title = {Recipe: A Multi-Window Rate Pacer for Fitness APIs},
year = {2026},
url = {https://aifitnessapi.com/cookbook/multi-window-rate-pacer},
urldate = {2026-11-05},
note = {Last verified 2026-11-05}
}Cite the page, not the site — every page carries its own verification date.
Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All cookbook · by AIFitnessAPI