Modules, Async Work, and Browser Events
Introduction
Real pages do not run in a straight line. 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 the three kinds of failure every request can have.
The practice scenario is original: load a mocked list of Harbor Workshops events and show useful feedback when loading fails. Running examples use a mocked fetch so that nothing depends on a real server.
Learning objectives
- Split code into ES modules using named and default exports and imports.
- Select elements, listen to events, read form values, and update the DOM safely.
- Explain the event loop well enough to predict the order of synchronous code, promise callbacks, and timers.
- Write and consume promises with
then/catchandasync/await. - Use
fetch, checkresponse.ok, and parse JSON. - Distinguish network errors, HTTP errors, and parsing errors and report each helpfully.
- Combine promises with
Promise.all,allSettled, andrace.
1. ES modules
A module is a file with its own scope. Names are private unless exported.
// 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`;
}
// 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 paths start with ./ or ../; bare names ("react") come from node_modules |
| Extensions | Browsers require .js in paths; bundlers like Vite often let you omit it |
| In the browser | <script type="module" src="main.js"></script>; modules are deferred and in strict mode |
| Live bindings | Imports are read-only views of the exporter's variables |
In the Vite-based React project you will use, every file is a module, and you will write import React style statements constantly. 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.
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.
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:
const form = document.querySelector("#booking");
form.addEventListener("submit", (event) => {
event.preventDefault(); // stop the full-page reload
const data = new FormData(form);
const seats = Number(data.get("seats"));
if (!Number.isInteger(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) andcurrentTarget(the element holding the listener). - Events bubble: a click on a button inside a card also triggers listeners on the card and its ancestors. 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.
list.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-id]");
if (btn) console.log("Reserve", btn.dataset.id);
});
inputfires on every change in a field;changefires when the value is committed (blur or selection);submitis on the form.- A form field's
.valueis always a string, so convert it, as you learned in lesson 1.
React will replace most manual DOM code with declarative components and its own event props (onClick, onSubmit), but the underlying concepts (events, bubbling, preventDefault, string values) are identical. Learners who skip this section struggle later with forms.
3. The event loop in one page
JavaScript runs one piece of code at a time on a single thread. 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:
- Run the current synchronous code to completion.
- Run all pending microtasks (promise callbacks,
awaitcontinuations). - Run the next macrotask (a timer, a click handler).
- Repeat.
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.
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.
async function loadEvents() {
const response = await fetch("/api/events");
const events = await response.json();
return events;
}
- An
asyncfunction always returns a promise, even if youreturn 1. awaitpauses only that function until the promise settles; other code keeps running.awaitis allowed insideasyncfunctions 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 |
Never |
Promise.race([...]) |
The first settles | The first settles with 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 for timeouts.
Run independent requests in parallel rather than awaiting each in turn:
const [events, hosts] = await Promise.all([getEvents(), getHosts()]); // parallel
Awaiting them one after the other would take the sum of both times instead of the longer of the two.
5. fetch and JSON
fetch(url, options) starts an HTTP request and returns a promise for a Response.
const response = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId: 1, seats: 2 }),
});
Crucial behavior: fetch rejects only when the request could not be completed (offline, DNS failure, blocked by CORS). 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:
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; undefined and functions are dropped by stringify; 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. Three kinds of failure
| 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." |
A robust loader treats all three:
async function load(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return { data: await response.json(), error: null };
} catch (error) {
return { data: [], error: `${error.name}: ${error.message}` };
}
}
Returning { data, error } instead of throwing forces the caller to handle both outcomes, 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. The page has <ul id="events"> and <p id="status" role="status">. 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:
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:
async function init() {
const status = document.querySelector("#status");
status.textContent = "Loading events...";
const { data, error } = await load("/api/events");
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);
}
init();
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. With a dropped connection the error is TypeError: Failed to fetch. With an HTML error page, response.json() throws SyntaxError, caught by the same catch.
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 exam tips
- Pitfall: assuming
fetchrejects on 404/500. Tip: the correct check isresponse.ok. - Pitfall: forgetting
awaitbeforeresponse.json(), then using a promise as data. - Pitfall: using
awaitoutside anasyncfunction in a non-module script (syntax error). - Pitfall:
forEach(async ...)does not wait; usefor...ofwithawaitorPromise.all(map(...)). - Pitfall: sequential
awaits for independent requests. - Pitfall: forgetting
event.preventDefault()on form submit, which reloads the page. - Pitfall: reading
input.valueas a number. - Pitfall: missing
returninside athencallback. - 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 "checkresponse.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:
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.
fetchfulfills because the server answered, with status 503 and an HTML body.response.json()tries to parse HTML and rejects withSyntaxError.- There is no
try/catch, soinit()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:
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
preventDefaulton submit. - I insert text with
textContent, notinnerHTML. - I can predict the order of synchronous code, promises, and timers.
- I write
async/awaitwithtry/catch. - I check
response.okbecausefetchdoes not reject on HTTP errors. - I can tell network, HTTP, and parsing failures apart and message each.
- I run independent requests with
Promise.alland know when to useallSettled. - I know what
JSON.stringifydrops.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 2: Programming with JavaScript (opens in a new tab)
- MDN: JavaScript modules (opens in a new tab)
- MDN: Using the Fetch API (opens in a new tab)
- MDN: Using promises (opens in a new tab)
- MDN: Introduction to events (opens in a new tab)
- MDN: JSON (opens in a new tab)
