If you have ever copy-pasted a fetch() snippet from documentation and watched it fail the moment you pointed it at a real API, you are not alone. Most Fetch tutorials show you the happy path: a clean GET request to a free public API that returns JSON instantly with no rate limits and no weird headers. Real third-party APIs do not behave like that.
This post walks through what actually breaks when you connect to third-party APIs using Fetch, and how to fix each problem with working code. We will cover error handling that Fetch does not give you by default, timeouts, sending the right headers, handling auth tokens, dealing with CORS, and retrying failed requests without hammering the API.
Why Fetch Feels Easy Until It Is Not
Fetch is built into every modern browser and Node.js (18+), so there is no library to install for a simple request. That is the appeal. The problem is that Fetch was designed to be a low-level primitive, not a full HTTP client. It does not throw on HTTP error codes, it does not support request timeouts natively, and it leaves you to handle a lot of the plumbing that older tools like Axios used to do for you.
None of that is a dealbreaker. You just need to know what Fetch will not do for you, so you can add it yourself.
The First Mistake: Assuming Fetch Throws on 404 or 500
This is the single most common bug I see in code that calls third-party APIs. Here is the broken version:
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json();
return data;
}
If the user does not exist and the API returns a 404 with an error message in the body, this code will not throw. It will happily parse whatever JSON came back, even if that JSON is { "error": "User not found" }, and your calling code will treat it as a valid user object. Fetch only rejects the promise on network failures, like a dropped connection or a bad domain. A 404, 401, or 500 is still a "successful" fetch as far as the API is concerned.
The fix is to check response.ok yourself and throw explicitly:
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Request failed (${response.status}): ${errorBody}`);
}
return response.json();
}
This one change will save you hours of debugging "why is my data undefined" issues. Always check response.ok before you try to use the response body.
Sending the Right Headers (This Is Where Most Integrations Fail)
Third-party APIs almost always need an API key, a bearer token, or a specific content type header. Forgetting these, or sending them in the wrong format, is the second most common failure point.
async function fetchWithAuth(endpoint, apiKey) {
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
A few things people get wrong here:
- Some APIs expect
Authorization: Bearer TOKEN, others expect a custom header likeX-API-Key. Always check the docs for the exact header name, it is not standardized across providers. - If you are sending a POST request with a JSON body, you need
'Content-Type': 'application/json'in the headers AND you need to stringify the body. Fetch does not do this automatically. - Some APIs reject requests that do not include an
Acceptheader, returning HTML error pages instead of JSON, which then breaksresponse.json()with a confusing parsing error.
Here is a complete POST example with a body:
async function createPost(apiKey, title, body) {
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title, body })
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to create post: ${response.status} ${errorText}`);
}
return response.json();
}
Fetch Has No Built-In Timeout, and That Will Bite You
If a third-party API hangs (slow server, network issue, rate limit silently dropping the connection), Fetch will wait forever by default. There is no timeout option. This matters a lot in production, where a hanging request can stall your entire app if you are not careful.
The fix is to use AbortController:
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
}
This pattern is one you will reuse constantly once you start building anything that talks to external services. An 8 second timeout is a reasonable default for most third-party APIs, but adjust it based on what the provider's own documentation suggests for typical response times.
Retrying Failed Requests Without Making Things Worse
Third-party APIs fail sometimes. Rate limits, temporary outages, and flaky networks are normal. The instinct is to retry immediately, but that can actually get you rate limited harder or banned if the API tracks request frequency. The better approach is exponential backoff: wait a bit longer after each failed attempt.
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : (2 ** attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, (2 ** attempt) * 1000));
}
}
}
Notice the special handling for status 429 (too many requests). Good APIs send a Retry-After header telling you exactly how long to wait. Respect it when it is there, fall back to exponential backoff when it is not. Do not retry on every error blindly though, a 401 (unauthorized) or 400 (bad request) will not fix itself by waiting, so retrying those just wastes time and quota.
The CORS Problem (And Why It Is Not a Fetch Bug)
If you have ever seen this in your browser console:
Access to fetch at 'https://api.example.com/data' from origin 'https://yoursite.com'
has been blocked by CORS policy
This trips up almost everyone the first time. It is important to understand that this is not something wrong with your Fetch code. CORS is a browser security feature, and it is the third-party API's server that decides which origins are allowed to call it from client-side JavaScript. If the API does not include your domain in its allowed origins, the browser blocks the response before your code ever sees it, even if the request technically succeeded on the server side.
There are three real fixes, and "just add more headers to fetch" is not one of them:
- Use the API from your backend, not the browser. If the third-party API does not support CORS for browser requests (many APIs designed for server-to-server use do not), route the call through your own backend or a serverless function, then have your frontend call your own backend instead.
- Check if the API offers a CORS-friendly endpoint or requires an API key header that signals server-side use. Some providers have separate public, browser-safe endpoints versus private, server-only ones.
- Use a proxy only as a last resort for testing, never ship a public CORS proxy in production, since it adds a security risk and an extra point of failure.
If you are building something like a Cloudflare Worker as a thin proxy layer between your frontend and a third-party API, this is exactly the kind of problem it solves, your Worker calls the API server-side where CORS does not apply, then returns the data to your frontend with your own CORS headers attached.
Putting It Together: A Reusable API Client Pattern
Once you have all of the above, it makes sense to wrap it into a small reusable function instead of repeating this logic everywhere you call an API.
async function apiRequest(endpoint, {
method = 'GET',
body = null,
apiKey = null,
timeoutMs = 8000,
maxRetries = 2
} = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const headers = { 'Accept': 'application/json' };
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
if (body) headers['Content-Type'] = 'application/json';
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(endpoint, {
method,
headers,
body: body ? JSON.stringify(body) : null,
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.status === 429 && attempt < maxRetries) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : (2 ** attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error ${response.status}: ${errorText}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
if (attempt === maxRetries) throw error;
}
}
}
Usage is clean once it is wrapped:
const weather = await apiRequest('https://api.weatherprovider.com/current?city=Dhaka', {
apiKey: process.env.WEATHER_API_KEY
});
const newPost = await apiRequest('https://api.example.com/posts', {
method: 'POST',
apiKey: process.env.API_KEY,
body: { title: 'Hello', content: 'First post' }
});
A Few Things That Save You Debugging Time Later
- Log the status code and a snippet of the response body when something fails, not just the generic error message. Most APIs put useful detail in the error response, and throwing it away makes debugging painful later.
- Never put API keys directly in frontend JavaScript if the key has real permissions or costs money per request. Anyone can open dev tools and read it. Route those calls through a backend or serverless function instead.
- Check the API's rate limit headers (commonly
X-RateLimit-Remainingor similar) if they exist, so you can slow down before you actually hit the limit instead of reacting after the fact. - Read the actual response shape before assuming it. Some APIs wrap data in an envelope like
{ "data": {...}, "meta": {...} }, others return the object directly. Console log a real response once before writing the code that consumes it.
Wrapping Up
Fetch is genuinely good once you stop expecting it to behave like a fully-featured HTTP client out of the box. The four things that cause almost every real-world integration bug are the same every time: not checking response.ok, missing or wrong headers, no timeout handling, and not understanding that CORS failures are server-side decisions, not Fetch bugs. Handle those four, and connecting to a third-party API stops being a guessing game.
If you are working on something where the third-party API does not support CORS at all, a small proxy on Cloudflare Workers or a serverless function is usually the cleanest fix, and it is a pattern worth having in your toolkit regardless of which API you are integrating with next.