Create your own
Lesson illustration

Implementing HTTP Requests with Fetch, Async/Await, and Error Handling

Welcome back. Your Vite project now has an accessible, responsive video-library shell, but its cards are still hard-coded HTML. In this lesson, you will replace those placeholder cards with data requested over HTTP.

The immediate goal is practical: load a local JSON endpoint with fetch, wait for the response with async/await, validate that the response is usable, and give the user a meaningful loading or failure state. This same request pattern will later connect your React frontend to Django REST Framework endpoints.


What actually happens when browser code fetches data

An HTTP request is a message from a client to a server. For the library page, the browser will ask for a collection of videos:

Browser  -- GET /api/videos.json -->  Vite development server
Browser  <-- JSON response -------  Vite development server

Calling fetch() begins that request:

const responsePromise = fetch("/api/videos.json");

It does not immediately give you video data. It returns a Promise: an object representing a result that will arrive later.

That matters because network operations are unpredictable. The server might respond in milliseconds, take several seconds, return an HTTP error, or be unreachable. JavaScript starts the request and remains free to handle rendering, clicks, scrolling, and other browser work.

async and await let you write this asynchronous sequence in a readable top-to-bottom style:

async function loadVideos() {
  const response = await fetch("/api/videos.json");
  const videos = await response.json();

  console.log(videos);
}

There are two separate asynchronous operations:

  1. await fetch(...) waits until the browser receives HTTP response headers and creates a Response object.
  2. await response.json() waits for the response body to be read and parsed as JavaScript data.

A Response is not the JSON itself. It contains metadata such as status, ok, headers, and methods for reading its body.

Using Async/Await with the Fetch API - JavaScript Tutorial

Watch “Using Async/Await with the Fetch API - JavaScript Tutorial” from dcode for a compact visual walkthrough of the two await points and a try...catch boundary.

Start with awaiting fetch to see why the first result is a Response, not the final data. Then watch catching failures for the role of try...catch, and finish with parsing JSON to reinforce why response.json() also needs await.

One detail is especially important: an async function itself always returns a Promise. So this code starts loading; it does not synchronously return the finished video array:

const result = loadVideos();

console.log(result); // A Promise, not the loaded videos

Inside loadVideos, however, each await gives you the completed value for that step.


A successful HTTP response is not guaranteed

A common first attempt looks like this:

const response = await fetch("/api/videos.json");
const videos = await response.json();

It works when everything is healthy, but it quietly assumes too much.

fetch() rejects for failures such as a network interruption, an invalid URL, or a browser-blocked request. But it does not reject simply because the server responds with an HTTP status such as 404 Not Found or 500 Internal Server Error.

In both of these cases, the server sent a response, so fetch() successfully produces a Response object:

SituationDoes fetch() resolve?Is response.ok true?
200 OK with JSONYesYes
404 Not FoundYesNo
500 Internal Server ErrorYesNo
Network offline or server unreachableNo; it rejectsNo response exists

That is why reliable code has two layers of failure handling:

const response = await fetch("/api/videos.json");

if (!response.ok) {
  throw new Error(`Could not load videos: HTTP ${response.status}`);
}

response.ok is true for HTTP success statuses from 200 through 299. If it is false, throw creates a failure path that a surrounding catch can handle.

The next validation concerns the format of the body. Your endpoint promises JSON, so checking the content-type header before parsing is useful:

const contentType = response.headers.get("content-type");

if (!contentType?.includes("application/json")) {
  throw new TypeError("Expected a JSON response from the videos endpoint.");
}

The optional-chaining operator, ?., means “only call includes if a content type exists.” It prevents a separate error if the server omitted that header.

Using the Fetch API - Web APIs | MDN

Read MDN’s “Using the Fetch API” for the precise contract behind fetch, Response, status validation, headers, and body parsing. It is the reference pattern you will apply in the project work below.

At the start of the page, read the introduction and minimal example before the “In this article” list. Focus on the request overview: fetch returns a Promise that later yields a Response. Then go to “Handling the response,” especially the “Checking response status” subsection. Read the status behavior through the example that throws when ok is false. Continue into “Checking headers” and locate the content type pattern. Finally, scan “Reading the response body” to confirm that json() returns a Promise and can itself fail if the body is invalid JSON.

There is one more useful distinction. Valid HTTP and valid JSON do not automatically mean valid application data. An endpoint might return an array when your UI expects an object, or a video record without a title. For a small project, lightweight JavaScript checks can make that contract explicit. Later, Django REST Framework serializers will validate API output and input at the server boundary as well.


Replace static cards with fetched JSON

For now, Vite will serve a local JSON file. This gives you a real browser HTTP request without needing a backend yet.

Create this file:

public/api/videos.json

