JavaScript Fetch API with async/await: GET, POST, Error Handling and Timeouts

Almost every modern web page loads data from somewhere: a weather widget, a product list, a chat window, a dashboard. In JavaScript, the standard way to do that is the Fetch API, and the cleanest way to use it is with async/await. Old tutorials often show long chains of .then() calls, which work, but are harder to read and much easier to get wrong.

In this tutorial, you will learn how to use fetch() with async/await to make GET and POST requests, how to handle errors the right way (including the 404 trap that catches many beginners), how to add a timeout, and how to run several requests at the same time. At the end, we will build a small page that loads users into a list. All examples were run and the outputs below are the real results.

Requirements

  • Any modern browser (Chrome, Edge, Firefox, Safari)
  • Node.js 18 or newer if you want to run the examples in the terminal, since fetch is built into Node from version 18
  • A code editor such as VS Code

We will use JSONPlaceholder, a free fake REST API made for testing, so you do not need to set up a server.

Step 1: A Basic GET Request

fetch() returns a Promise. With await, JavaScript pauses inside the async function until the response arrives, so the code reads from top to bottom like normal code:

  1. // 1. Basic GET request
  2. async function getUser(id) {
  3.   const response = await fetch(`${API}/users/${id}`);
  4.   const user = await response.json();
  5.   return user;
  6. }

There are two await keywords here, and both are needed. The first waits for the response headers. The second waits for the body to download and be parsed as JSON with response.json().

Step 2: Handle Errors Properly

Here is the part many developers miss: fetch() does not throw an error for HTTP errors like 404 or 500. It only rejects when the request could not be made at all (no internet, DNS failure, blocked by CORS). A 404 page is still a "successful" response from fetch's point of view.

That is why you should always check response.ok, which is true only for status codes 200 to 299. Let's write a reusable helper that does this, sends JSON headers, and also adds a timeout so a slow server cannot freeze your app forever:

  1. // 2. A reusable helper with error handling and a timeout
  2. async function request(url, options = {}, timeoutMs = 8000) {
  3.   try {
  4.     const response = await fetch(url, {
  5.       ...options,
  6.       headers: { 'Content-Type': 'application/json', ...options.headers },
  7.       signal: AbortSignal.timeout(timeoutMs),
  8.     });
  9.  
  10.     if (!response.ok) {
  11.       throw new Error(`HTTP ${response.status} ${response.statusText}`);
  12.     }
  13.     return await response.json();
  14.   } catch (error) {
  15.     if (error.name === 'TimeoutError') {
  16.       throw new Error(`Request timed out after ${timeoutMs} ms`);
  17.     }
  18.     throw error;
  19.   }
  20. }

A few things are going on here:

  • AbortSignal.timeout(ms) cancels the request automatically after the given time. When that happens, fetch rejects with an error named TimeoutError, which we turn into a friendly message.
  • We use the spread operator (...options) so callers can pass their own method, body or extra headers.
  • If the status is not OK, we throw our own error. Now every problem, whether network, timeout or HTTP status, ends up in one place: the caller's catch block.

Step 3: Send Data with a POST Request

To send data, set the method to POST and convert your object to a JSON string with JSON.stringify(). Our helper already adds the Content-Type: application/json header, so this function stays short:

  1. // 3. POST request that sends JSON
  2. async function createPost(title, body) {
  3.   return request(`${API}/posts`, {
  4.     method: 'POST',
  5.     body: JSON.stringify({ title, body, userId: 1 }),
  6.   });
  7. }

PUT, PATCH and DELETE requests work exactly the same way; only the method value changes.

Step 4: Run Requests in Parallel with Promise.all

If two requests do not depend on each other, do not wait for them one by one. Promise.all() starts them together and waits until both are finished, which can make your page noticeably faster:

  1. // 4. Run several requests at the same time
  2. async function getDashboard() {
  3.   const [user, todos] = await Promise.all([
  4.     request(`${API}/users/2`),
  5.     request(`${API}/todos?userId=2`),
  6.   ]);
  7.   const done = todos.filter((todo) => todo.completed).length;
  8.   return `${user.name} has finished ${done} of ${todos.length} tasks`;
  9. }

Keep in mind that if any request in Promise.all fails, the whole thing rejects. If you want partial results even when one fails, use Promise.allSettled() instead.

The Complete Script

