The Notion API allows an average of about three requests per second per integration, with some burst allowance above that. Exceed it and you get a 429 with a Retry-After header telling you exactly how long to wait.
Three per second is low enough that any bulk operation hits it, and the limit is per integration rather than per user - so every workspace connected to your integration draws from the same budget. Designing around that from the start is much easier than retrofitting it.
Table of contents
- The limit and the signal
- The retry pattern
- Not hitting the limit in the first place
- Bulk operations
- Where this runs
- How this fits the rest of the stack
- FAQ
The limit and the signal
The documented limit is an average of three requests per second per integration, with bursts above the average tolerated. It is an average rather than a hard per-second cap, which is why a short spike often succeeds and a sustained one does not.
When you exceed it, the response is a 429 status with a Retry-After header carrying an integer number of seconds. That header is the important part: it is the server telling you precisely when to try again, and honouring it is the whole of correct behaviour.
There are separate limits on payload size and the number of items in a single request, distinct from the rate limit. A request rejected for size will not be fixed by waiting, so distinguish the two rather than feeding everything into the same retry loop.
The per-integration scoping is the detail that changes architecture. If your product connects many workspaces through one integration, they share the budget, and one customer running a large sync degrades everyone. Queueing per workspace with a global rate limiter is the shape that handles this; hoping it does not happen is not.
The retry pattern
The documented approach retries on 429 and on overloaded responses, and additionally on transient server errors for idempotent methods only.
async function notionRequest(url, options = {}, attempt = 0) {
const response = await fetch(url, options)
const method = (options.method ?? 'GET').toUpperCase()
const isIdempotent = method === 'GET' || method === 'DELETE'
const retryable =
response.status === 429 ||
response.status === 529 ||
(isIdempotent && [500, 502, 503, 504].includes(response.status))
if (!retryable || attempt >= 5) return response
const retryAfter = response.headers.get('retry-after')
const retryAfterSeconds = Number(retryAfter)
const exponentialDelaySeconds = Math.min(2 ** attempt, 30)
const baseDelaySeconds =
retryAfter !== null && Number.isFinite(retryAfterSeconds)
? retryAfterSeconds
: exponentialDelaySeconds
const jitterMs = Math.random() * 250
await new Promise(r => setTimeout(r, baseDelaySeconds * 1000 + jitterMs))
return notionRequest(url, options, attempt + 1)
}
Four decisions in that function are worth understanding rather than copying.
- Only idempotent methods retry server errors. A failed POST may have succeeded server-side before the connection dropped. Retrying it can create a duplicate page, which is worse than surfacing the error.
- Retry-After wins over the exponential calculation. The server knows when the budget resets; your formula is a guess for when the header is absent.
- Jitter is not decoration. Without it, every client that hit the limit at the same moment retries at the same moment, and the retry storm reproduces the problem.
- Attempts are capped. Unbounded retrying converts a transient limit into a hung process.
The same shape applies in any language - the documentation provides equivalents for Python, Go, and Java, and the logic is identical across them.
Not hitting the limit in the first place
Retrying correctly is the safety net. The real work is not needing it.
Request fewer things. The most common cause of rate limiting is fetching a page’s blocks recursively when you only needed its properties. Query the database and read properties rather than walking the block tree, unless you genuinely need the content.
Use pagination sensibly. Ask for the maximum page size rather than the default, so a thousand items is ten requests instead of many more. But do not fetch pages you will not use - stop when you have what you need.
const res = await notionRequest(`${API}/v1/databases/${dbId}/query`, {
method: 'POST',
headers,
body: JSON.stringify({ page_size: 100, start_cursor: cursor }),
})
Cache. Notion content changes far less often than most integrations poll it. Caching database schemas, which change rarely, and page metadata with a short lifetime removes a large share of requests at almost no cost in freshness.
Rate limit yourself. A client-side limiter at just under the documented rate means you rarely see a 429 at all, which is far more predictable than discovering the limit repeatedly.
// Minimal spacing limiter - one request every ~350ms
let chain = Promise.resolve()
function throttled(fn) {
const next = chain.then(() => fn())
chain = next.then(
() => new Promise(r => setTimeout(r, 350)),
() => new Promise(r => setTimeout(r, 350)),
)
return next
}
Note that the chained delay runs on both success and failure. A limiter that only spaces successful calls stops spacing exactly when you are being rate limited, which is the worst possible moment.
Bulk operations
At three requests per second, a bulk job is a background job. Syncing ten thousand pages takes close to an hour at the limit, and that is the floor rather than a pessimistic estimate.
Design accordingly. Bulk work belongs in a queue with a worker, not in a request handler - a user waiting on an HTTP response while you page through a database is a timeout waiting to happen.
- Make it resumable. Store the pagination cursor as you go. A job that fails at ninety percent and restarts from zero is a job that may never finish.
- Make it idempotent. Use a stable external identifier so re-processing an item updates rather than duplicates.
- Report progress. An hour-long job with no output is indistinguishable from a hung one.
- Sync incrementally after the first run. Filter on the last-edited timestamp and process only what changed. The initial import is the expensive operation; subsequent syncs should be small.
- Handle deletions explicitly. An incremental sync filtered on edit time will not tell you something was archived, so reconcile periodically.
// Incremental: only what changed since the last run
body: JSON.stringify({
page_size: 100,
filter: {
timestamp: 'last_edited_time',
last_edited_time: { on_or_after: lastSyncIso },
},
})
That incremental filter is the difference between a sync that takes an hour every time and one that takes an hour once and seconds thereafter.
Where this runs
A rate-limited integration has a particular operational shape, and it is worth matching the infrastructure to it.
The work is long-running, mostly idle while waiting, and needs to survive restarts without losing its place. That means a worker process rather than a request handler, a durable store for cursors and sync state, and logs you can read when a job fails at three in the morning.
It also means the retry state cannot live in memory. A process that restarts mid-sync should resume from the stored cursor, not begin again - which requires the cursor to be in a database rather than a variable.
On RunxBuild, that is a Node, Python, or Go service deployed from a repository with a build log, a live route, environment variables held by the platform rather than in the repo, and runtime logs beside the deploy that produced them - with a managed Postgres or MySQL holding the sync state. When a sync job fails, the error and the deploy that introduced it are in the same place, and rolling back to the previous deploy is a button.
How this fits the rest of the stack
Rate-limited integrations are background workers with durable state, which makes them an infrastructure question as much as an API one. The RunxBuild hosting calculator shows the service and the managed database that holds its sync state as separate line items, so a job that runs for an hour has a known cost rather than an assumed one.
Useful related references:
- The Airtable API: Rate Limits, Pagination, and the 100,000 Record Wall
- The Okta API: Tokens, Scopes, and the Rate Limit That Catches Everyone
- Python requests.post: Send JSON, Forms, Files, and Reliable API Calls
- Services on RunxBuild
FAQ
What is the Notion API rate limit?
An average of about three requests per second per integration, with some burst allowance above the average. Exceeding it returns a 429 status with a Retry-After header giving the number of seconds to wait. Separate limits apply to payload size and item counts.
Is the Notion rate limit per user or per integration?
Per integration. Every workspace connected through your integration draws from the same budget, so one customer running a large sync can degrade others. Queue work per workspace behind a global rate limiter rather than assuming concurrent syncs will not collide.
How should I handle a 429 from the Notion API?
Wait the number of seconds in the Retry-After header, add a small random jitter, and retry with a capped number of attempts. Fall back to exponential backoff only when the header is absent. Retry server errors only for idempotent methods, since a failed POST may have succeeded already.
Why add jitter to retries?
Without it, every client rate-limited at the same moment retries at the same moment, and the synchronised burst reproduces the problem. A few hundred milliseconds of randomness spreads the retries out and is the difference between recovering and oscillating.
How long does it take to sync a large Notion database?
At three requests per second, roughly an hour for ten thousand pages - and that is the floor. Treat bulk syncs as background jobs with stored pagination cursors so they resume after a restart, then switch to incremental syncs filtered on last-edited time.