GuidesDIAGNOSE

GitHub API rate limit exceeded in the browser

Because api.github.com sends Access-Control-Allow-Origin: *, browser JavaScript can call it directly without a CORS proxy. Without a token, GitHub allows 60 REST API requests per hour per IP address. Behind a CORS proxy, every visitor shares the proxy's IP addresses, so GitHub returns 403 with "API rate limit exceeded for" and the proxy's IP.

What the error looks like

When the budget runs out, GitHub responds with HTTP 403 or 429, x-ratelimit-remaining: 0, and a JSON message that starts with API rate limit exceeded for and the IP address that used it up. Because the response still includes CORS headers, fetch resolves, allowing your code to read the status, headers and body. This one came back for a request through proxy.cors.dev, which runs on Cloudflare:

Rate-limited response from api.github.com
HTTP/2 403
content-type: application/json; charset=utf-8
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
x-ratelimit-reset: 1790819852
x-ratelimit-resource: core
x-ratelimit-used: 60

{
  "message": "API rate limit exceeded for 172.71.144.122. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
  "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
}

The IP address in the message indicates whose budget ran out. If it is your visitor's own address, that browser made 60 requests in the last hour. If it belongs to a proxy, VPN or hosting provider, other people's requests consumed the budget, and your code may have sent only one.

Rate-limit headers on every GitHub API response
HeaderMeaning
x-ratelimit-limitRequests allowed in the current window: 60 without a token
x-ratelimit-remainingRequests left in the window; 0 means blocked until the reset
x-ratelimit-usedRequests already used in the window
x-ratelimit-resetWhen the window resets, in UTC epoch seconds
x-ratelimit-resourceWhich budget the request counted against, such as core or search

Because GitHub lists all five in Access-Control-Expose-Headers, response.headers.get() can read them on a cross-origin request.

GitHub API rate limits

GitHub REST API primary rate limits
CallerLimitCounted per
No token: browser fetch, curl, scripts60 requests per hourIP address
No token, search endpoints10 requests per minuteIP address
No token, GraphQL APINone: GraphQL requires a tokenNot applicable
Personal access token or OAuth token5,000 requests per hourGitHub user
GitHub App installation token5,000 to 12,500 per hour; 15,000 on Enterprise CloudInstallation
GITHUB_TOKEN in GitHub Actions1,000 requests per hour; 15,000 on Enterprise CloudRepository

Secondary limits apply on top: no more than 100 concurrent requests, and no more than 900 points per minute for REST endpoints, where most GET requests cost 1 point. They also return 403 or 429, sometimes with a retry-after header. Calling GET https://api.github.com/rate_limit shows the budget left for the IP or token that calls it and does not count against the primary limit.

Check the remaining budget from the browser console
const { resources } = await fetch('https://api.github.com/rate_limit').then((response) => response.json());

console.log(resources.core);
// {limit: 60, remaining: 51, reset: 1790819269, used: 9}
console.log(resources.search);
// {limit: 10, remaining: 10, reset: 1790819174, used: 0}

Why a CORS proxy makes it worse

Because a CORS proxy fetches the API from its own servers, GitHub counts the proxy's IP address instead of your visitor's. Everyone sending GitHub requests through that proxy draws from the same pool of 60 requests per hour per outgoing IP, and hosted proxies on shared cloud networks run out quickly. This applies to every hosted CORS proxy, proxy.cors.dev included: on October 1, 2026, three requests through it to api.github.com came back with x-ratelimit-remaining at 0, 4 and 9.

When requests are sent directly, each visitor uses their own IP address and their own 60 requests per hour. GitHub already allows browser requests from any origin: its preflight response returns Access-Control-Allow-Origin: *, allows GET, POST, PATCH, PUT and DELETE, and accepts headers such as Authorization, If-None-Match and X-GitHub-Api-Version. A proxy adds nothing here except a shared limit.

cors.dev Auto sends every request natively first and falls back to the proxy only when the browser blocks it. As a result, calls to api.github.com stay direct, allowing a readable 403 from GitHub to reach your code unchanged: Control Auto routing.

Call api.github.com from the browser

Because a GET request that sets only the Accept header is a simple CORS request, the browser sends it without a preflight. Read the rate-limit headers on every response and let the user know when to try again instead of retrying in a loop.

Call the GitHub API directly and read the limit
const response = await fetch('https://api.github.com/repos/octocat/Hello-World', {
  headers: { Accept: 'application/vnd.github+json' },
});

const remaining = Number(response.headers.get('x-ratelimit-remaining'));
const resetAt = new Date(Number(response.headers.get('x-ratelimit-reset')) * 1000);

