Programming Beginner 8 min

How to Fetch and Render API Data with Vanilla JavaScript

The Fetch API is the modern way to make HTTP requests in the browser. It returns Promises, pairs cleanly with async/await, and ships natively in every browser — no library needed. But it has one sharp edge beginners routinely miss: fetch does not throw on HTTP error responses. A 404 or 500 response is a resolved Promise, not a rejected one.

This guide walks through the complete pattern: fetching JSON from a real API, checking the response status explicitly, rendering results into the DOM safely, handling loading and error states, and cancelling in-flight requests so stale data never overwrites fresh results.

All examples use https://api.github.com/users/octocat — publicly available, no authentication required.

Step-by-step

  1. 1

    Make a basic fetch call

    Call fetch(url), which returns a Promise that resolves to a Response object. Call response.json() to parse the body — this also returns a Promise. Use await for both.

    javascript
    async function getUser() {
      const response = await fetch('https://api.github.com/users/octocat');
      const data = await response.json();
      console.log(data);
    }
    
    getUser();
  2. 2

    Check response.ok — fetch does not throw on errors

    If the server returns a 404 or 500, fetch still resolves. The Promise only rejects on network failures (no connection, DNS error, CORS block). You must check response.ok (true for 200–299) or response.status explicitly, then throw your own error.

    javascript
    async function getUser(username) {
      const response = await fetch(`https://api.github.com/users/${username}`);
    
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
      }
    
      return response.json();
    }
  3. 3

    Wrap everything in try/catch

    Network errors, JSON parse failures, and the explicit throw from the previous step all surface as rejected Promises. A single try/catch block handles all of them in one place.

    javascript
    async function getUser(username) {
      try {
        const response = await fetch(`https://api.github.com/users/${username}`);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        renderUser(data);
      } catch (err) {
        showError(err.message);
      }
    }
  4. 4

    Show loading and error states

    Set a loading indicator before the request, hide it after. On error, show a message in the UI — never silently swallow errors. Keep the error element in your HTML but hide it by default.

    javascript
    const output = document.getElementById('output');
    const errorEl = document.getElementById('error');
    const loader = document.getElementById('loader');
    
    async function getUser(username) {
      loader.hidden = false;
      errorEl.hidden = true;
      output.hidden = true;
    
      try {
        const response = await fetch(`https://api.github.com/users/${username}`);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        renderUser(data);
        output.hidden = false;
      } catch (err) {
        errorEl.textContent = `Failed to load: ${err.message}`;
        errorEl.hidden = false;
      } finally {
        loader.hidden = true;
      }
    }
  5. 5

    Render data safely into the DOM

    Use textContent for any user-supplied or API-sourced string. It escapes HTML automatically — innerHTML does not, and could execute a script tag if the API returns malicious data. Only use innerHTML when you control 100% of the content.

    javascript
    function renderUser(data) {
      const card = document.getElementById('user-card');
    
      // Safe: textContent escapes HTML entities
      card.querySelector('.name').textContent = data.name ?? data.login;
      card.querySelector('.bio').textContent = data.bio ?? 'No bio.';
      card.querySelector('.repos').textContent = `${data.public_repos} repositories`;
    
      // Safe for URLs you trust (keep validation tight in real apps)
      const avatar = card.querySelector('.avatar');
      avatar.src = data.avatar_url;
      avatar.alt = `Avatar of ${data.login}`;
    }
  6. 6

    Cancel stale requests with AbortController

    When a user types a new search query before the previous one completes, the old response should not replace the new one. AbortController lets you cancel in-flight requests. Create a new controller for each call and abort the previous one.

    javascript
    let currentController = null;
    
    async function searchUsers(query) {
      // Abort any in-flight request
      if (currentController) currentController.abort();
      currentController = new AbortController();
    
      try {
        const response = await fetch(
          `https://api.github.com/search/users?q=${encodeURIComponent(query)}`,
          { signal: currentController.signal }
        );
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        renderResults(data.items);
      } catch (err) {
        if (err.name === 'AbortError') return; // Expected — ignore
        showError(err.message);
      }
    }
  7. 7

    Send POST requests with JSON body

    For POST/PUT/PATCH requests, pass a second options object to fetch. Stringify the body, and set Content-Type: application/json so the server parses it correctly.

    javascript
    async function createRepo(name, isPrivate = false) {
      const response = await fetch('https://api.github.com/user/repos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${token}`,
        },
        body: JSON.stringify({ name, private: isPrivate }),
      });
    
      if (!response.ok) {
        const err = await response.json();
        throw new Error(err.message ?? `HTTP ${response.status}`);
      }
    
      return response.json();
    }

Tips & gotchas

  • Always check <code>response.ok</code> before calling <code>response.json()</code> — a 4xx/5xx body might not be valid JSON.
  • Use <code>encodeURIComponent()</code> on any query parameter derived from user input to prevent URL injection.
  • The browser caches GET requests aggressively. Append a cache-busting param or use <code>cache: 'no-store'</code> in the fetch options when you always need fresh data.
  • For multiple concurrent requests, <code>Promise.all([fetch(a), fetch(b)])</code> runs them in parallel and waits for both.
  • Avoid re-fetching data that hasn't changed. A simple in-memory Map keyed by URL is a lightweight client-side cache.

Wrapping up

The fetch API covers everything you need for HTTP requests in the browser, but you have to use it correctly. The key habits: check response.ok, wrap with try/catch, render with textContent, and cancel with AbortController. Get these four right and you won't need Axios for most projects.

#JavaScript #API #Fetch
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.