Skip to content

JavaScript Foundations: Values, Functions, Async, and Tests

Step 7 of 45

JavaScript Modules, Async/Await, Fetch, and DOM Events

Introduction

JavaScript modules, async/await, and DOM events organize an interface that reacts to people and network results. A visitor clicks, types, submits a form; data arrives from a server a moment later; a request fails. This lesson covers the parts of JavaScript that deal with that unpredictability, within the JavaScript domain of the Meta Front-End Developer pathway (course 2): ES modules to organize code, the DOM and events that connect code to the page (the model React later hides behind components), promises and async/await for work that finishes later, fetch and JSON for talking to servers, and error handling for common request and response failures.

The practice scenario is original: load a mocked list of Harbor Workshops events and show useful feedback when loading fails. Individual snippets illustrate browser APIs. The complete worked example below injects a mock fetch, so that exercise needs no real API server.

Learning objectives

  1. Split code into ES modules using named and default exports and imports.
  2. Select elements, listen to events, read form values, and update the DOM safely.
  3. Explain the event loop well enough to predict the order of synchronous code, promise callbacks, and timers.
  4. Write and consume promises with then/catch and async/await.
  5. Use fetch, check response.ok, and parse JSON.
  6. Distinguish network errors, HTTP errors, and parsing errors and report each helpfully.
  7. Combine promises with Promise.all, allSettled, and race.

1. ES modules

A module is a file with its own scope. Names are private unless exported.

JavaScript
// money.js
export const TAX_RATE = 0.2;                  // named export
export function withTax(cents) {
  return Math.round(cents * (1 + TAX_RATE));
}
export default function formatEuros(cents) {  // default export (one per file)
  return `${(cents / 100).toFixed(2)} EUR`;
}
JavaScript
// main.js
import formatEuros, { withTax, TAX_RATE as rate } from "./money.js";
import * as money from "./money.js";           // namespace import

console.log(formatEuros(withTax(1000)));       // 12.00 EUR

Rules to remember:

Topic Rule
Named exports Imported with braces and the exact name (or renamed with as)
Default export One per file; imported without braces under any name
Paths Relative specifiers start with ./ or ../; tools can resolve bare names such as "react" from packages, while browsers need an import map for bare specifiers
Extensions Use complete browser URLs such as ./money.js; extensionless URLs can work if the server serves JavaScript with the correct MIME type. Bundlers can offer extension resolution
In the browser <script type="module" src="main.js"></script>; without async, module scripts defer execution until parsing is complete. Modules use strict mode
Live bindings Imports are read-only views of the exporter's variables

In the Vite-based React project, JavaScript/JSX source uses modules. Modern JSX tooling does not require import React merely to write JSX; import APIs you actually use, such as import { useState } from "react". Prefer named exports for utilities (clearer, easier to refactor) and use default exports where a framework convention expects them, such as one component per file.

For related practice, study semantic HTML and accessible page structure.

2. The DOM and events

The Document Object Model is the browser's live tree of objects for your page. JavaScript reads and changes it. DOM snippets assume the named elements exist and run after parsing; querySelector can return null, so check optional elements before using them.

JavaScript
const list = document.querySelector("#events");        // first match or null
const buttons = document.querySelectorAll(".reserve"); // NodeList of all matches
const card = document.createElement("li");
card.textContent = "Pottery: 4 seats";
list.append(card);

Prefer textContent to insert plain text. innerHTML parses HTML, so inserting user-provided text through it opens the door to cross-site scripting (XSS): a name like <img src=x onerror=...> would run code. Use textContent, or build nodes with createElement.

Events

An event is something that happened: a click, a key press, a form submission. You attach a listener:

JavaScript
const form = document.querySelector("#booking");
form.addEventListener("submit", (event) => {
  event.preventDefault();                       // prevent native submission/navigation
  const data = new FormData(form);
  const rawSeats = data.get("seats"); // a string, File, or null
  const seats = typeof rawSeats === "string" && rawSeats.trim() !== ""
    ? Number(rawSeats) : NaN;
  if (!Number.isSafeInteger(seats) || seats < 1) {
    document.querySelector("#error").textContent = "Enter a whole number of seats.";
    return;
  }
  console.log("Booking", seats);
});