if (response.ok) {
  const repo = await response.json();
  console.log(repo.full_name, repo.stargazers_count, `${remaining} requests left`);
} else if (remaining === 0) {
  console.warn(`GitHub rate limit reached, resets at ${resetAt.toLocaleTimeString()}`);
} else {
  const { message } = await response.json();
  console.error(response.status, message);
}

Stay under 60 requests per hour

  • Instead of fetching on every page load or render, cache responses and reuse them for several minutes. Repository details, releases, and star counts rarely need to be fresher.
  • Stop making requests once x-ratelimit-remaining reaches 0. Wait until x-ratelimit-reset, or wait retry-after seconds if GitHub sends that header. GitHub warns that integrations that keep sending requests while rate limited can be banned.
  • Load file contents from raw.githubusercontent.com, which also sends Access-Control-Allow-Origin: *. In our test, three raw downloads left the REST API's x-ratelimit-remaining unchanged.
  • Do not rely on conditional requests without a token. In our test, an unauthenticated If-None-Match request that returned 304 Not Modified still used one of the 60 requests, as GitHub only skips 304 responses for requests sent with an Authorization header.
  • Call search endpoints sparingly, as they have a separate budget of 10 requests per minute without a token.
Cache GitHub responses and back off until the reset
const CACHE_MS = 10 * 60 * 1000;

async function github(path) {
  const cacheKey = `github:${path}`;
  const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null');
  if (cached && Date.now() - cached.savedAt < CACHE_MS) return cached.data;

  const blockedUntil = Number(localStorage.getItem('github:blocked-until') ?? 0);
  if (Date.now() < blockedUntil) {
    if (cached) return cached.data;
    throw new Error(`GitHub rate limit, try again at ${new Date(blockedUntil).toLocaleTimeString()}`);
  }

  const response = await fetch(`https://api.github.com${path}`, {
    headers: { Accept: 'application/vnd.github+json' },
  });
  const body = await response.json().catch(() => ({}));

  if (response.ok) {
    localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), data: body }));
    return body;
  }

  const rateLimited = [403, 429].includes(response.status) && /rate limit/i.test(body.message ?? '');
  if (!rateLimited) throw new Error(`GitHub ${response.status}: ${body.message}`);

  // Primary limits name a reset time, secondary limits a retry delay or nothing
  const retryAfter = Number(response.headers.get('retry-after'));
  const remaining = response.headers.get('x-ratelimit-remaining');
  const resetAt = Number(response.headers.get('x-ratelimit-reset')) * 1000;
  const until = retryAfter ? Date.now() + retryAfter * 1000 : remaining === '0' ? resetAt : Date.now() + 60_000;
  localStorage.setItem('github:blocked-until', String(until));

  if (cached) return cached.data;
  throw new Error(`GitHub rate limit, try again at ${new Date(until).toLocaleTimeString()}`);
}

const repo = await github('/repos/octocat/Hello-World');
console.log(repo.full_name, repo.stargazers_count);

Raise the limit to 5,000 requests per hour

Authenticated requests receive 5,000 requests per hour, counted per GitHub user instead of per IP, and 304 responses to conditional requests do not count. Do not put a personal access token in frontend code: anyone can copy it from your JavaScript bundle and use its quota and permissions.

If your users sign in with GitHub, create a GitHub App or OAuth app. Exchange the authorization code for a token on your backend so the client secret stays private, then send each user's token from the browser. This way, every user has their own 5,000 requests per hour. For data that is the same for every visitor, such as your repository's star count, fetch it on your server with one token and serve a cached copy.

Good questions.

Does the GitHub API support CORS?

Yes. Browser code can call the REST API without a proxy. The REST API responds to requests from any origin with Access-Control-Allow-Origin: *, and its preflight allows GET, POST, PATCH, PUT and DELETE with headers such as Authorization and If-None-Match.

Why does the error name an IP address I do not recognize?

GitHub tracks unauthenticated requests per IP address and identifies the address that ran out. An unfamiliar address usually belongs to a CORS proxy, VPN, or shared network whose other users spent the 60 requests.

When does the GitHub API rate limit reset?

Rate limits reset at the time in x-ratelimit-reset, given in UTC epoch seconds. The core limit uses a one-hour window, while the search limit uses a one-minute window. GET https://api.github.com/rate_limit shows both without counting against the primary limit.

Is a GitHub rate limit a 403 or a 429?

It can be either, as GitHub uses both for primary and secondary rate limits. Instead of relying on the status alone, check x-ratelimit-remaining, retry-after, and the message, because 403 also means missing permissions.

Do conditional requests save GitHub API rate limit?

Only with a token. A 304 Not Modified response to an authenticated If-None-Match request does not count, but without a token, it still uses one of the 60 requests per hour.

Should I send a GitHub token through a CORS proxy?

No. GitHub accepts the Authorization header directly from browsers, so the proxy adds nothing and your token would pass through a third-party server.