notion-kit blog

Staying under Notion's rate limit

Notion's API allows an average of about three requests per second per integration. A single page view is far below that. A static build is not: rendering fifty posts means fetching fifty pages of blocks, plus one request for every toggle, column and nested list inside them, and the build starts them all at once.

The result is a stream of 429 responses, and the official client gives up after a couple of retries. Builds fail at random, usually on the post with the most nested blocks.

A limiter

notion-fetch sends every request through a limiter. It keeps only a few requests in flight, and when Notion answers 429 it waits for as long as the Retry-After header says before trying again, instead of guessing.

const limit = createRequestLimiter(); // one per process
const blocks = await fetchBlockTree(notion, pageId, { limit });

A retry waits outside the limiter, so one slow request does not hold up the others. Timeouts are retried too, which matters more than you would think on a busy build machine.

Sharing work between pages

During a build, every page also wants the list of posts, for navigation and links between posts. Without care, that is one database query per page. createDedupe shares one fetch between everyone who asks for the same key at the same time:

const dedupe = createDedupe();
const rows = await dedupe("rows", () => queryDatabase(notion, id, { limit }));

A failed fetch is not kept, so the next page simply tries again.

With both in place, a build of a few hundred posts finishes without a single failed request; it just takes as long as Notion's limit allows.