Week 10 - Asynchronous programming

Synchronous vs Asynchronous Code

Callbacks

Promises

Asynchronous File I/O

Event Loop

Fetch API

Async/Await and Promise.all()

AI Using GitHub Copilot in VS Code

Practice

Assignment

Back to core program

The Fetch API

In earlier chapters you used tools like curl and Postman to make HTTP requests to external web APIs. In this chapter we will do the same thing directly from our JavaScript code using the Fetch API in Node.js.

We will:

<aside> 💭

The examples in this chapter are meant to run in Node.js, not in the browser. Modern versions of Node (v18 and later) provide a built‑in fetch function globally, so you do not need to install anything for the basic examples.

</aside>

What is fetch?

You have already seen a few different ways to talk to web APIs:

Both do essentially the same thing: they send HTTP requests and show you the HTTP responses.

fetch is a JavaScript API that lets you do the same thing from your code. It:

In the browser, fetch has been available for a long time. In Node.js it is now also available globally in recent versions, so you can simply write:

const url = '<https://jsonplaceholder.typicode.com/posts/1>';
fetch(url);

The important part is that fetch(url) returns a promise, which you should handle using the techniques you learned in the Promises chapter.

A first example

Let us start with a simple GET request that fetches a single post and logs it.

const url = '<https://jsonplaceholder.typicode.com/posts/1>';

fetch(url)
  .then((response) => {
    console.log('Status:', response.status);
    console.log('OK?:', response.ok);
    return response.json(); // parse body as JSON
  })
  .then((data) => {
    console.log('Data:', data);
  })
  .catch((error) => {
    console.error('Network or parsing error:', error);
  });

/*
Possible output:
Status: 200
OK?: true
Data: {
  userId: 1,
  id: 1,
  title: 'sunt aut facere repellat ...',
  body: 'quia et suscipit ...'
}
*/

Let’s connect this to what you already know:

From a Promises perspective, fetch is “just” another function that returns a promise.

Handling HTTP errors correctly

There is an important detail about fetch that surprises many developers the first time they see it:

<aside> ⚠️

fetch only rejects its promise on network errors, not on HTTP errors like 404 or 500.

</aside>

In other words:

This means you must check the response.ok or response.status manually and treat a bad status as an error yourself.

const url = '<https://jsonplaceholder.typicode.com/this-path-does-not-exist>';