Here is everything together in one file. Save it as fetch-demo.mjs and run it with node fetch-demo.mjs. The last two calls in main() intentionally fail so you can see the error handling in action: one asks for a user that does not exist, and the other uses a 1 millisecond timeout.

  1. // fetch-demo.mjs - run with: node fetch-demo.mjs  (Node 18+)
  2. const API = 'https://jsonplaceholder.typicode.com';
  3.  
  4. // 1. Basic GET request
  5. async function getUser(id) {
  6.   const response = await fetch(`${API}/users/${id}`);
  7.   const user = await response.json();
  8.   return user;
  9. }
  10.  
  11. // 2. A reusable helper with error handling and a timeout
  12. async function request(url, options = {}, timeoutMs = 8000) {
  13.   try {
  14.     const response = await fetch(url, {
  15.       ...options,
  16.       headers: { 'Content-Type': 'application/json', ...options.headers },
  17.       signal: AbortSignal.timeout(timeoutMs),
  18.     });
  19.  
  20.     if (!response.ok) {
  21.       throw new Error(`HTTP ${response.status} ${response.statusText}`);
  22.     }
  23.     return await response.json();
  24.   } catch (error) {
  25.     if (error.name === 'TimeoutError') {
  26.       throw new Error(`Request timed out after ${timeoutMs} ms`);
  27.     }
  28.     throw error;
  29.   }
  30. }
  31.  
  32. // 3. POST request that sends JSON
  33. async function createPost(title, body) {
  34.   return request(`${API}/posts`, {
  35.     method: 'POST',
  36.     body: JSON.stringify({ title, body, userId: 1 }),
  37.   });
  38. }
  39.  
  40. // 4. Run several requests at the same time
  41. async function getDashboard() {
  42.   const [user, todos] = await Promise.all([
  43.     request(`${API}/users/2`),
  44.     request(`${API}/todos?userId=2`),
  45.   ]);
  46.   const done = todos.filter((todo) => todo.completed).length;
  47.   return `${user.name} has finished ${done} of ${todos.length} tasks`;
  48. }
  49.  
  50. async function main() {
  51.   const user = await getUser(1);
  52.   console.log('GET    :', user.name, '-', user.email);
  53.  
  54.   const post = await createPost('Hello Fetch', 'Learning async/await');
  55.   console.log('POST   :', post);
  56.  
  57.   console.log('PARALLEL:', await getDashboard());
  58.  
  59.   try {
  60.     await request(`${API}/users/9999`);
  61.   } catch (error) {
  62.     console.log('ERROR  :', error.message);
  63.   }
  64.  
  65.   try {
  66.     await request(`${API}/photos`, {}, 1);
  67.   } catch (error) {
  68.     console.log('TIMEOUT:', error.message);
  69.   }
  70. }
  71.  
  72. main();

Output

GET    : Leanne Graham - [email protected]
POST   : {
  title: 'Hello Fetch',
  body: 'Learning async/await',
  userId: 1,
  id: 101
}
PARALLEL: Ervin Howell has finished 8 of 20 tasks
ERROR  : HTTP 404 Not Found
TIMEOUT: Request timed out after 1 ms

Notice the 404 line. Without the response.ok check, that request would have "succeeded" and returned an empty object, and the bug would have been hard to spot.

Using Fetch in the Browser

Now let's use the same ideas on a real page. This example shows a loading message, disables the button while the request is running, displays the users, and shows a readable message if something goes wrong. Save it as index.html and open it in your browser.

  1. <!DOCTYPE html>
  2. <html lang="en">
  3.   <meta charset="UTF-8">
  4.   <title>Fetch API Demo</title>
  5.   <style>
  6.     body { font-family: Arial, sans-serif; max-width: 520px; margin: 40px auto; }
  7.     li { padding: 6px 0; border-bottom: 1px solid #ddd; }
  8.     .error { color: #c0392b; }
  9.   </style>
  10. </head>
  11.   <h2>Users</h2>
  12.   <button id="loadBtn">Load Users</button>
  13.   <p id="status"></p>
  14.   <ul id="userList"></ul>
  15.  
  16.   <script>
  17.     const statusEl = document.getElementById('status');
  18.     const listEl = document.getElementById('userList');
  19.     const loadBtn = document.getElementById('loadBtn');
  20.  
  21.     async function loadUsers() {
  22.       statusEl.textContent = 'Loading...';
  23.       statusEl.className = '';
  24.       listEl.innerHTML = '';
  25.       loadBtn.disabled = true;
  26.  
  27.       try {
  28.         const response = await fetch('https://jsonplaceholder.typicode.com/users');
  29.         if (!response.ok) {
  30.           throw new Error(`HTTP ${response.status}`);
  31.         }
  32.         const users = await response.json();
  33.  
  34.         users.forEach((user) => {
  35.           const li = document.createElement('li');
  36.           li.textContent = `${user.name} (${user.email})`;
  37.           listEl.appendChild(li);
  38.         });
  39.         statusEl.textContent = `Loaded ${users.length} users.`;
  40.       } catch (error) {
  41.         statusEl.textContent = `Could not load users: ${error.message}`;
  42.         statusEl.className = 'error';
  43.       } finally {
  44.         loadBtn.disabled = false;
  45.       }
  46.     }
  47.  
  48.     loadBtn.addEventListener('click', loadUsers);
  49.   </script>
  50. </body>
  51. </html>

The finally block runs whether the request succeeds or fails, which makes it the perfect place to re-enable the button. Also notice that we use textContent instead of innerHTML to show data from the API, which protects the page from HTML injection.

Here is the page after clicking Load Users:

JavaScript Fetch API async await demo loading users into a list

Common Mistakes to Avoid

  • Forgetting await before response.json(). You will get a pending Promise instead of your data.
  • Using await outside an async function. In normal scripts, await must be inside an async function. It is only allowed at the top level in JavaScript modules.
  • Not checking response.ok. As shown above, fetch happily resolves for 404 and 500 responses.
  • Sending an object as the body. Always use JSON.stringify(), or the server will receive [object Object].
  • Blaming fetch for CORS errors. If the browser blocks a request from another domain, the fix is on the server, which must send the right CORS headers.

Conclusion

The Fetch API with async/await gives you clean, readable code for talking to any REST API. Remember the three habits that separate working code from fragile code: always check response.ok, wrap your calls in try...catch, and add a timeout. Once you have a small helper like request(), you can reuse it across your whole project. You can download the complete source code below. Happy coding!

More Tutorials for JavaScript

JavaScript Tutorials