Key ideas:

  • The listener receives an event object with target (the element that triggered it) and currentTarget (the element holding the listener).
  • Many events bubble: a click on a button inside a card also reaches listeners on the card and its ancestors. Some events, such as focus and blur, do not bubble; their focusin/focusout counterparts do. You can stop that with event.stopPropagation(), but you usually should not.
  • Event delegation: attach one listener to a parent and inspect event.target, instead of one per child. This also covers items added later.
JavaScript
list.addEventListener("click", (e) => {
  if (!(e.target instanceof Element)) return;
  const btn = e.target.closest("button[data-id]");
  if (btn && list.contains(btn)) console.log("Reserve", btn.dataset.id);
});
  • input fires for user edits; assigning .value in JavaScript does not automatically dispatch it. change timing depends on the control, often on commitment or blur. submit is on the form; failed native constraint validation can prevent submission before that event.
  • Text/number inputs expose a string .value; checkbox state is the boolean .checked, and files use .files. FormData.get() can return a string, a File, or null, so validate before converting.

React will replace most manual DOM code with declarative components and its own event props (onClick, onSubmit), and the core concepts still apply. React wraps event handling and some propagation rules differ; for example, React onFocus bubbles although the native focus event does not. Learners who skip this section struggle later with forms.

3. The event loop in one page

Within one browser event loop, JavaScript jobs run to completion on its thread. This model describes the page’s main thread; workers can execute code on other threads. Slow operations (timers, network, file access) are handed to the browser, which later queues a callback. The event loop takes finished work from queues when the call stack is empty:

  1. Run the current synchronous job to completion. This is a useful simplified trace for the examples below.
  2. Run all pending microtasks (promise callbacks, await continuations).
  3. The browser may have a rendering opportunity, then selects another task (often informally called a macrotask), such as a timer callback or user-input work.
  4. Repeat. Microtasks can enqueue more microtasks, so an endless microtask chain can also starve rendering.
JavaScript
console.log("A");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("B");
// A, B, microtask, timeout

Even a zero-delay timer runs after the promise callback because microtasks are drained first. A long synchronous loop blocks everything, including clicks and rendering, which is why heavy work must not sit in a handler.

4. Promises

A promise is an object representing a value that is not available yet. It is in one of three states: pending, fulfilled (with a value), or rejected (with a reason). Once settled it never changes.

JavaScript
fetch("/api/events")
  .then((response) => response.json())  // returns another promise
  .then((events) => console.log(events.length))
  .catch((error) => console.error(error)) // any rejection above lands here
  .finally(() => console.log("done"));    // always runs

Each then returns a new promise, which allows chaining. A value returned from a then callback becomes the next input; a thrown error skips to the next catch. Forgetting to return a promise inside then breaks the chain, because the next step would not wait.

async / await

async/await is syntax for working with promises in a top-down style.

JavaScript
async function loadEvents() {
  const response = await fetch("/api/events");
  const events = await response.json();
  return events;
}
  • An async function always returns a promise, even if you return 1.
  • await pauses only that function until the promise settles; other code keeps running.
  • await is allowed inside async functions and at the top level of modules.
  • Errors become exceptions, so you handle them with try/catch.

A classic mistake: array.map(async (n) => n * 2) returns an array of promises, not numbers. Wrap it in await Promise.all(...).

Combining promises

Helper Resolves when Rejects when
Promise.all([...]) All fulfill, with an array of results Any rejects (first rejection)
Promise.allSettled([...]) All settle, with {status, value/reason} items Not merely because a member rejects; invalid input or failed iteration can still reject
Promise.race([...]) First fulfillment wins First settlement is a rejection
Promise.any([...]) The first fulfills All reject

Use all when every result is required, allSettled when partial results are acceptable (for example a dashboard with several independent widgets), and race when only the first settlement matters. Racing a timeout does not cancel the losing request. Use an AbortController passed through fetch’s signal when cancellation is required.

For empty inputs, all and allSettled fulfill with [], any rejects with AggregateError, and race remains pending. For ordinary arrays, results from all and allSettled preserve input order, not completion order.

Start independent requests concurrently rather than awaiting each in turn:

JavaScript
const [events, hosts] = await Promise.all([getEvents(), getHosts()]); // parallel

Calling getEvents() and getHosts() starts the work; Promise.all joins their results rather than starting it itself. With both requests able to progress concurrently, elapsed time is often closer to the slower request than their sum, subject to overhead and network limits. A rejection does not automatically cancel other work.

For related practice, study HTTP requests, status codes, and browser tools.

5. fetch and JSON

fetch(url, options) starts an HTTP request and returns a promise for a Response.

JavaScript
const response = await fetch("/api/bookings", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ eventId: 1, seats: 2 }),
});

Crucial behavior: network, CORS, invalid-request configuration, or cancellation failures can reject fetch. A response with status 404 or 500 is a fulfilled promise. You must check response.ok (true for statuses 200 to 299) yourself.

JSON (JavaScript Object Notation) is the standard text format for data exchange:

JavaScript
JSON.stringify({ a: 1, d: new Date(0), u: undefined, f() {}, n: null });
// '{"a":1,"d":"1970-01-01T00:00:00.000Z","n":null}'
JSON.parse('{"a":[1,2]}').a; // [1, 2]

Notes: keys and strings need double quotes. In object properties, undefined and functions are omitted; in arrays they become null, and a top-level undefined or function produces no JSON string. BigInt values and circular references throw by default. Dates become strings and are not restored by parse; JSON.parse throws a SyntaxError on invalid text. response.json() does the parse for you and rejects the same way if the body is not valid JSON, for example when a server returns an HTML error page.

6. Common request, response, and data failures

Kind What it looks like How you detect it Useful message
Network fetch rejects with TypeError: Failed to fetch catch "Cannot reach the server. Check your connection and try again."
HTTP Promise fulfills, response.ok is false, status 4xx/5xx if (!response.ok) "The server could not load events (error 500). Try again later."
Parsing response.json() rejects with SyntaxError catch "The server sent an unexpected response."

Cancellation can produce AbortError; it is often handled differently from a failed network. Opaque no-cors responses hide their body and status, so no-cors is not a way to make a cross-origin JSON API readable.

A loader must also validate the parsed data’s shape; valid JSON is not necessarily a list of events:

JavaScript
async function load(url, fetchFn = fetch) {
  try {
    const response = await fetchFn(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    const data = await response.json();
    if (!Array.isArray(data) || !data.every(e => e !== null &&
        typeof e === "object" && typeof e.title === "string" &&
        Number.isSafeInteger(e.seats) && e.seats >= 0)) {
      throw new TypeError("Unexpected event data");
    }
    return { data, error: null };
  } catch (error) {
    const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
    return { data: [], error: detail };
  }
}

Returning { data, error } makes both outcomes explicit; the caller still has to check error, and it maps neatly onto the loading, error, and success states of a React component. Never show raw error text to visitors in production; log details for developers and display a friendly message.

Worked example: show events or an error

Task. Provide this markup and put the following functions in events.js, served by a local development server:

HTML
<ul id="events"></ul>
<p id="status" role="status"></p>
<script type="module" src="./events.js"></script>

Load the event list from /api/events, render each title with its seats, and show a useful message when loading fails. Seats of 0 must read "Sold out".

Step 1: write a loader that returns { data, error } as above.

Step 2: render with safe DOM calls:

JavaScript
function render(events) {
  const list = document.querySelector("#events");
  list.replaceChildren(); // clear
  for (const e of events) {
    const li = document.createElement("li");
    li.textContent = `${e.title}: ${e.seats === 0 ? "Sold out" : `${e.seats} seats`}`;
    list.append(li);
  }
}

Step 3: orchestrate, showing a loading message first:

JavaScript
async function init(fetchFn = fetch) {
  const status = document.querySelector("#status");
  status.textContent = "Loading events...";
  const { data, error } = await load("/api/events", fetchFn);
  if (error) {
    status.textContent = "We could not load the events. Please try again.";
    console.error(error);
    return;
  }
  status.textContent = data.length === 0 ? "No events yet." : "";
  render(data);
}
const fakeFetch = async () => new Response(JSON.stringify([
  { title: "Pottery", seats: 4 },
  { title: "Weaving", seats: 0 },
]), { status: 200, headers: { "Content-Type": "application/json" } });
init(fakeFetch);

To explore failure paths, inject a function returning a Response with status 500, a status-200 HTML body, or a function that throws TypeError("Simulated network failure"). Each mock is local and makes no request.

Trace with the mocked server. With a good response the status clears and the list shows Pottery: 4 seats and Weaving: Sold out. With HTTP 500 the loader returns Error: HTTP 500 and the visitor sees the friendly message. The rejected-network mock returns its chosen TypeError message; a real browser’s exact network-error text can differ. A status-200 HTML body makes JSON parsing reject with SyntaxError; malformed event objects trigger the explicit shape check. An HTTP error is detected before parsing.

Why other designs are worse. Checking only catch would miss the 500 case, because fetch fulfills. Using innerHTML with e.title would risk script injection if titles ever come from users. Awaiting without try/catch would leave an unhandled rejection and a page stuck on "Loading events...". Note also that the check for an empty list is separate from the error check: "no events" is a successful result, not a failure.

7. Pitfalls and study tips

  • Pitfall: assuming fetch rejects on 404/500. Tip: the correct check is response.ok.
  • Pitfall: forgetting await before response.json(), then using a promise as data.
  • Pitfall: using await outside an async function in a non-module script (syntax error).
  • Pitfall: forEach(async ...) does not wait; use for...of with await or Promise.all(map(...)).
  • Pitfall: sequential awaits for independent requests.
  • Pitfall: forgetting event.preventDefault() on form submit, which reloads the page.
  • Pitfall: reading input.value as a number.
  • Pitfall: missing return inside a then callback.
  • Tip: predicting output order: synchronous code first, then promise callbacks, then timers.
  • Tip: if an option says "wrap the call in try/catch" and another says "check response.ok", real code often needs both: one for failed requests, one for failed responses.

Mini case study: the stuck spinner

Situation. The Harbor events page shows "Loading..." and never changes when the API returns a 503 error page. The code:

JavaScript
async function init() {
  const status = document.querySelector("#status");
  status.textContent = "Loading...";
  const response = await fetch("/api/events");
  const events = await response.json();
  status.textContent = `${events.length} events`;
}
init();

Questions. What exactly happens, in what order, and how do you fix it?

Commented solution.

  1. fetch fulfills because the server answered, with status 503 and an HTML body.
  2. response.json() tries to parse HTML and rejects with SyntaxError.
  3. There is no try/catch, so init() returns a rejected promise nobody handles. The last line never runs and the text stays at "Loading...". The console shows "Uncaught (in promise) SyntaxError".

Fix: check response.ok first, handle exceptions, and always leave the loading state:

JavaScript
async function init() {
  const status = document.querySelector("#status");
  status.textContent = "Loading...";
  try {
    const response = await fetch("/api/events");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const events = await response.json();
    status.textContent = `${events.length} events`;
  } catch (error) {
    console.error(error);
    status.textContent = "Events are unavailable right now. Please try again later.";
  }
}
init();

Checking response.ok gives the more precise cause (503) in the log before the parse is even attempted.

Key terms

  • Module: a file with its own scope and explicit imports/exports.
  • DOM: the browser's object tree of the page.
  • Event bubbling: events travel up through ancestors.
  • Event delegation: one parent listener handles many children.
  • Event loop: scheduler that runs queued callbacks when the stack is empty.
  • Microtask: a queued promise continuation, run before timers.
  • Promise: an object for a future value (pending, fulfilled, rejected).
  • JSON: text format for structured data.
  • CORS: browser rule limiting cross-origin requests.

Summary checklist

  • I can write named and default exports and import them.
  • I select elements, attach listeners, and call preventDefault on submit.
  • I insert text with textContent, not innerHTML.
  • I can predict the order of synchronous code, promises, and timers.
  • I write async/await with try/catch.
  • I check response.ok because fetch does not reject on HTTP errors.
  • I can tell network, HTTP, and parsing failures apart and message each.
  • I run independent requests with Promise.all and know when to use allSettled.
  • I know what JSON serialization omits, replaces with null, and cannot encode by default.

Official sources