JavaScript · Tutorial

JavaScript Fetch API Tutorial — Call Free APIs with No Setup

📅 April 2025 ⏱ 13 min read 🏷️ JavaScript · Fetch · Async/Await

The JavaScript Fetch API is the modern, built-in way to make HTTP requests from the browser — no libraries, no npm installs, no setup required. In this tutorial, you'll go from zero to confidently calling free public APIs using fetch() with async/await, properly handling errors, parsing JSON, passing query parameters, and making POST requests. Every code example uses a real free API you can run right now.

What is the Fetch API?

The Fetch API is a modern browser built-in that replaces the older XMLHttpRequest for making HTTP requests. It works in every major browser and Node.js 18+. The key thing to understand about fetch is that it's asynchronous — it returns a Promise that resolves when the response arrives, rather than blocking execution while waiting.

You don't need to install anything. Open your browser's DevTools console (F12 → Console) and type fetch — if it returns ƒ fetch(), it's available. Every modern browser has it.

Basic GET Request

The simplest possible fetch call looks like this. Open your browser console and paste it in:

// The most basic fetch call — returns a Promise
fetch('https://dog.ceo/api/breeds/image/random')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

// Output: { status: 'success', message: 'https://images.dog.ceo/...' }

This uses Promise chaining (.then()). The first .then() receives the raw response and calls .json() to parse it (which itself returns another Promise). The second .then() receives the parsed JSON data. The .catch() handles any network errors.

While this works, the Promise chain syntax becomes unwieldy with more complex logic. The modern way to write fetch calls is with async/await.

Async/Await Pattern (Recommended)

Async/await is syntactic sugar over Promises that makes asynchronous code read like synchronous code. The await keyword pauses execution until the Promise resolves, then returns the resolved value. You can only use await inside an async function.

// The recommended way to use fetch — async/await pattern
const getDogImage = async () => {
  const response = await fetch('https://dog.ceo/api/breeds/image/random');
  const data = await response.json();
  console.log(data.message); // The image URL
};

getDogImage(); // Call the async function

This reads top-to-bottom like normal code, with no nested callbacks or chains. Every await pauses the function and waits for the result before moving to the next line. This is how professional JavaScript developers write API calls in 2025.

Proper Error Handling

This is the most commonly skipped step that causes production bugs. Fetch has a quirk: it only rejects the Promise on network failures (no internet, DNS failure, etc.). HTTP errors like 404 or 500 don't throw — the Promise resolves successfully, but response.ok is false.

// WRONG — won't catch HTTP errors (404, 500, etc.)
try {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/999999');
  const data = await response.json(); // Returns {} — no error thrown!
} catch (e) { /* Only catches network failures */ }

// CORRECT — always check response.ok before parsing
const safeFetch = async (url) => {
  try {
    const response = await fetch(url);

    // Manually throw for HTTP errors (4xx, 5xx)
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
    }

    return response.json(); // Safe to parse now

  } catch (error) {
    console.error('Fetch failed:', error.message);
    throw error; // Re-throw so callers can handle it
  }
};

// Usage
try {
  const data = await safeFetch('https://dog.ceo/api/breeds/image/random');
  console.log(data);
} catch (e) {
  // Handle error in UI
  document.getElementById('error').textContent = 'Failed to load data. Try again.';
}

The safeFetch wrapper pattern above is the professional standard. It checks response.ok (true for 200-299 status codes), throws a meaningful error for HTTP failures, and catches network errors — so your calling code has a single, consistent error handling path.

Passing Query Parameters

Many free APIs accept query parameters to filter, paginate, or customize the response. The cleanest way to build parameterized URLs is with URLSearchParams:

// Building URLs with query parameters — the clean way
const params = new URLSearchParams({
  ids: 'bitcoin,ethereum',
  vs_currencies: 'usd,eur'
});

const url = `https://api.coingecko.com/api/v3/simple/price?${params}`;
const response = await fetch(url);
const prices = await response.json();
// { bitcoin: { usd: 65000, eur: 60000 }, ethereum: { ... } }

