You have learned about variables, functions, the DOM, and events. You can build a small interactive page from scratch. That is the foundation, and it is plenty to be proud of. But the moment you try to build anything real — a weather widget, a chat client, a dashboard that updates on its own — you will bump into a new set of ideas. This article is about those ideas: async code, fetching data from servers, splitting your work across files, and a few patterns you will see everywhere.

The good news is that none of it is fundamentally different from what you already know. It is the same JavaScript, just with a few new rules about timing, network, and code organisation. Once you internalise those rules, the rest is just typing.

Sync versus async: what is the difference?

By default, JavaScript runs your code one line at a time, top to bottom. If a line takes a long time, the next line waits. That sounds reasonable, but consider: what if a line takes ten seconds because it is downloading a big image from across the internet? The whole page freezes. You cannot click anything. The browser looks broken.

We avoid that with async code. Async just means "this might take a while; do not freeze the page while you wait." The browser starts the slow operation, moves on to other work, and comes back to your code when the operation finishes. Your code continues from where it left off, and the user never notices the gap.

Here is a concrete example. Imagine you click a button that loads ten photos. The naive way would freeze the page until all ten photos arrive. The async way shows a loading spinner, lets the user keep clicking other things, and quietly slides the photos in as they arrive. That is the difference we are chasing.

Promises: values that have not arrived yet

The foundation of async JavaScript is the Promise. A Promise is a value that does not exist yet but will, eventually. Think of it like an IOU note from the network: "I will have your data in a moment, here is a placeholder you can hold onto."

A Promise has three states. It is pending while the operation is still happening. It becomes fulfilled if the operation succeeded, and you can now read the value. It becomes rejected if the operation failed, and you can read the error instead. Once a Promise is fulfilled or rejected, it stays that way forever — you cannot un-resolve it.

The classic way to use a Promise is with .then chains:

fetch("https://api.example.com/data")
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => console.error(err));

Read this line by line. First, fetch starts a network request and returns a Promise. When it resolves, the first .then runs: take the response and parse it as JSON. That returns another Promise. When that one resolves, the second .then runs and logs the data. If anything throws along the way, the .catch at the end handles it.

async / await: cleaner syntax

The chain of .then works, but it gets messy when you have multiple steps. Modern JavaScript gives us a much nicer syntax: async and await:

async function loadData() {
  try {
    const res = await fetch("https://api.example.com/data");
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

loadData();

An async function is just a normal function that returns a Promise. The await keyword pauses execution inside the function until the Promise settles, then resumes with the resolved value. It looks like synchronous code, but it is not — the browser is still doing other things while you are waiting.

Most modern JavaScript you will read uses async/await rather than .then chains. They are easier to write, easier to read, and easier to debug. Reach for them by default, and only fall back to .then when you are working with an API that only exposes Promise objects. The MDN async function reference has the formal specification if you want to dive deeper.

fetch in practice

fetch is the built-in way to make HTTP requests from the browser. It takes a URL and returns a Promise that resolves to a Response object. Here is the pattern you will write a hundred times:

async function loadArticles() {
  const res = await fetch("/api/articles");
  if (!res.ok) throw new Error("HTTP " + res.status);
  const articles = await res.json();
  return articles;
}

Three things to notice. First, fetch only throws on network errors — a 404 or 500 response still resolves "successfully," so you have to check res.ok yourself. Second, res.json() parses the body as JSON and returns another Promise, hence the second await. Third, throwing inside the function turns into a rejected Promise, which the caller can catch.

You can also send data with fetch by passing a second argument — the options object. For a POST request that sends JSON, you would write something like:

const res = await fetch("/api/articles", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "New article" }),
});

The body has to be a string, which is why we wrap our object with JSON.stringify. The server will get the JSON text and parse it on its end. If you are sending a lot of requests, you will often wrap this in a helper function so you do not repeat the headers and stringify logic every time.