fetch(url)
  .then((response) => {
    console.log('Status:', response.status); // e.g. 404
    if (!response.ok) {
      // Turn this into a rejected promise so it is handled by .catch()
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log('This will not run for a 404');
    console.log('Data:', data);
  })
  .catch((error) => {
    console.error('Error while fetching:', error.message);
  });

/*
Possible output:
Status: 404
Error while fetching: Request failed with status 404
*/

You can compare this to how you handled errors in the Promises and File I/O chapters:

Using query parameters

You often need to send query parameters like ?userId=1&page=2. You could manually concatenate strings, but Node.js provides a safer option: the URL class.

const BASE_URL = '<https://jsonplaceholder.typicode.com/posts>';
const url = new URL(BASE_URL);

url.searchParams.set('userId', '1'); // adds ?userId=1

console.log('Full URL:', url.toString());

fetch(url)
  .then((response) => {
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json();
  })
  .then((posts) => {
    console.log(`Received ${posts.length} posts`);
    console.log(posts.map((post) => post.title));
  })
  .catch((error) => {
    console.error('Error:', error.message);
  });
  
/*
Possible output:
Full URL: <https://jsonplaceholder.typicode.com/posts?userId=1>
Received 10 posts
[
  (ommitted for brevity)
]

This is very similar to how you might add query parameters in Postman or curl, but now you are doing it inside your JavaScript code.

Adding headers (for example API keys)

Most real‑world APIs require custom headers:

You pass headers as part of the second argument to fetch:

const url = '<https://api.example.com/protected-resource>';

fetch(url, {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    'X-API-Key': 'your-api-key-here', // example value
  },
})
  .then((response) => {
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log('Protected data:', data);
  })
  .catch((error) => {
    console.error('Error:', error.message);
  });

<aside> ❗

Never commit real API keys or other secrets to Git. Use environment variables or configuration files that are not part of your version control history.

</aside>

For this module we will mostly work with public APIs that do not require authentication, but the pattern remains the same.

Making a POST request with JSON

Up to now we have only made GET requests. Let us now send a POST request with a JSON body.

In Postman you would:

In fetch you do the same, but in JavaScript:

const url = '<https://jsonplaceholder.typicode.com/posts>';

const newPost = {
  title: 'Hello from Node fetch',
  body: 'This is the body of the post.',
  userId: 1,
};

fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(newPost),
})
  .then((response) => {
    if (!response.ok) {
      throw new Error(`Failed to create post: ${response.status}`);
    }
    return response.json();
  })
  .then((createdPost) => {
    console.log('Created post:', createdPost);
  })
  .catch((error) => {
    console.error('Error:', error.message);
  });

/*
Possible output:
Created post: {
  title: 'Hello from Node fetch',
  body: 'This is the body of the post.',
  userId: 1,
  id: 101
}
*/

This pattern (method + headers + JSON body) is extremely common when working with HTTP APIs.

Comparing fetch to curl and Postman

Let us look at the same request in three different tools.

curl

curl -X POST \\
-H "Content-Type: application/json" \\
-d '{"title":"Hello","body":"World","userId":1}' \\
<https://jsonplaceholder.typicode.com/posts>

Postman

Node.js with fetch

fetch('<https://jsonplaceholder.typicode.com/posts>', {
  method: 'POST',

  headers: {
    'Content-Type': 'application/json',
  },

  body: JSON.stringify({
    title: 'Hello',
    body: 'World',
    userId: 1,
  }),
})
  .then((response) => response.json())
  .then((data) => {
    console.log('Response:', data);
  })
  .catch((error) => {
    console.error('Error:', error.message);
  });

The HTTP information is the same in all three cases. Only the interface and syntax change.

Common pitfalls with fetch and promises

You have already seen some typical promise mistakes in the Promises chapter. When using fetch, the same rules apply.

Forgetting to return response.json()

If you forget the return in a .then() block, the next .then() receives undefined.

// ❌ Wrong: missing return

fetch('<https://jsonplaceholder.typicode.com/posts/1>')
  .then((response) => {
    response.json(); // this promise is ignored
  })
  .then((data) => {
    console.log('Data:', data); // Data: undefined
  })
  .catch((error) => {
    console.error('Error:', error);
  });

Correct:

// ✅ Correct: return the promise from response.json()

fetch('<https://jsonplaceholder.typicode.com/posts/1>')
  .then((response) => {
    return response.json();
  })
  .then((data) => {
    console.log('Data:', data);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

Or using an implicit return:

fetch('<https://jsonplaceholder.typicode.com/posts/1>')
  .then((response) => response.json())
  .then((data) => {
    console.log('Data:', data);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

Ignoring response.ok

If you never check response.ok, your code might happily call response.json() even for a 404 or 500 response.

// ❌ Not checking response.ok

fetch('<https://jsonplaceholder.typicode.com/this-path-does-not-exist>')
  .then((response) => response.json())
  .then((data) => {
    console.log('Maybe error page, maybe not:', data);
  })
  .catch((error) => {
    console.error('Network error only:', error);
  });

Better:

// ✅ Check response.ok

fetch('<https://jsonplaceholder.typicode.com/this-path-does-not-exist>')
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log('This will only run for 2xx responses:', data);
  })
  .catch((error) => {
    console.error('Request failed:', error.message);
  });

This is completely in line with the way you used promises in the asynchronous file I/O chapter: treat errors explicitly and let them be handled in one central .catch().

Summary

In this chapter you learned that: