Short answer: when the Bluesky API returns HTTP 429, wait until the time in the ratelimit-reset header, then try again once. Better still, watch ratelimit-remaining on every response and slow down before you reach zero. The wrapper below does both in about 40 lines of TypeScript, with no dependencies.

The Headers You Get

Bluesky's servers send rate limit headers on most responses:

HeaderMeaning
ratelimit-limitHow many requests this window allows
ratelimit-remainingHow many are left
ratelimit-resetWhen the window resets, as a Unix timestamp in seconds
ratelimit-policyThe policy, such as 3000;w=300 (3,000 per 300 seconds)

Use them instead of guessing. A fixed sleep(60_000) after a 429 is too long when the window resets in 5 seconds and too short when it resets in 4 minutes.

The Wrapper

// xrpc.ts: a fetch wrapper for Bluesky XRPC calls that respects rate limits.
type Opts = { maxRetries?: number; minRemaining?: number };

export async function xrpc(
  url: string,
  init: RequestInit = {},
  { maxRetries = 5, minRemaining = 10 }: Opts = {},
): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init);

    // 1. Slow down *before* the limit: if we're nearly out, wait for the reset.
    const remaining = Number(res.headers.get("ratelimit-remaining"));
    const reset = Number(res.headers.get("ratelimit-reset")); // Unix seconds
    if (res.ok && Number.isFinite(remaining) && remaining <= minRemaining && reset) {
      await sleep(untilReset(reset));
    }

    if (res.status !== 429 && res.status < 500) return res;

    // 2. Writes are not safe to retry blindly: they may have gone through.
    const method = (init.method ?? "GET").toUpperCase();
    if (method !== "GET" && res.status !== 429) return res;
    if (attempt >= maxRetries) return res;

    // 3. On 429, wait for the reset; otherwise back off exponentially with jitter.
    const wait = res.status === 429 && reset
      ? untilReset(reset)
      : Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random());
    await sleep(wait);
  }
}

const untilReset = (reset: number) => Math.max(0, reset * 1000 - Date.now()) + 250 + Math.random() * 750;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

What it does, in order:

  1. Throttles early. When fewer than minRemaining requests are left, it waits for the reset before returning, so the next call doesn't fail.
  2. Waits for the reset on 429. Plus a little random jitter, so several workers don't all retry in the same millisecond.
  3. Backs off exponentially on server errors (500, 502, 503) for reads, up to 30 seconds.
  4. Doesn't retry writes on server errors. If createRecord returns a 502, the post may have been created anyway. Retrying blindly makes duplicates. A 429 is safe to retry because the server refused the request before doing anything.
  5. Gives up after maxRetries and returns the last response, so the caller sees a real error instead of hanging forever.

Using It

const res = await xrpc(
  "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=bsky.app",
);
const profile = await res.json();

// Authenticated write through your PDS:
await xrpc(`${pds}/xrpc/com.atproto.repo.createRecord`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessJwt}` },
  body: JSON.stringify({ repo: did, collection: "app.bsky.feed.post", record }),
});

If You Use @atproto/api

The official SDK throws an error with a status property when a call fails. Check for 429 and wait rather than retrying in a loop. Recent versions include the response headers on the error; if yours doesn't, fall back to a fixed backoff.

import { AtpAgent } from "@atproto/api";

try {
  await agent.post({ text: "hello" });
} catch (err: any) {
  if (err?.status === 429) {
    // Rate limited. Don't loop: wait for the reset and try once more later.
    const reset = Number(err.headers?.["ratelimit-reset"]);
    console.warn("Rate limited until", reset ? new Date(reset * 1000) : "unknown");
  } else {
    throw err;
  }
}

The Limits Worth Designing Around

  • 3,000 requests per 5 minutes per IP on Bluesky-hosted servers. Shared across everything running from that address.
  • 5,000 write points per hour and 35,000 per day per account, where creating a record costs 3, updating 2 and deleting 1. See the follow and like limits.
  • 30 logins per 5 minutes and 300 per day per account. Reuse sessions; see the login limit.
  • The public AppView (public.api.bsky.app) has generous limits and is cached, so use it for public reads instead of your PDS.

Fewer Requests Beats Better Retries

A good retry wrapper keeps you running when you hit a limit. Making fewer requests keeps you from hitting it:

  • Stream instead of polling. Jetstream sends you new posts as they happen with no rate limit on your side. See Firehose vs Jetstream.
  • Cache identity. Handles, DIDs and DID documents rarely change. Cache them for minutes, not milliseconds.
  • Batch writes with com.atproto.repo.applyWrites. It's one request, though each record still costs write points.
  • Use getProfiles, getPosts and other plural endpoints to fetch up to 25 items per request instead of one at a time.

Frequently Asked Questions

What does a 429 error mean on the Bluesky API?

HTTP 429 Too Many Requests means you've hit a rate limit. The request was refused before anything happened, so it's safe to retry after the time in the ratelimit-reset header.

What format is ratelimit-reset in?

A Unix timestamp in seconds. Multiply by 1,000 and subtract the current time in milliseconds to get how long to wait.

Should I retry failed writes to Bluesky?

Retry a write after a 429, because the server refused it. Don't blindly retry after a 500-range error, because the record may have been created and you'd make a duplicate.

What is the Bluesky API rate limit?

On Bluesky-hosted servers: 3,000 requests per 5 minutes per IP, 5,000 write points per hour and 35,000 per day per account, and 30 logins per 5 minutes per account.

Does the public AppView have rate limits?

Yes, but they're generous, and public.api.bsky.app is cached. Bluesky recommends it for public read use cases.

Built on the Same API

Skyscraper is an independent Bluesky and Mastodon client for iPhone, iPad, Mac, Apple TV, Apple Watch and Vision Pro. Everything in this post comes from building it: caching, backoff and one long-lived session per account so you don't burn through your limits.

Download Skyscraper →

Related Reading