Error handling: try, catch, and finally

Anything in a try block that throws will be caught by the matching catch block. Use it liberally around anything async, because the network is famously unreliable. Wi-Fi drops, servers hiccup, DNS misbehaves. Async code has to be ready for all of it.

try {
  const data = await loadArticles();
  render(data);
} catch (err) {
  showError("Could not load articles: " + err.message);
}

There is also an optional finally block that runs whether the operation succeeded or failed. It is the right place to hide a loading spinner, no matter what happened.

try {
  await loadArticles();
} catch (err) {
  showError(err);
} finally {
  hideSpinner();
}

That is nicer than remembering to call hideSpinner() in two places — once after success, once in the catch block. finally is one of those small features that quietly saves you from a dozen small bugs.

Modules: splitting your code across files

Once a single file grows past a few hundred lines, you will want to split it. JavaScript's built-in way to do that is modules. A module is just a regular file that uses the export keyword to make some of its values available to other files, and the import keyword to grab values from other files.

Here is a tiny math module in a file called math.js:

// math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export const PI = 3.14159;

And here is how you would use it from another file, say app.js:

// app.js
import { add, sub, PI } from "./math.js";

console.log(add(2, 3));   // 5
console.log(PI);          // 3.14159

To make this work in the browser, load the entry file with a special script tag:

<script type="module" src="app.js"></script>

The type="module" part is what turns the file into a module. Modules have a few nice properties you get for free: they are automatically deferred (so they do not run until the page has loaded), they are scoped to their own file (no more accidentally declaring count in two places), and you can import only the specific functions you need. Modules also enable tree-shaking — a build tool feature where unused exports are stripped from the final bundle.

You can also rename imports if there is a name clash. import { add as sum } from "./math.js" brings in add as sum in your file. Useful when two libraries want to use the same name for different things. We cover modules in greater depth in our JavaScript Modules article.

Real-world patterns

You will see these patterns over and over in production code. They are not magic — they are just small pieces of logic that solve common problems.

  • Debounce — wait until a user stops typing before doing expensive work. Search boxes are the classic example: you do not want to send an API request on every single keystroke.
  • Throttle — limit how often something fires, even if the user keeps triggering it. Scroll handlers are the classic example.
  • AbortController — cancel an in-flight fetch when the user navigates away or the component unmounts. Prevents weird race conditions.
  • Optional chaininguser?.address?.city returns undefined instead of throwing if any part of the chain is missing.
  • Nullish coalescingname ?? "Anonymous" returns "Anonymous" only if name is null or undefined, not if it is an empty string or zero.

You will not need all of these on day one. When you see one in real code, look it up, read a quick example, and move on.

An example: a search box that debounces

Here is the search box pattern in full. It is a great example because it pulls together async, events, and a small bit of utility code:

function debounce(fn, ms) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

const input = document.querySelector("#search");
input.addEventListener("input", debounce(async (e) => {
  const q = e.target.value;
  const res = await fetch("/api/search?q=" + encodeURIComponent(q));
  const results = await res.json();
  console.log(results);
}, 250));

Read it slowly. The debounce function wraps any other function so it only runs after the user has stopped calling it for ms milliseconds. We pass 250 here, so the search waits a quarter of a second after the last keystroke before firing. That means if a user types "mango" quickly, only one network request goes out, not five.

The encodeURIComponent call is important too — it escapes special characters in the search term so they do not break the URL. Always encode user input before putting it in a URL. Forgetting this is a classic source of weird bugs that only show up when someone types a query with a space or an ampersand in it.

Common mistakes

These are the traps that catch even experienced developers when they are tired.

  • Forgetting await. The variable will be a Promise instead of the value. You will see weird [object Promise] in your logs.
  • Not checking res.ok before calling res.json(). A 404 returns HTML, and parsing HTML as JSON throws. Always check first.
  • Catching and swallowing errors silently. An empty catch {} block is a bug waiting to happen. Always log or surface the error somehow.
  • Loading huge libraries for one feature. Use native APIs first. You almost never need jQuery, Lodash, or Axios for a small project.
  • Race conditions. If you fire two requests and the second comes back first, your UI shows the older answer. Track the latest request and ignore older responses.