The public directory is served from the root of the Vite application. Therefore, while Vite runs locally, this file is available at /api/videos.json.

Add the following data:

[
  {
    "id": "postgresql-joins",
    "title": "PostgreSQL joins for application data",
    "status": "ready",
    "uploadedAt": "2026-05-18",
    "uploadedLabel": "May 18, 2026",
    "description": "12 minutes · 1080p rendition available"
  },
  {
    "id": "ffmpeg-pipeline",
    "title": "FFmpeg transcoding pipeline",
    "status": "processing",
    "uploadedAt": "2026-05-19",
    "uploadedLabel": "May 19, 2026",
    "description": "Upload received · Creating HLS renditions"
  },
  {
    "id": "react-state",
    "title": "React state and user interaction",
    "status": "ready",
    "uploadedAt": "2026-05-20",
    "uploadedLabel": "May 20, 2026",
    "description": "18 minutes · 720p and 1080p renditions available"
  }
]

Now revise the “Recent videos” section in index.html. Remove the three hard-coded video-card <li> elements and replace them with an initially empty list, a status message, and a retry button:

<section aria-labelledby="recent-videos-title">
  <div class="section-heading">
    <h2 id="recent-videos-title">Recent videos</h2>
    <a href="/videos/">View all videos</a>
  </div>

  <p id="library-status" class="library-status" role="status">
    Loading videos…
  </p>

  <ul id="video-grid" class="video-grid"></ul>

  <button
    id="retry-button"
    class="button button--secondary"
    type="button"
    hidden
  >
    Try again
  </button>
</section>

role="status" creates a polite live region. When its text changes, screen-reader users can be informed that loading completed or failed without being unexpectedly moved around the page. The retry control is a real <button> because it triggers an in-place action rather than navigating to a new URL.

Add these small CSS additions to src/style.css:

.library-status {
  min-block-size: 1.5rem;
  margin-block: 0 1rem;
  color: #475569;
}

.button--secondary {
  border: 0;
  background: #075985;
  color: #ffffff;
  cursor: pointer;
  font: inherit;
}

.button--secondary:hover {
  background: #0c4a6e;
}

The existing .button class already supplies a usable size, padding, and layout. These rules give the retry button a suitable visible treatment.


Build a defensive loadVideos function

Replace the contents of src/main.js with the following:

import "./style.css";

const videoEndpoint = "/api/videos.json";

const videoGrid = document.querySelector("#video-grid");
const libraryStatus = document.querySelector("#library-status");
const retryButton = document.querySelector("#retry-button");

const statusLabels = {
  ready: "Ready",
  processing: "Processing",
};

function setStatus(message) {
  libraryStatus.textContent = message;
}

function validateVideos(payload) {
  if (!Array.isArray(payload)) {
    throw new TypeError("Expected the videos endpoint to return an array.");
  }

  return payload.map((video, index) => {
    const hasExpectedFields =
      video !== null &&
      typeof video === "object" &&
      typeof video.id === "string" &&
      typeof video.title === "string" &&
      typeof video.description === "string" &&
      typeof video.uploadedAt === "string" &&
      typeof video.uploadedLabel === "string" &&
      Object.hasOwn(statusLabels, video.status);

    if (!hasExpectedFields) {
      throw new TypeError(`Video at index ${index} has an invalid shape.`);
    }

    return video;
  });
}

function createVideoCard(video) {
  const listItem = document.createElement("li");
  const article = document.createElement("article");
  const metadata = document.createElement("div");
  const badge = document.createElement("span");
  const time = document.createElement("time");
  const title = document.createElement("h3");
  const description = document.createElement("p");
  const detailsLink = document.createElement("a");

  article.className = "video-card";
  metadata.className = "video-card__meta";
  badge.className = `status status--${video.status}`;
  badge.textContent = statusLabels[video.status];

  time.dateTime = video.uploadedAt;
  time.textContent = video.uploadedLabel;

  title.textContent = video.title;
  description.textContent = video.description;

  detailsLink.href = `/videos/${encodeURIComponent(video.id)}/`;
  detailsLink.textContent = "Open video details";

  metadata.append(badge, time);
  article.append(metadata, title, description, detailsLink);
  listItem.append(article);

  return listItem;
}

function renderVideos(videos) {
  if (videos.length === 0) {
    setStatus("No videos have been uploaded yet.");
    return;
  }

  const cards = videos.map(createVideoCard);

  videoGrid.replaceChildren(...cards);
  setStatus(`${videos.length} videos loaded.`);
}

