DEV Community

Cover image for How to Call an API with JavaScript fetch() and Handle Errors Properly
Loknath Patel
Loknath Patel

Posted on AI-assisted

How to Call an API with JavaScript fetch() and Handle Errors Properly

Most fetch() tutorials only show the happy path. The bug that actually catches beginners is a different one: fetch() does not throw an error when a server responds with a 404 or 500. Your code just keeps running with bad data.

This article is for you if you know basic JavaScript but haven't used fetch() yet. We'll write a small script that:

  1. requests data from a public test API,
  2. checks whether the request succeeded,
  3. parses the JSON response,
  4. handles errors and timeouts without crashing.

fetch() works in modern browsers and in Node.js (it's built in since Node 18, so use a currently supported LTS version). There's nothing to install.

The test API

We'll use JSONPlaceholder, a free fake REST API made for testing and prototyping. It doesn't need an API key. The /posts/1 endpoint returns this JSON:

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
Enter fullscreen mode Exit fullscreen mode

What fetch() actually does

fetch(url) sends an HTTP request (a GET by default) and returns a Promise. Once the server sends back its response headers, that promise resolves to a Response object.

Two things to know before we go further:

  • The Response isn't your data yet. It's a wrapper around the status, headers, and body. To get the data, you call a method like response.json(), which also returns a Promise.
  • fetch() only rejects its promise when the request itself fails, for example when there's no connection, the URL is invalid, CORS blocks the request, or it gets aborted or times out. A 404 or 500 still counts as a completed round trip, so the promise resolves. That's why you have to check the status yourself.

async/await in one minute

async/await lets you write Promise-based code that reads from top to bottom.

  • A function marked async always returns a Promise.
  • Inside it, await pauses the function until a Promise settles, then hands you the result.
  • If the awaited Promise rejects, await throws, so you can catch it with try/catch.

The complete example

Save this as example.mjs. The .mjs extension lets Node run it as an ES module without any setup:

const BASE_URL = "https://jsonplaceholder.typicode.com";

async function getPost(id) {
  const response = await fetch(`${BASE_URL}/posts/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json();
}

async function main() {
  try {
    const post = await getPost(1);
    console.log("Title:", post.title);
    console.log("Post:", post);
  } catch (error) {
    console.error("Request failed:", error.message);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

Run it:

node example.mjs
Enter fullscreen mode Exit fullscreen mode

Expected output

If the request succeeds, Node prints:

Title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Post: {
  userId: 1,
  id: 1,
  title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
  body: 'quia et suscipit\n' +
    'suscipit recusandae consequuntur expedita et cum\n' +
    'reprehenderit molestiae ut ut quas totam\n' +
    'nostrum rerum est autem sunt rem eveniet architecto'
}
Enter fullscreen mode Exit fullscreen mode

Node splits long strings across lines when it prints objects, but the data is the same JSON you saw above.

You can also skip Node entirely: open your browser's DevTools console, paste in the whole script, and press Enter.

Watch an error happen

Now change getPost(1) to getPost(99999) and run it again. That post doesn't exist, so JSONPlaceholder responds with a 404:

Request failed: HTTP error: 404
Enter fullscreen mode Exit fullscreen mode

Without the response.ok check, this request would have looked like a success, and your code would have carried on with an empty object. That's exactly what the check protects you from.

How it works, step by step

1. Send the request.

const response = await fetch(`${BASE_URL}/posts/${id}`);
Enter fullscreen mode Exit fullscreen mode

await waits for the response headers to arrive and gives you the Response object.

2. Check for HTTP errors with response.ok.

if (!response.ok) {
  throw new Error(`HTTP error: ${response.status}`);
}
Enter fullscreen mode Exit fullscreen mode

response.ok is true when the status code is between 200 and 299. For anything else, like a 404 or 500, we throw our own error so the problem gets handled in one place instead of being ignored.

3. Parse the JSON.

return response.json();
Enter fullscreen mode Exit fullscreen mode

response.json() reads the body and turns it into a JavaScript object. It returns a Promise, and since we return it from an async function, callers get the parsed object when they await getPost(1).

4. Catch errors in one place. The try/catch in main() covers everything that can go wrong: network failures, the error we throw for bad status codes, and invalid JSON.

Gotcha: This works because the try/catch lives in main(). If you later add a try/catch inside getPost, a JSON parse error will slip past it when you write return response.json(). Use return await response.json() there, so the rejection is thrown inside your try block.

The ways a request can fail

What went wrong What happens
Server returns 404 or 500 fetch() resolves, response.ok is false, and our code throws
Server returns a non-JSON body with a 200 status response.json() rejects with a SyntaxError
Server can't be reached fetch() rejects with a TypeError
Request takes too long (with a timeout set) fetch() rejects with a TimeoutError

Error message text varies between runtimes and versions, and browsers and Node word things differently. So don't compare against message strings. Use response.status for HTTP errors, and treat everything else as "something went wrong."

If you want to react to a specific status code, attach it to the error:

if (!response.ok) {
  const error = new Error(`HTTP error: ${response.status}`);
  error.status = response.status;
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Tip for Node: A network failure often shows up as just fetch failed. The useful detail, such as ENOTFOUND or ECONNREFUSED, is in error.cause, so log that too:

console.error("Request failed:", error.message, error.cause);
Enter fullscreen mode Exit fullscreen mode

Tip for browsers: If CORS blocks a request, all you'll see is a generic TypeError: Failed to fetch. Check the DevTools console and network tab for the real reason. The server has to allow your origin, and you can't fix that from the client.

Add a timeout

If a server never answers, a request can hang for a very long time. To give up after a set number of milliseconds, pass AbortSignal.timeout() as the signal:

const response = await fetch(`${BASE_URL}/posts/${id}`, {
  signal: AbortSignal.timeout(5000), // give up after 5 seconds
});
Enter fullscreen mode Exit fullscreen mode

When time runs out, fetch() rejects with an error whose name is "TimeoutError". You can handle it separately in your catch:

catch (error) {
  if (error.name === "TimeoutError") {
    console.error("The request took too long.");
  } else {
    console.error("Request failed:", error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Common mistakes

1. Assuming fetch() throws on 404 or 500.
It doesn't. Try getPost(99999): JSONPlaceholder returns a 404, the promise resolves normally, and response.ok is false. Without a response.ok check, your code behaves as if everything worked.

2. Forgetting await (or .json()).
Both fetch() and response.json() return Promises. If you skip await, you get a pending Promise (it prints as Promise { <pending> }) instead of your data.

3. Reading the body twice.
A response body can only be read once, and calling response.json() a second time throws a TypeError. If you need it twice, call response.clone() first, or store the result in a variable.

4. Calling response.json() on a response that isn't JSON.
Some error responses come back as plain text or HTML. Check response.ok first, and use response.text() when you need to see what the server actually sent.

5. Skipping try/catch.
An unhandled rejection from await fetch(...) can crash a Node script or leave a browser UI stuck on a loading state.

Bonus: sending data with POST

The same pattern works for other HTTP methods. Pass an options object as the second argument. Save this as a separate file called post.mjs:

const BASE_URL = "https://jsonplaceholder.typicode.com";

async function createPost(data) {
  const response = await fetch(`${BASE_URL}/posts`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
    signal: AbortSignal.timeout(5000),
  });

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json();
}

async function main() {
  try {
    const created = await createPost({
      title: "Hello",
      body: "My first post",
      userId: 1,
    });
    console.log(created);
  } catch (error) {
    console.error("Request failed:", error.message);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

Run it with node post.mjs. JSONPlaceholder accepts write requests but doesn't save anything, so it's safe to experiment. It echoes back what you sent, plus a fake id:

{ title: 'Hello', body: 'My first post', userId: 1, id: 101 }
Enter fullscreen mode Exit fullscreen mode

Conclusion

Here's the pattern for calling an API with fetch():

  1. await fetch(url) to get the Response.
  2. Check response.ok and throw if it's false, because fetch() won't do it for you.
  3. await response.json() to get your data.
  4. Wrap everything in try/catch, and set a timeout with AbortSignal.timeout().

That covers most everyday API calls. From here, you can explore query parameters and custom request headers.

Top comments (0)