G

Further reading

Once you leave the basics, the documentation that matters is the one the platform vendors and browser teams keep current. These are the MDN reference pages the Mangobaz team uses whenever a fetch or Promise question comes up in code review.

  • MDN Promise reference — the authoritative reference for the Promise API, including chaining, composition, and error handling patterns.
  • MDN Using Fetch — the canonical guide to the Fetch API, covering requests, responses, streaming, and error handling.
  • MDN JavaScript modules guide — the official guide to ES modules, including import/export syntax, module resolution, and tree shaking.
oing one step further: classes (a quick tour)

You can write a lot of JavaScript without ever using class, but once you start working on larger apps you will see it everywhere. A class is a blueprint for creating objects that share behaviour.

class Dog {
  constructor(name) {
    this.name = name;
  }

  bark() {
    return this.name + " says woof!";
  }
}

const rex = new Dog("Rex");
console.log(rex.bark());   // Rex says woof!

The constructor runs when you create an instance. Methods defined inside the class become part of every instance. this refers to the instance. Classes can also extend other classes to inherit behaviour, which is how frameworks like Vue model components.

Under the hood, classes are mostly a nicer syntax for something JavaScript has always been able to do (prototypes). You do not need to understand the prototype chain to use classes — just know that they exist, and that you will meet them whenever you read larger codebases.

FAQ

The questions I get most when teaching this material.

When should I use Promise.then versus async/await?

Almost always async/await, in modern code. It reads like synchronous code, which is easier to reason about. Use .then chains when you are working with an older API or when you specifically want parallel composition with Promise.all.

What is a race condition, and how do I avoid it?

A race condition happens when you fire off multiple async operations and the order they return is not the order you fired them. Imagine typing in a search box quickly: "m", "ma", "man". If the "m" request returns last, your UI shows wrong results. Track the latest request id and ignore older responses.

Should I use a state management library?

For a small page, no — just use plain variables. For a large single-page app, a library like Pinia (Vue) or Redux (React) is worth the overhead. Reach for it when you find yourself passing the same data through five levels of components.

What is the difference between ES modules and CommonJS?

ES modules are the standard, what we use in the browser, with import/export. CommonJS is the older Node.js format using require/module.exports. Use ES modules for new code. Our modules article covers this in depth.

How do I cancel a fetch?

Create an AbortController, pass its signal to fetch, and call controller.abort() when you want to cancel. This is essential for in-flight requests when the user navigates away.

Homework

Build a small weather widget. It should fetch the current temperature for a city from a public API. Open-Meteo is free and needs no API key, which makes it perfect for this exercise — you can hit it directly with fetch from any browser.

Your widget needs to do the following:

  • Show a text input where the user types a city name.
  • When the user presses a button (or hits Enter), fetch the weather for that city using Open-Meteo's geocoding endpoint to turn the name into coordinates, then the forecast endpoint to get the temperature.
  • Display the city name and the temperature in degrees Celsius on the page.
  • Show a loading spinner while the request is in flight, and hide it when it finishes (try using a finally block).
  • Handle the error state gracefully: if the network fails or the city does not exist, show a friendly message instead of a blank screen.
  • Debounce the input so you do not fire a request on every keystroke.
  • Style it nicely with the CSS techniques you have already learned.
  • Split your code into at least two ES modules: one for the network calls, one for the UI logic.

Once that works, you have built a real, network-connected, asynchronously-loading, debounced, error-handling, modular widget — which is exactly the kind of thing you will be writing professionally within months. Take it slow, get each step working before moving to the next, and do not worry if you have to read the Open-Meteo docs three times. Everyone does.