A 5-hour window tracker is mostly a countdown problem, not a percent problem
The percent on its own is half information. 92 percent with reset in 22 minutes is a coffee break. 92 percent with reset in 3 hours is a switch-to-Sonnet decision. The Settings page on claude.ai shows the percent and a binary low/reset label. A tracker has to render both axes (how full, and how long until empty) on a single glance. This page walks through the resets_at humanization math that turns the server’s ISO 8601 timestamp into the “22m” or “3h” you actually plan around.
Step 1: what the server actually hands you
Hit GET /api/organizations/{org_uuid}/usage on claude.ai with your existing logged-in cookies. This is the same endpoint the Settings page fetches to draw its own bar. The interesting field for the countdown is five_hour.resets_at, an ISO 8601 string with millisecond precision:
Two things to notice. The timestamp is UTC (Z suffix), so any countdown has to convert against the local clock at render time. And it is the moment the oldest weighted prompt in the rolling window will age out, which means the next prompt you fire can move that timestamp forward by minutes. The countdown is not stable; it is a live rolling boundary.
Step 2: the humanization function (the anchor of this whole page)
A timestamp is not a tracker. The tracker is what turns the timestamp into a label that fits on a 6-character badge surface and tells you which decision to make. ClaudeMeter does that in eleven lines of JavaScript:
Four output bands, three thresholds. The 1-hour boundary is where “60m” would feel coarse next to “1h”. The 48-hour boundary is where “50h” starts to scan worse than “2d”. The 0-millisecond floor catches the brief window where resets_at has already passed but the server has not yet recomputed against the next-oldest weighted prompt. The bands are picked for one-glance legibility, not for precision; precision is a bug here, not a feature.
Step 3: gluing the countdown to the bucket label
The popup row is one line per bucket. Three pieces of information, assembled in twelve more lines:
The label looks like 5-hour · 22m. The middle dot is U+00B7, deliberately not a hyphen; a hyphen next to a digit reads like a negative sign on first glance and the countdown should never read as negative. The bar width is clamped at 100 because the server can return values over 1.0 on overage, and an unclamped bar would push past the 320-pixel popup column and break the layout.
Step 4: why this only works because the poll is fixed at 60 seconds
A countdown derived once and then ticked locally drifts within minutes when the underlying boundary is rolling. Every new prompt can slide resets_at forward by however long the oldest aging prompt was holding the window. The tracker re-fetches the JSON every minute and re-runs the band, which is the only way the label stays honest:
The cadence is intentional. No exponential backoff, no on-focus poll, no jitter. Predictable cadence is what lets you trust that the number on the badge reflects the last full minute of state. The chrome.alarms registration on onInstalled and onStartup covers the case where the service worker has been put to sleep by Chrome between sessions; setInterval would not survive that.
What it actually looks like
A snapshot of the popup at the start of an Opus session, with one 5-hour bucket close to the cap:
17 minutes later, after one heavy prompt, the same surface looks like this:
The percent ticked from 92 to 94 (small move). The countdown ticked from 22m to 9m (much bigger move). That ratio is the whole point: a rolling window can move the reset countdown by minutes for every one or two percent of utilization burned, because what is aging out is a single weighted prompt at a time, not a smooth curve. Watch the countdown, not the percent.
End-to-end transformation, one timestamp
One ISO 8601 string from the server, one popup row at the end. Six steps in between, each implemented in a handful of lines.
resets_at to popup row
The whole transformation, in steps
Step 1: server returns ISO 8601
claude.ai/api/organizations/{org_uuid}/usage returns five_hour.resets_at as a millisecond-precision ISO 8601 string ('2026-04-28T19:36:14.221Z'). The same Settings page bar uses this exact string. The tracker fetches it with credentials: 'include' so it rides the existing logged-in session, no API key, no cookie paste.
Step 2: parse to a Date
popup.js line 19 calls new Date(iso). Browsers parse the Z-suffixed millisecond format natively, so there is no library dependency. The Date object stores UTC internally, which is what the math wants.
Step 3: subtract Date.now()
popup.js line 21 computes diff = d - now. Date arithmetic in JS coerces both sides to milliseconds since the epoch, so diff is a signed millisecond delta. Negative means resets_at has already passed; positive means it is in the future.
Step 4: band into one of four shapes
popup.js lines 22 to 26. <=0 returns 'now'. <1 hour returns minutes. 1-48 hours returns hours. >=48 hours returns days. Math.round at every step. The output is always two or three characters, never a precision-pretending string.
Step 5: prepend the bucket label
popup.js line 33 assembles `${label} · ${resets}`. So '5-hour' alone if resets came back empty, '5-hour · 22m' if it did. The middle dot is U+00B7. The label slot accepts any bucket name the API ships, which is why Anthropic adding seven_day_sonnet did not break anything.
Step 6: redraw on the next 60-second tick
background.js line 3 sets POLL_MINUTES = 1, registered as a chrome.alarms tick. Every 60 seconds the extension refetches /usage, normalizes utilization, recomputes resets_at into the band, and rewrites the popup HTML. If you keep prompting, the band moves; if you go idle, it stops.
Six invariants the tracker holds, all derived from the rolling-window shape
What a 5-hour tracker has to get right
- The countdown is one unit, never two. '22m' but not '0h 22m'. '3h' but not '3h 14m'. The unit is whichever band fmtResets fell into. Compound countdowns flicker on the second tick and burn pixels.
- The countdown re-derives from a fresh resets_at every minute, not from the last value minus 60 seconds. The window is rolling, so a stored countdown drifts; only the server knows where the boundary now sits.
- Negative diffs collapse to 'now', not to a negative duration. resets_at can briefly land in the past while the server recomputes against the next-oldest weighted prompt; the band hides that crack.
- The bucket label and the countdown are separated by U+00B7 (middle dot), not by a hyphen. Hyphens read like negative numbers next to a digit; the dot does not.
- The bar width is clamped at Math.min(100, v ?? 0). The server can return >100% on overage, and an unclamped bar would push past the row, breaking the popup layout.
- The poll cadence is fixed at 60 seconds. No exponential backoff, no on-focus poll, no jitter. Predictable cadence is what lets the user trust that the countdown reflects the last full minute of state.
Tracker vs the Settings page
Both read the same JSON. The difference is what they render and how fresh the render is.
| Feature | claude.ai/settings/usage | ClaudeMeter (browser + menu bar) |
|---|---|---|
| What the percent comes from | five_hour.utilization parsed from the same JSON | five_hour.utilization parsed from the live JSON |
| What the countdown looks like | 'usage will reset at 19:36' (absolute clock time) | '22m', '3h', '5d' (one-unit, single glance) |
| Where you read it | claude.ai/settings/usage page | Browser toolbar badge or macOS menu bar |
| When it refreshes | On full page reload | Every 60 seconds in the background |
| How it handles a sliding resets_at | Stale until the next reload | Re-derives every poll from the new timestamp |
| What it shows for already-passed timestamps | Hides the label until the next poll | 'now' literal, no negative durations |
| Cross-org coverage | One org per visible page | Worst-case across every membership in one badge |
| Cost | Bundled in your Pro/Max subscription | Free, MIT licensed |
The numbers that fix the design
All four are knobs you can read out of the source. None are invented benchmarks.
What this buys you in practice
A 5-hour window tracker that gets the countdown right turns the cap from a surprise into a planning input. You see 5-hour · 22m at 92 percent and you finish the function you are in the middle of, then take the break. You see 5-hour · 3h at 92 percent and you switch to Sonnet for the rest of the session. Both are signals the Settings page conveys with a binary “low” or “reset at 19:36” and a static bar. Neither of those tells you which mitigation actually fits the next 90 seconds of work.
The countdown is also the part of the tracker that catches a quiet behavioral change first. When Anthropic tightens the window weighting, the percent moves a little but the reset countdown jumps a lot, because aging is the variable they are actually adjusting. Tracking both is what makes a tightening detectable in a session rather than three days later when you hit a 429.
The honest caveat
The endpoint is internal and undocumented. Anthropic could rename fields or change the rolling-window semantics in any release. The countdown math is robust to that because it operates on the field shape, not on the field meaning, but if the shape changes a release ships the same day. The four-band humanization is a UX choice, not a protocol decision; if your taste runs to compound countdowns or finer precision, the eleven lines of fmtResets are the only thing you would need to fork.
Building your own tracker and want to compare countdown bands?
If you have a different humanization scheme or a poll cadence story you want to compare against, send a 15 minute call. Happy to swap notes on the rolling-window edge cases.
Frequently asked questions
What is the rolling 5-hour window on Claude Pro and Max?
It is a server-side cap that bills your prompts against the last 5 hours of activity, weighted by model class, attachments, and tool calls. The endpoint /api/organizations/{org_uuid}/usage on claude.ai returns it as a five_hour object with two fields: utilization (a float between 0 and 1, sometimes 0 and 100 depending on the bucket) and resets_at (an ISO 8601 timestamp pointing at the moment the oldest weighted message in the window will age out). A 'tracker' is anything that polls that JSON and turns it into something you can glance at without opening the Settings page.
Why is the countdown the part that actually matters?
Because the percent on its own is half information. 92 percent with a reset four minutes away is a coffee break. 92 percent with a reset four hours away is a switch-to-Sonnet-or-stop decision. The Settings page reduces resets_at to a binary 'usage will reset at HH:MM' string. A tracker has to render both axes (how full, and how long until empty) so you can pick the right remediation in under a second. The countdown is what makes the page-on-the-toolbar tracker more useful than the page on claude.ai.
How does the tracker turn an ISO 8601 timestamp into '22m' or '3h'?
extension/popup.js lines 17 to 27. The function fmtResets parses the ISO string, subtracts Date.now(), and bands the result. <=0ms returns 'now'. <1 hour returns Math.round(diff / 60000) plus 'm'. 1 to 48 hours returns Math.round(h) plus 'h'. >=48 hours returns Math.round(h / 24) plus 'd'. Three thresholds, four output shapes. The 1-hour boundary is the place where '60m' would feel coarse next to '1h'. The 48-hour boundary is where '50h' starts to feel less readable than '2d'. The bands are picked for one-glance legibility on a 6-character toolbar surface.
Why does the tracker have to repoll every 60 seconds?
Because the 5-hour window is rolling. resets_at is computed off the oldest weighted prompt still inside the 5-hour boundary; as soon as you fire another big prompt, that boundary slides forward and the timestamp changes. A static countdown would lie within minutes. background.js sets POLL_MINUTES = 1, registers a chrome.alarms tick on install and on startup, and calls refresh() on every fire. The cadence is fixed: no exponential backoff, no on-focus poll, no jitter. One minute, every minute, while the browser is awake.
What does the tracker actually display?
Two surfaces. The toolbar badge shows one number, the rounded percent on the worst five_hour bucket across every org you belong to, with a green/orange/red color band at <80 / >=80 / >=100 thresholds. Click the icon and the popup renders one row per account (email, percent, bar, countdown). The label format is '<bucket-name> · <countdown>' assembled in popup.js line 33: `${label} · ${resets}`. So '5-hour · 22m' or '5-hour · 3h' or '5-hour · now' depending on which band fmtResets fell into.
Why not just show a precise countdown like 1h 47m 12s?
Three reasons. First, the popup row is narrow (the extension popup is a fixed 320 pixel surface) and 1h 47m 12s eats horizontal space. Second, anything finer than minute precision flickers as the second ticks; the user reads a flicker as 'something is wrong'. Third, a single-unit label compresses to the part you need to plan around: when you see '3h' you do not need the minutes to know your refactor session is over for the afternoon. The design choice is precision proportional to the action the user will take, not to the resolution of the underlying timestamp.
What about the seven-day buckets, do they get countdowns too?
Yes, the same fmtResets function runs on every bucket the popup row renders: five_hour, seven_day, seven_day_sonnet, seven_day_opus when present. seven_day buckets land in the days band almost always, so you typically see '7d · 5d' or '7d Opus · 4d'. The algorithm does not care which bucket; it just receives an ISO timestamp and returns the band. That is intentional. The cap shape is changing on Anthropic's side fast enough that hardcoding bucket-specific countdown logic would be an immediate footgun.
Does the countdown ever read 'now' for more than a second?
Yes. resets_at can be a few seconds or even a few minutes in the past at the moment you read it, because the server's clock and the prompt-aging logic do not always tick on the same edge. fmtResets line 22 returns 'now' for any non-positive diff, which covers that small leading window. The next refresh, 60 seconds later, will pick up the new resets_at the server has computed against the next-oldest weighted prompt and the countdown will jump forward to a real value. This is also why the popup feels briefly stuck after a reset: the server has not yet recomputed the boundary.
What if my system clock is wrong, do I get a wrong countdown?
Yes. fmtResets uses Date.now() as the reference, which reads the local OS clock. If you have skewed your clock by an hour to test something, the countdown will be off by an hour. The percent is unaffected because it comes straight from the server. There is no NTP step inside the tracker. In practice machines stay close enough to true time that this never matters, but it is the one place the countdown depends on something other than the server payload.
Keep reading
The 5-hour window is one float on a sliding clock
Where the 5-hour bucket lives in the JSON, why resets_at slides, and how to read it yourself in one curl.
5-hour visibility: one worst-case number on your toolbar
Multi-org-aware visibility into Claude Pro's rolling 5-hour window. The badge shows the worst case, not the active tab.
Local counter vs server quota: why ccusage and claude.ai disagree
Why ccusage at 5 percent and claude.ai at rate-limited are both correct. They are reading two different sources.