async function loadVideos() {
  setStatus("Loading videos…");
  retryButton.hidden = true;
  videoGrid.replaceChildren();

  try {
    const response = await fetch(videoEndpoint);

    if (!response.ok) {
      throw new Error(`Could not load videos: HTTP ${response.status}`);
    }

    const contentType = response.headers.get("content-type");

    if (!contentType?.includes("application/json")) {
      throw new TypeError("Expected a JSON response from the videos endpoint.");
    }

    const payload = await response.json();
    const videos = validateVideos(payload);

    renderVideos(videos);
  } catch (error) {
    console.error("Video library load failed:", error);

    setStatus(
      "We could not load your videos. Check your connection and try again."
    );

    retryButton.hidden = false;
  }
}

retryButton.addEventListener("click", loadVideos);

loadVideos();

Read the control flow in order:

  1. loadVideos() updates the UI to loading, hides the retry button, and clears any displayed cards.
  2. await fetch(videoEndpoint) starts a default GET request and waits for a Response.
  3. The response.ok condition catches HTTP-level failures such as a 404 or 500.
  4. The content-type condition checks the promised body format before parsing.
  5. await response.json() reads and parses the body.
  6. validateVideos() checks that the parsed value is an array of records matching the UI’s expected fields.
  7. renderVideos() creates and inserts cards only after those checks pass.
  8. Any failure in the try block enters catch, where the console receives diagnostic detail and the user receives a clear, non-technical message plus a retry action.
An annotated JavaScript example showing the key request pattern: await `fetch`, check `response.ok`, throw an error for an unsuccessful HTTP response, parse JSON only after validation, and handle failures in `catch`.

Notice that try...catch surrounds both await operations. That is intentional. Any of these can reach catch:

  • The browser cannot complete the request because of a network-level failure.
  • The server responds with an unsuccessful HTTP status and your code throws.
  • The server responds with HTML or another unexpected content type.
  • JSON parsing fails because the response body is malformed.
  • The JSON parses successfully but does not match the basic data shape your UI requires.

The console.error() call preserves the technical reason for development and debugging. The visible message stays useful and safe for users. A user generally cannot act on “unexpected token at position 14,” but they can retry or check their connection.

Also note the deliberate use of textContent while rendering API values:

title.textContent = video.title;

Avoid interpolating server-provided titles or descriptions directly into innerHTML. textContent treats the received value as text rather than executable markup, which is the safer default when rendering external data.


Inspect success and deliberately test failure paths

Start the application:

npm run dev

Open the Vite URL in the browser. You should see the three cards loaded from public/api/videos.json.

Then open browser developer tools and use the Network panel:

  1. Reload the page.
  2. Find the request to videos.json.
  3. Inspect its status, response headers, and JSON preview or response body.

This connects the JavaScript objects to the actual HTTP exchange: you can see that the page did not simply “import a file”; the browser issued a request and received a response.

Test the error handling without permanently changing the finished implementation:

  • HTTP failure: Temporarily change videoEndpoint to /api/missing-videos.json, refresh, and confirm that the page displays the failure message and retry button. Restore the valid endpoint afterward.
  • Unexpected content: Request a known HTML path temporarily. Depending on how the development server responds, it may return HTML instead of JSON; the content-type check should stop the application before JSON parsing.
  • Network failure: In browser developer tools, use the Network panel’s offline mode, reload the page, and confirm that the catch path is used. Restore online mode afterward.
  • Malformed JSON: Temporarily introduce invalid JSON in videos.json, such as a trailing comma after the last object. response.json() should fail, and the same catch path should handle it.

These are distinct failures, but the UI has one predictable recovery path: communicate the problem, avoid rendering unreliable data, and allow a retry.

For now, the JSON endpoint is served from the same Vite origin as the frontend, so the browser permits the request without extra configuration. When your frontend later communicates with a separately running Django or FastAPI API, cross-origin rules will become relevant; you will configure them deliberately rather than trying to bypass browser security.


Wrap-up

You can now load application data safely with the core browser request pattern:

try {
  const response = await fetch(url);

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

  const data = await response.json();
} catch (error) {
  // Report and recover appropriately.
}

The key ideas are:

  • fetch() starts an asynchronous HTTP request and returns a Promise.
  • await fetch(...) produces a Response; it does not yet produce JSON data.
  • response.json() is asynchronous too, so it needs its own await.
  • A 404 or 500 does not automatically reject fetch; validate with response.ok.
  • Validate the expected response format and, when useful, the basic shape of application data.
  • Put the request, validation, and parsing inside try...catch so network, HTTP, parsing, and validation failures all receive a controlled user-facing response.
  • Render API data with DOM APIs and textContent, not untrusted innerHTML.

Next, you will turn this completed frontend change into a focused Git commit, with a clear history that makes the feature easy to review and safely build on.

Can't find a good explanation? Sign up and we'll make it for you

Sign up