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:
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.
| Header | Meaning |
|---|---|
| x-ratelimit-limit | Requests allowed in the current window: 60 without a token |
| x-ratelimit-remaining | Requests left in the window; 0 means blocked until the reset |
| x-ratelimit-used | Requests already used in the window |
| x-ratelimit-reset | When the window resets, in UTC epoch seconds |
| x-ratelimit-resource | Which 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
| Caller | Limit | Counted per |
|---|---|---|
| No token: browser fetch, curl, scripts | 60 requests per hour | IP address |
| No token, search endpoints | 10 requests per minute | IP address |
| No token, GraphQL API | None: GraphQL requires a token | Not applicable |
| Personal access token or OAuth token | 5,000 requests per hour | GitHub user |
| GitHub App installation token | 5,000 to 12,500 per hour; 15,000 on Enterprise Cloud | Installation |
| GITHUB_TOKEN in GitHub Actions | 1,000 requests per hour; 15,000 on Enterprise Cloud | Repository |
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.
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.
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-remainingreaches 0. Wait untilx-ratelimit-reset, or waitretry-afterseconds 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 sendsAccess-Control-Allow-Origin: *. In our test, three raw downloads left the REST API'sx-ratelimit-remainingunchanged. - Do not rely on conditional requests without a token. In our test, an unauthenticated
If-None-Matchrequest that returned304 Not Modifiedstill used one of the 60 requests, as GitHub only skips304responses for requests sent with anAuthorizationheader. - Call search endpoints sparingly, as they have a separate budget of 10 requests per minute without a token.
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.