// URLSearchParams handles special characters automatically
// e.g., city names like "São Paulo" become "S%C3%A3o%20Paulo"
const geoParams = new URLSearchParams({ name: 'São Paulo', count: '1' });
const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?${geoParams}`;

Working with JSON Responses

Once you have the parsed JSON object, you access its data using standard JavaScript property access and array methods:

// Fetch all posts from JSONPlaceholder free API
const posts = await (await fetch('https://jsonplaceholder.typicode.com/posts')).json();

// posts is an array of 100 objects — use standard JS methods:
console.log(posts.length);           // 100
console.log(posts[0].title);        // First post title
console.log(posts.filter(p => p.userId === 1).length); // Posts by user 1

// Display all post titles in the DOM
const list = document.getElementById('posts-list');
list.innerHTML = posts.slice(0, 10)  // First 10
  .map(post => `<li>${post.title}</li>`)
  .join('');

Making POST Requests

GET is the default method. For POST, PUT, PATCH, or DELETE, pass a second options object to fetch:

// POST request to a free test API
const newPost = {
  title: 'Hello from fetch()',
  body: 'My first POST request',
  userId: 1
};

const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // REQUIRED for JSON body
  },
  body: JSON.stringify(newPost)         // Convert object → JSON string
});

const created = await response.json();
console.log(created.id);  // 101 — the new post's ID
console.log(response.status); // 201 Created

Three things are required for a JSON POST request: set method: 'POST', add the Content-Type: application/json header (tells the server the body is JSON), and convert your JavaScript object to a JSON string with JSON.stringify().

Adding Headers (API Keys)

Some APIs require authentication via a header. The two most common patterns are Authorization: Bearer TOKEN and custom X-Api-Key: YOUR_KEY headers:

// API key in Authorization header (Bearer token pattern)
const response = await fetch('https://api.example.com/data', {
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN_HERE'
  }
});

// API key as a custom header (some APIs use this pattern)
const response2 = await fetch('https://api.example.com/data', {
  headers: {
    'X-Api-Key': 'YOUR_KEY',
    'Accept': 'application/json'  // Request JSON response
  }
});

// ⚠️ Never put API keys in client-side code for production apps.
// Use a backend proxy or environment variables. For learning/testing
// with free APIs, it's fine to hardcode them temporarily.

Parallel API Calls with Promise.all()

When you need data from multiple endpoints, don't await them one after another — run them in parallel with Promise.all() for a huge performance win:

// Sequential — SLOW (each request waits for the previous)
const user = await fetch('.../users/1').then(r => r.json());
const posts = await fetch('.../posts?userId=1').then(r => r.json());
// Total time: 200ms + 180ms = 380ms

// Parallel — FAST (both requests fire simultaneously)
const [user, posts] = await Promise.all([
  fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json()),
  fetch('https://jsonplaceholder.typicode.com/posts?userId=1').then(r => r.json())
]);
// Total time: ~200ms (limited by the slowest request, not their sum)
console.log(user.name, posts.length);

5 Common Fetch Mistakes

1. Not checking response.ok. Fetch resolves even on 404/500. Always check if (!response.ok) throw new Error(response.status) before calling .json().

2. Forgetting Content-Type on POST. Without 'Content-Type': 'application/json', many APIs will reject your POST or fail to parse the body correctly.

3. Forgetting JSON.stringify() on the body. The body option expects a string — passing a plain JavaScript object won't work. Always wrap with JSON.stringify(yourObject).

4. Not handling CORS errors. If you get a "Access-Control-Allow-Origin" error, the API doesn't allow browser-to-API calls. You'll need to call it from a backend instead. Check our no-key API list — all entries with CORS ✓ work from the browser.

5. Calling fetch in a non-async function with await. If you get "SyntaxError: await is only valid in async functions", wrap your code in an async function or use an IIFE: (async () => { await fetch(...); })().

🚀 Find Free APIs to Practice Fetch With

Browse 1,500+ free public APIs — all returnable with JavaScript fetch(). View the Complete API Directory →