Surviving Atlassian's points-based rate limits in a Forge app (2026)
Mihai Perdum
Author
11 min readAugust 12, 2026
Key takeaways
One page of 200 group members costs 401 points (1 per request + 2 per identity object). Reading three big groups on a 13,500-user estate is ~55,000 points — most of the Tier 1 hourly pool in one pass.
A 1-point member-count probe decides whether the 137-page read runs at all. Unchanged counts, bounded quiet streak: skip. Our quiet hours went from pool-dead-by-:18 to ~2,000 points.
Meter 1 + 2×objects per response into an hourly per-endpoint table before touching anything. Our biggest spender was a sweep that usually did no work at the end of it.
The RateLimit-Reason header (confluence-quota-global-based vs confluence-quota-tenant-based) is the only proof of which pool you're in. Never infer it from silence.
A self-imposed soft budget belongs at the scheduling layer — never per-call, and never anywhere near an authorization path.
On March 2, 2026, Atlassian started enforcing points-based rate limits for Jira and Confluence Cloud apps. Every request costs points, every user or group object in the response costs more, and a Forge app on the default tier draws from one 65,000-point hourly pool shared across every site it's installed on. Our licence-management app runs against a 13,500-user Confluence estate, and its routine hourly passes spent that entire pool by 18 minutes past the hour. Every hour. The worst stretch was a 42-hour outage where background work, the admin dashboard and end-user self-service all fought over a dead quota.
We got it back under control without a bigger quota. The app now meters what every response costs, skips its heaviest reads when 1-point count probes say nothing changed, imposes a soft budget on itself below Atlassian's hard one, and defers its biggest job to a configured night window. Quiet hours cost around 2,000 points. This is the write-up I wanted three weeks ago: the maths, the instrumentation, the two traps we shipped into production, and the header evidence you'll need if you ever ask Atlassian for the bigger pool.
Note
Prerequisites — a Forge app calling Confluence Cloud REST (the same model applies to Jira), production access to forge logs, and somewhere to persist counters (we use Forge SQL; Forge storage works too). Reading the official rate-limiting reference alongside this helps.
This is what a healthy night looks like in our production log now — the skip lines are the feature:
text
1INFO [joiner] no membership change since the last full read (3/3 quiet sweeps) — skipped ~137 pages
2INFO [licence-refresh] no membership change (2/2 quiet passes) — skipped ~23 paged reads
3INFO [joiner] skipped: hourly API allowance spent — the sweep resumes after 20:00 UTC
What actually changed on March 2, 2026?
The old model rate-limited requests. The new one prices the work: 1 point per request, plus 2 points for every identity object (users, groups, permissions) a GET returns, plus 1 point for core content objects like pages and spaces. Writes cost their 1-point base. The pricing lives in response headers you can read today: Beta-RateLimit-Policy: "global-app-quota";q=65000;w=3600 names your pool and quota, and on a 429 the RateLimit-Reason header says which limit you actually hit.
The tiers are the part that caught us. Tier 1 — where every app starts — is 65,000 points per hour shared across all installs of your app. One noisy tenant starves the rest. Tier 2 gives each tenant its own pool (Enterprise: 150,000 + 30 × user count, capped at 500,000), but it's assigned only after an Atlassian review of apps with "exceptionally high or concentrated usage". You can't buy it and you can't self-serve it. So the engineering assumption has to be: Tier 1 is what you get.
Now the arithmetic that killed us. A paged group-members read at limit=200 costs 1 + 200 × 2 = 401 points per page. Our app watched three big groups — roughly 13,900, 9,960 and 3,730 members. That's ~137 pages, ~55,000 points, in a single membership pass. The pool is 65,000. One routine pass, one hour, gone.
Caution
The wrong turn we took first — reacting to 429s per call site. Catch the 429 here, back off there, retry over there. Weeks of that and the app was still dying hourly, because twelve polite callers still add up to the same 137 pages. Containment has to live where work is scheduled, deciding whether a pass runs at all — not sprinkled where requests are made.
How do you find out what's spending the points?
Don't optimize from a guess. We were sure the expensive thing was our nightly sync; the telemetry said the biggest spender was an hourly placement sweep that usually placed nobody. Fifteen of its last twenty runs read all ~137 pages and then did no work at the end of them.
1
Meter every response at the client wrapper
you already have one function all your Confluence calls go through (if you don't, make one; you'll need it for everything below).
2
Estimate points as 1 + 2 × identity objects returned
results.length on a group-members page is close enough. Precision doesn't matter; proportion does.
3
Bucket per UTC hour per endpoint label and flush additively (INSERT … ON DUPLICATE KEY UPDATE requests = requests + VALUES(requests)), so concurrent invocations don't clobber each other.
4
Sample the rate-limit headers while you're there
policy name, quota, RateLimit-Reason on any 429. Store the latest observation.
5
Chart the hourly totals against a 65,000 line. Ours made the diagnosis in one glance.
quota-meter.ts (trimmed to the shape that matters):
ts
1exportfunctionmeterResponse(2 res:{ status:number; headers: Headers },3 source:string,4 identityObjects:number,5){6// Capture-only contract: this must NEVER throw and NEVER7// gate a request. A broken meter that takes the app down8// is worse than no meter.9try{10const points =1+2* identityObjects;11bucket(hourKeyUtc(), source).add(points, res.status ===429);12sampleQuotaHeaders(res.headers);13}catch{14/* swallow: measurement never outranks the work */15}16}
One rule we treat as load-bearing: the meter never throws and never blocks. Measurement is a side effect of the work, never a gate on it.
How do you stop reading 137 pages to place nobody?
Group membership on a stable estate barely changes hour to hour. Atlassian will tell you a group's member count for a single request — about 1 point. So before each full read, probe the counts, and skip the read when nothing moved:
1
Probe each watched group's raw member count
a handful of points against ~55,000.
2
Compare per-group raw totals, keyed by group id, against the totals your last genuine full read recorded.
3
Skip when every count matches
up to a bounded quiet streak (ours: 3 sweeps), after which a full read runs anyway on a wall clock.
4
Any change, any unreadable count, or any manual "run now"
do the full read. Counts decide whether to LOOK. They never decide whether to ACT.
Two traps here, both of which we shipped to production before catching them.
Warning
The inert tripwire. Our first version compared the probe's raw per-group totals against a stored baseline that was deduplicated across groups and filtered of app accounts. One shared member or one bot and the numbers could never match — so the tripwire silently forced a full read every time, while everyone believed the saving was live. Store exactly what you will later compare: raw totals, per group, keyed by group id. Then watch the skip line actually appear in production logs. A tripwire that never fires looks identical to a busy estate.
Warning
Compensating changes. A join and a leave in the same hour keep the count identical while the membership changed. That's fine for additive work (someone gets placed an hour late) and catastrophic for anything that revokes access from a stale read. We exclude our revocation path from count-skipping entirely — it always reads live. Decide per consumer, not globally, and write the exclusion down where the next person will trip over it.
What does a self-imposed budget look like?
Atlassian's limit is a cliff: everything works, then everything 429s, including the admin dashboard and the end-user page. A soft budget turns the cliff into a slope. Ours: background passes check projected spend for the current hour against 40,000 points — comfortably under 65,000, leaving headroom for other tenants and for humans — and stand down until the next hour when they'd cross it. The log says "the app is pacing itself", which is true, instead of "Atlassian is rate-limiting the app", which wasn't yet.
The placement rules matter more than the number. The budget lives at the scheduling layer only — a pass is admitted or deferred at its entry, whole. It never gates individual requests (that just moves the cliff), it never gates user-clicked actions (a human pressing "run now" means now), and it never goes anywhere near the authorization path — a background job exhausting the budget must not lock administrators out of the dashboard. And one clamp we only found by review: a pass whose estimated cost exceeds the whole budget must still be admitted when the hour is fresh, or it defers forever and your biggest job simply stops happening.
The last consumer was scheduling drift. Our full sync started wherever the previous run happened to finish — sometimes mid-workday, competing with people using the site. It now starts only inside a configured window (18:00–05:00 UTC by default). The window gates starts only: it never cancels a running job, never applies to the manual button, and never pauses the cheap hourly passes. And if an admin sets start equal to end, that reads as "no restriction" — a fumbled setting must not switch your nightly job off forever.
How do you prove which pool Atlassian has you in?
If you do ask Atlassian for Tier 2, or you just want to know, the headers are the only evidence that counts. RateLimit-Reason: confluence-quota-global-based on a 429 means the shared app-wide pool refused you; confluence-quota-tenant-based means a per-tenant pool did. The policy header carries the quota (q=65000;w=3600). We persist the latest observation and surface it as a badge in the app's admin UI, with an export of the hourly consumption table formatted for a support ticket.
The rule we wrote down: never conclude anything from silence. An hour without 429s only means nothing was refused. The pool didn't change. If a bigger quota is ever granted, the header is how you'll know it took effect — and until the header says so, it didn't.
First night after the tripwires and budget went live: the skip lines appeared on schedule, quiet hours came in around 2,000 points, and the one exhausted hour was an actual new user joining the estate — which correctly forced the full read the tripwire exists to allow. The app hit the ceiling at 16 past, held, and resumed at the top of the hour on its own. That's the design outcome: busy hours spend, quiet hours don't.
Storage has its own budget, on the same shape of maths. KVS allows 4,000 reads and 4,000 writes a minute per installation, counted in 10KB units, which is why a lock you poll for is the most expensive lock you can build — and why the lease pattern in that piece retries with backoff instead of spinning.
One last lesson from that night, about logs rather than quotas. Our "keep the admin logged in while Atlassian refuses the re-check" fallback logged its message on every re-attempt — 146 identical warnings in one night — and the message said "verified 2640s ago", which the estate owner reasonably read as something hanging for 44 minutes. Nothing was slow; the number was the age of the last successful check. Expected recurrences should log once and then summarise, and a log line is user interface: ages in minutes, and say the state ("access continues; corrected the moment Atlassian answers"). A log everyone has learned to skim is where the next real failure hides.
Key takeaways
Price your reads before Atlassian does: 401 points per 200-member page means group-heavy passes are the whole budget. Do the multiplication for your own estate first.
Meter first, optimize second — 1 + 2×objects per response into an hourly per-endpoint table. Our worst spender wasn't the one we'd have bet on.
Count probes before full reads, comparing raw per-group totals keyed by group id — and verify the skip line fires in production, because an inert tripwire is invisible.
Never let a count skip feed anything that revokes access. Additive work tolerates staleness; destructive work reads live.
A soft budget at the scheduling layer plus a night window for the big job turns the hard cliff into a slope — and keeps humans and auth paths exempt.
Keep the header evidence. RateLimit-Reason is the only proof of which pool you're in, granted quota included.