Chapter 22 — Fetch, APIs and Async JavaScript
Section 1 — What an API is
An API, or Application Programming Interface, is a set of rules for asking another program for something. The word sounds technical, but the idea is everyday: a menu in a restaurant is an interface between you and the kitchen. You do not walk into the kitchen, inspect the ingredients, and cook the meal yourself; you choose from the menu, place an order in the agreed form, and the kitchen returns the result. An API works the same way for software. A weather API lets your page ask, in an agreed form, "What is the weather in Lagos today?" and receive an answer back in a structured form it can display. Your page does not need to know how the weather service measures temperature, stores its data, or runs its servers. It only needs to know how to ask, what the question should look like, and what shape the answer will take.
That separation is what makes modern web development possible. A small team can build a page that shows maps, payments, videos, or social posts without building a mapping service, a payment system, a video platform, or a social network. They use the APIs those services provide, following their documentation, and they concentrate on their own page's design and behaviour. APIs also define boundaries of responsibility. If the weather service changes how it stores data internally, your page does not break, as long as the API's questions and answers stay the same. If the API itself changes, its versioning and documentation tell you what to update. That stability at the boundary, with freedom to change behind it, is the core promise of an interface.
There are different kinds of APIs, and it helps to distinguish them early. A web API is reached over the internet with HTTP requests, and it is the kind this chapter covers. A browser API is built into the browser itself, such as the DOM API you used in earlier chapters, or the local storage API in Chapter 23, and it is reached with JavaScript calls rather than network requests. A library API is the set of functions a code library offers. All three share the same idea: a defined way to ask for something without needing to know how it works inside. When you hear a developer say "the API returns JSON", they usually mean a web API, and when they say "check the API docs", they mean the documentation that defines the questions you can ask, the parameters each question accepts, and the shape of each answer. Reading that documentation is a skill in itself, covered in Section 42, and it is worth practising early, because every real project you build will involve reading someone else's API documentation and translating it into working requests.
Check: In plain words, what does an API let your page do?
Section 2 — Data lives somewhere else
Most interesting pages show data they did not write themselves. A news page shows today's headlines, a shop shows its products and prices, a music page shows tracks and artists, and a social page shows posts from other people. None of that data is sensibly written by hand into the page's HTML, because it changes constantly: headlines update, prices change, new tracks appear, and new posts arrive every minute. Instead, the data lives on a server, in a database, and your page fetches a copy of it when it needs it, then displays that copy. The page itself is a template and a set of instructions for displaying data; the data is a separate thing that flows into it. That separation is why a news page can show today's headlines without anyone editing the page every morning, and why a shop can change a price in one place and have it appear everywhere at once.
This arrangement has practical consequences for how you build pages. First, a page's first load is often only the beginning of its work. The HTML arrives, the JavaScript runs, and then the JavaScript fetches data and fills the page in. A visitor may briefly see a loading message or an empty list before the data arrives, which is why loading states, covered in Section 17, matter so much. Second, the same data can feed many pages: a product list, a product detail page, and a search result can all fetch from the same API and show the same product in different ways, staying consistent because they share a source. Third, the data can be personal: a logged-in visitor's messages, orders, or settings are fetched for them specifically, and a different visitor fetching the same page receives different data. The page's code is the same for everyone; the data it fetches is what makes it personal.
There is also a boundary to respect. Data that lives on a server belongs to someone, and the API defines what you are allowed to ask for and how often. Some APIs require a key, some limit the number of requests per hour, and some forbid storing or republishing their data beyond certain terms. Those limits are part of the API's design, not obstacles to work around, and a well-built page respects them, caches where allowed, and handles a "too many requests" reply gracefully. Understanding that data lives elsewhere, is fetched on demand, and is governed by the provider's rules is the mental model for this whole chapter. Every technique that follows, from promises to error handling to caching, exists to manage that flow of data from somewhere else into your page, reliably and honestly.
A further consequence is that a page's data can be updated without redeploying the page itself. Changing a price in a database, publishing a new post, or correcting a headline updates the data source, and every page that fetches from it shows the new value on its next fetch. That separation of content from presentation is what makes large sites maintainable: editors change data, developers change code, and neither needs to touch the other's work to do their job well.
Check: Why does a page need to fetch data instead of writing it all into the HTML?
Section 3 — What JSON is
JSON, short for JavaScript Object Notation, is the most common format for data sent over the web, and it earns that position by being simple, readable, and supported by almost every programming language. It looks like JavaScript's way of writing objects and lists, but it is not JavaScript code; it is plain text with strict rules. An object is written in curly braces, with keys in double quotes, a colon after each key, and commas between pairs. A list is written in square brackets, with commas between items. Values can be strings in double quotes, numbers, true or false, null for "no value", objects, or lists, and objects and lists can nest inside each other to describe complex data, such as a user with a list of posts, each post with its own title and body.
{ "name": "Ada", "age": 30, "skills": ["HTML", "CSS"] }
The example above shows the shape in miniature. The object has three keys: name, whose value is the string "Ada"; age, whose value is the number 30; and skills, whose value is a list of two strings. Reading JSON is largely a matter of following the brackets: start at the outer braces, read each key, and follow its value, opening nested braces or brackets as you go. Writing JSON requires care with its strictness. Keys must be in double quotes, not single quotes. Trailing commas after the last item are not allowed. Comments are not allowed. A single misplaced comma or a single-quoted key makes the whole document invalid, and a parser will reject it rather than guess what you meant. That strictness is a feature, not a flaw: it means any program that reads JSON can rely on its shape, and a document that parses is a document whose structure is unambiguous.
JSON's plain-text nature is also what makes it portable. Because it is text, it can travel over HTTP, be stored in a file, be saved in local storage, or be printed in a console, and any language with a JSON parser can turn it back into its own objects and lists. That universality is why APIs choose it: the server might be written in Python, Go, or Java, and your page in JavaScript, yet both agree on JSON as the shared language for data. When you fetch from an API, you are usually receiving JSON text, and your first job is to parse it into JavaScript values you can work with, which is the subject of the next section. Learning to read JSON fluently, brackets, keys, nesting, and all, is therefore a foundational skill, and it repays practice: open any API reply in a browser or console, and practise describing its structure in words before you write code against it.
Check: What do curly braces and square brackets mean in JSON?
Section 4 — JSON is text until you parse it
When data arrives from a server, it arrives as text. That statement is easy to read and easy to forget in practice, because the text looks like an object and it is tempting to treat it as one immediately. But a string that reads {"name":"Ada"} is not an object with a name property; it is a sequence of characters, and asking it for .name will give you undefined, not "Ada". The function JSON.parse(text) performs the conversion from text to values: it reads the JSON string, checks its structure, and returns a real JavaScript object or list that you can work with, accessing properties, looping over items, and passing values to your rendering code. The reverse function, JSON.stringify(object), turns a JavaScript object back into JSON text, which is what you need when sending data to a server, since a request body must be text, not a live object.
const object = JSON.parse('{"name": "Ada", "age": 30}');
const text = JSON.stringify(object);
Mixing these two up is a common beginner bug, and it appears in several disguises. One is calling JSON.parse on something that is already an object, which throws an error because parse expects a string. Another is sending an object directly as a fetch body without stringifying it, which results in the text "[object Object]" being sent, a useless string that the server cannot use. A third is forgetting to parse a response at all, and then wondering why a property access returns undefined. The remedy in each case is to be explicit about which form your data is in at each step: is this a string, or is this an object? Name your variables to reflect the answer, such as responseText for text and data for parsed values, and the confusion largely disappears. A related habit is to log the type of a value when you are puzzled, using typeof, which will tell you plainly whether you are holding a string or an object.
Parsing can also fail, and that failure deserves handling. If the text is not valid JSON, because the server returned an error page in HTML instead of JSON, or because the data was truncated, JSON.parse throws an error. In an async function, that error can be caught with try and catch, as in Section 13, and turned into a friendly message rather than a crash. The fetch method response.json(), covered in Section 15, combines reading the body and parsing it into one step, and it too can fail for the same reasons. Understanding that JSON travels as text and becomes values only when parsed gives you a mental checkpoint at every boundary in your code: data crossing into your program is text until parsed, and data leaving your program must be stringified into text. Holding that distinction clearly will prevent a whole family of bugs that otherwise look mysterious.
Check: What does JSON.parse() do, and what does JSON.stringify() do?
Section 5 — HTTP methods in plain words
When your page talks to a server, it does more than send a URL; it also sends a verb, called the HTTP method, that says what it wants to do with the thing at that URL. The five methods you will meet most often form a small, memorable set. GET asks for data, without changing anything. POST sends new data to create something. PUT replaces an item entirely with a new version. PATCH changes part of an item, leaving the rest untouched. DELETE removes an item. Together they cover the basic operations on data, often summarised as CRUD: create, read, update, and delete. GET is the read, POST is the create, PUT and PATCH are the update, and DELETE is the delete. Most pages you build will start with GET and POST, and those two alone will carry you a long way, but knowing the full set helps you read API documentation, where each endpoint is defined by its method as well as its path.
The distinction between GET and POST matters beyond their definitions, because it affects safety and behaviour. GET requests are considered safe in the HTTP sense: they should not change anything on the server, so they can be repeated, cached, bookmarked, and prefetched without harm. A search, a product listing, or a user profile are all GETs, because asking for them twice changes nothing. POST requests, by contrast, are understood to change something, so they are not cached or bookmarked in the same way, and repeating one might create a duplicate. Browsers warn before resubmitting a POST, for exactly that reason. That is why the form chapter's advice to use GET for searches and POST for actions that change something is not merely a convention; it reflects how the whole web treats these methods, from caches to browsers to servers.
PUT and PATCH deserve a careful distinction, because they are easy to confuse. PUT sends a complete replacement: to update a user's name with PUT, you send the whole user object, with the new name and every other field as it should be afterwards, and the server replaces the old object with the new one. PATCH sends only the changes: to update the same name, you send just the name field, and the server merges it into the existing object, leaving everything else alone. PATCH is often more convenient, because you do not need to know the whole object to change one field, but it requires the server to support merging, and not every API offers it. DELETE, finally, usually takes only an id in the URL and no body, and its success reply may be empty or may confirm the deletion. Reading an API's method list tells you what you are allowed to do with each kind of data, and choosing the method that matches your intention keeps your code honest about whether it is reading or changing the world.
Check: Which HTTP method asks for data, and which one creates something new?
Section 6 — What a URL with a path means
An API address is usually built from a base, such as https://api.example.com, plus a path that describes the resource you want. The path often looks like a route to a collection and then to an item within it: /users for all users, /users/3 for user number 3, /users/3/posts for the posts belonging to user 3. Reading these paths is a skill, because they tell you, at a glance, what an API offers and how its data is organised. The collection part names a kind of thing, in plural, and the number or identifier that follows names one specific thing of that kind. The nesting then continues: posts belonging to a user live under that user, comments belonging to a post live under that post, and so the path mirrors the relationships in the data itself. Learning to read a path as a sentence, "the posts of user 3", makes API documentation far less intimidating.
Paths also carry parameters in a more general sense. Some APIs use a word rather than a number as an identifier, such as a username in /users/ada, and some use a long, random-looking identifier. The principle is the same: the segment identifies one item. Other segments are actions or sub-collections, such as /posts/1/comments, and the order matters: /users/3/posts and /posts/3/users would mean different things, if the second even existed. When you build a request, you usually build its path from values you already have, such as a user id from a previous reply or from a page's address, inserting that value into the path template. That is why the previous section's sequential requests matter: you cannot ask for user 3's posts until you know which user you mean, and the path is where that knowledge is expressed.
There is a design philosophy behind this style, often called REST, which treats web addresses as names for things and HTTP methods as verbs for acting on them. In a REST-style API, the same path with different methods does different things: GET /posts/1 reads post 1, PUT /posts/1 replaces it, DELETE /posts/1 removes it. The path stays constant while the verb changes, which keeps the address space small and predictable. Not every API follows REST strictly, and some use paths that name actions, such as /posts/1/publish, but the habit of reading paths as resource names will still help you navigate them. When you encounter an unfamiliar API, start by listing its paths and asking, for each one, what kind of thing it names and whether it names a collection or a single item. That question alone will unlock most of the documentation, and it will guide you to the right path for the data you need, before you worry about parameters, headers, or bodies.
Paths also appear in a browser's address bar for ordinary pages, not only for APIs, and reading them the same way helps you understand a site's structure. A blog's path for a post, a shop's path for a product, and an API's path for a resource all follow the same idea: the path names the thing, and the segments narrow it down from a collection to a single item.
Check: In /users/3, what does the 3 identify?
Section 7 — Query strings
A query string adds options to a URL after a question mark, as a series of key-and-value pairs joined by ampersands. In /posts?userId=1&limit=5, the question mark marks the start of the query, userId=1 says "only posts belonging to user 1", and limit=5 says "at most five of them". The query string does not name a different resource; it refines the request for the resource named by the path. It is how you ask for a filtered set, a smaller page of results, a sorted order, or a search term, without needing a separate path for every possible combination. A single /posts endpoint can serve all users' posts, one user's posts, the first ten posts, or posts matching a search, simply by varying the query string, which keeps the API's address space manageable.
Query parameters have conventions worth learning. Filtering uses a field name and a value, such as userId=1 or status=published. Pagination often uses a page number and a page size, such as page=2&limit=20, or an offset, and the reply may include information about the total number of results so your page can show "page 2 of 5". Sorting may use a field and a direction, such as sort=title&order=asc. Searching may use a single term parameter, such as q=cats. These names vary between APIs, and the documentation for each endpoint lists the parameters it accepts, which ones are required, and what values they take. Reading that list before you guess is important, because a misspelled parameter is usually ignored silently, returning unfiltered results that look correct at first glance but are not what you asked for.
Building query strings by hand, by joining strings with question marks and ampersands, is error-prone, especially when values contain spaces or special characters, as Section 31 explains. A safer approach in modern JavaScript is to use URLSearchParams, which builds the string for you and encodes values correctly. You create it from an object of parameters, or append to it one by one, and then attach its string form to the URL. That approach also makes your code readable: the parameters are named values in code, not fragments of a string, and changing one is a change to a value, not surgery on a URL. Whether you build the string by hand for a simple case or use URLSearchParams for a complex one, the principle is the same: the path names the resource, the query string refines the request, and the two together, path plus query, are the complete question you are asking the server.
A query string can also carry a value that is itself a list, by repeating a parameter, such as ?tag=html&tag=css, which many APIs interpret as two values for the same key. That convention varies between APIs, so check the documentation before assuming it, and prefer URLSearchParams with append for repeated keys, which handles the repetition correctly. Repeating a key by hand, with string concatenation, risks encoding errors that a dedicated builder avoids.
Check: What does ?userId=1 do in a URL?
Section 8 — Status codes
Every server reply carries a status code, a three-digit number that summarises the result of the request. The code is the server's headline verdict, and learning to read it will save you hours of debugging, because it tells you, before you look at the body, whether the request succeeded, whether your request was at fault, or whether the server failed. You do not need to memorise every code; you need to recognise the families, which are grouped by their first digit. Codes in the 200s mean success: 200 means the request succeeded, and 201 means something was created, the usual reply to a successful POST. Codes in the 300s mean redirection: the resource has moved, and the client should look elsewhere, though browsers usually follow these automatically. Codes in the 400s mean the client, that is, your request, was at fault: 400 means the request was malformed, 401 means authentication is required, 403 means you are authenticated but not allowed, and 404 means the thing asked for was not found. Codes in the 500s mean the server failed: 500 is a general server error, and 503 means the service is temporarily unavailable.
The distinction between 400s and 500s is the most useful one for debugging, because it tells you where to look. A 400-series code means your request needs fixing: the path, the parameters, the body, or the authentication. Changing the server will not help, because the server understood the request well enough to judge it wrong. A 500-series code means the server itself had a problem, and your request may have been perfectly correct; retrying later, or reporting the failure to the service's operators, is the right response, not rewriting your request. A 404 deserves special mention, because it is the code beginners meet most often, and it usually means a wrong path, a wrong id, or a resource that has been deleted, rather than a broken network. Checking the URL, character by character, against the documentation is the first step when you see a 404, and it resolves most of them.
Status codes also shape how your code should respond, which is why Section 14's response.ok check matters. The property response.ok is true for 200s and false for everything else, giving you a single test for success. But the specific code still matters for the message you show. A 404 deserves "That item could not be found", a 401 deserves "Please sign in", and a 500 deserves "The service is having trouble; please try again later". Those messages are different because the visitor's next step is different in each case: check the link, sign in, or wait and retry. A page that shows the same generic error for every failure leaves the visitor guessing, while a page that translates the status code into a specific, actionable message respects the visitor's time. The status code is the server speaking plainly about what happened; your code's job is to listen and to translate.
Check: What does a 404 status code mean?
Section 9 — Your first fetch
The fetch() function is the browser's built-in way to make an HTTP request from JavaScript. You call it with a URL, and it returns a promise, a value that represents a result not yet available, which you will learn about in the next section. The simplest fetch asks for a URL and waits for the reply, without reloading the page, which is what makes modern pages feel alive: new data can arrive and be displayed while the visitor stays exactly where they are. The example below uses a public practice API, which provides fake posts for learning, and it fetches a single post, reads its body as JSON, and logs the result. It is a complete, runnable example, and it is worth typing it into a console or a small page and watching it work, because seeing a real reply arrive from a real server makes the abstractions of this chapter concrete.
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then((response) => response.json())
.then((data) => console.log(data));
There are several things to notice in that short example. First, fetch does not return the data; it returns a promise for a response, and the first .then() receives that response object. The response object is not the data itself; it is a container holding the status, the headers, and a body that has not yet been read. Calling response.json() reads the body and parses it, returning another promise, which the second .then() receives as the parsed data. That two-step structure, response first, then body, is why fetch code always has at least two stages, and understanding it prevents the common mistake of trying to use the response object as if it were the data. Second, the example uses .then() chains, the older style of handling promises, which the next sections explain before introducing the newer async and await style. Both styles are current, and you will meet both in real code, so learning to read both matters even if you prefer one for writing.
Third, and most importantly for later sections, this example has no error handling and no loading state. If the network fails, the promise rejects and nothing catches it, producing an unhandled rejection in the console. If the server returns a 404, the first .then() still runs, because fetch only rejects on network failure, and response.json() may fail on an error page, or return an error object that your code mistakes for data. The rest of this chapter adds, piece by piece, the handling that this first example lacks: checking response.ok, catching errors, showing loading and error states, and using async and await for readability. Start with this simple fetch, see it work, and then add each safeguard in turn, so you understand what each one protects against.
Running that first fetch in the console is worth doing even before you build a page around it, because seeing a real reply arrive, with its fields and values, makes the abstract ideas of requests and responses concrete. A console experiment costs nothing, requires no page design, and gives you a reply you can inspect, expand, and learn from, which is a faster way to build intuition than reading about fetch without ever calling it.
Check: What does fetch() return, and what does it not do to the page?
Section 10 — What a promise is
A promise is JavaScript's way of representing a value that is not ready yet. When you call fetch, the network request takes time: the request must travel to the server, the server must process it, and the reply must travel back. JavaScript does not wait in place for that journey, because waiting would freeze the page, stopping scrolling, typing, and animation until the reply arrived. Instead, fetch returns a promise immediately, a placeholder object that says, in effect, "I do not have the value yet, but I will have it later, or I will have a reason why I could not get it". Your code attaches functions to that promise, to run when the value arrives or when the failure is known, and then carries on with other work. When the network reply finally arrives, JavaScript calls the attached function with the value, and your code continues from there. That is the whole idea, and it is what keeps pages responsive during slow operations.
A promise has three states, and only three. It starts pending, meaning the work is still in progress and neither value nor failure is known yet. It then settles, exactly once, into one of two final states: fulfilled, with a value, or rejected, with a reason, usually an error object. Once settled, a promise never changes state again; a fulfilled promise stays fulfilled with the same value, and a rejected promise stays rejected with the same reason. That permanence matters, because it means you can attach a handler to a promise after it has settled, and the handler will still run with the settled value, rather than missing it because it arrived early. It also means you can attach several handlers to the same promise, and each will run, which is useful when different parts of a page care about the same data.
Promises are not only for fetch. They appear wherever JavaScript must wait for something outside its immediate control: reading a file, waiting for a timer, asking the visitor for permission, or any operation you define yourself that returns a promise. You can also create a promise with the Promise constructor, wrapping a task that calls resolve with its value on success or reject with its reason on failure, though for fetch you will rarely need to, since fetch already returns a promise. The key skill is to think in terms of "when this settles, then do that", rather than "do this, then do that", because the second phrasing assumes the value is already there. Internalising that shift, from immediate values to eventual values, is the conceptual heart of asynchronous JavaScript, and the next sections give you two syntaxes for expressing it: the .then() chain and async with await.
Check: What are the three states of a promise?
Section 11 — Then and catch
The methods .then() and .catch() are how you attach code to a promise. Calling .then() on a promise registers a function to run when the promise fulfils, and that function receives the fulfilled value as its argument. Calling .catch() registers a function to run when the promise rejects, receiving the reason, usually an error. The power of .then() lies in chaining: .then() itself returns a new promise, whose value is whatever your function returns. If your function returns a plain value, the next .then() in the chain receives that value. If your function returns another promise, the next .then() waits for that promise and receives its value. That is how the fetch example works: the first .then() receives the response and returns response.json(), which is a promise, so the second .then() waits for the parsing and receives the parsed data. Each step transforms the result and passes it on, and the chain describes a sequence of asynchronous steps.
Error handling in a chain follows a similar logic. If any promise in the chain rejects, the code skips forward to the next .catch() in the chain, bypassing the intervening .then() functions, just as an exception skips to a catch block in synchronous code. A single .catch() at the end of a chain therefore catches failures from any step: a network failure in the fetch, a parsing failure in response.json(), or an error thrown by your own code in a .then() handler. That is why the advice to always end a chain with a .catch() matters so much. Without it, a failure produces an unhandled promise rejection, which appears in the console but is invisible to the visitor, who sees a page that simply never updates, with no explanation. With it, the failure becomes a message you control, and the page can show an error state, as Section 18 describes.
fetch(url)
.then((response) => response.json())
.then((data) => console.log(data.title))
.catch((error) => console.error(error));
There are refinements worth knowing. You can attach a .catch() in the middle of a chain to recover from a failure and continue, because a .catch() handler that returns a value turns the chain back into a fulfilled state, and the following .then() will run with that value. You can also pass a second function to .then() as a rejection handler, though a trailing .catch() is clearer in most cases. And you can use .finally(), which runs whether the promise fulfils or rejects, for cleanup such as hiding a loading indicator, since loading should end in both cases. Chaining is a complete, readable style for asynchronous sequences, and you will meet it in older code and in libraries. The async and await style in the next section expresses the same sequences differently, but the underlying promises, and the need to catch their rejections, remain exactly the same.
Chaining also allows you to transform data step by step, with each then handling one transformation, such as parsing, filtering, or picking a field, before passing the result onward. That stepwise style keeps each function small and focused, and it makes the chain read as a pipeline, where data enters at one end and emerges, transformed, at the other.
Check: What does .catch() at the end of a chain do?
Section 12 — Async and await
The keywords async and await provide a second way to write asynchronous code, one that reads like ordinary, top-to-bottom code. You mark a function with async, which means the function will return a promise, and inside it you can use await before a promise, which pauses that function until the promise settles, then continues with the fulfilled value, or, if the promise rejects, throws an error that can be caught. While the function is paused at an await, the rest of the page keeps working: scrolling, typing, and other scripts are unaffected, because only that one function is paused, not the whole page. The code below does the same work as the .then() chain in Section 9, but its shape is the shape of synchronous code: fetch, then read, then use, in that order, with no nesting and no chain.
async function loadPost() {
const response = await fetch(url);
const data = await response.json();
console.log(data);
}
There are rules governing where await can appear. It can only be used inside an async function, or at the top level of a module, which is a newer feature not available in every script context. A function that is not marked async cannot use await, and attempting to do so is a syntax error. An async function always returns a promise, even if its body returns a plain value: that value becomes the promise's fulfilled value, and any error thrown inside becomes a rejection. That means calling an async function does not give you its result directly; it gives you a promise for its result, which you must await in another async function or handle with .then(). Beginners often trip on this point, expecting const data = loadPost() to hold the data, when it holds a promise for the data. The mental model to hold is that async spreads: once a function needs to wait for a promise, it must itself become async, and its callers must handle its promise in turn, up to the top level where the chain is started.
Async and await are not a different mechanism from promises; they are a syntax built on top of promises, and the two styles can be mixed freely. A function can await a promise created by a .then() chain, and a .then() can be attached to a promise returned by an async function. Choosing between them is largely a matter of readability. For a straight sequence of steps, await reads more clearly, because the steps appear in order and error handling uses familiar try and catch. For a set of independent promises combined with Promise.all, or for a short chain in a single expression, .then() can be more concise. Learning both, and recognising that they are the same promises underneath, leaves you able to read any asynchronous code you meet, and to write in whichever style fits the task at hand.
Async functions also make sequential code easier to follow, because the steps appear in the order they happen, without indentation growing with each step. A chain of five then calls nests or stretches across lines, while five awaited steps read as five plain lines, in order, which is easier to scan and to maintain.
Check: What does await do inside an async function?
Section 13 — Try and catch with await
When you use await, error handling takes the familiar form of try and catch, the same construct used for synchronous errors. Code inside the try block runs normally, step by step. If any awaited promise inside it rejects, or if any code inside it throws an error, execution jumps immediately to the catch block, skipping the remaining code in the try, and the catch block receives the error as its argument. That jump is the async equivalent of a .catch() at the end of a chain, and it keeps error handling close to the code that can fail, rather than at the end of a long chain. The example below shows the shape: fetch and parse inside try, and a single catch that handles any failure from either step, logging it and, in a real page, showing an error state to the visitor.
try {
const data = await getData();
} catch (error) {
console.error("Could not load data.", error);
}
A try block can also contain a finally clause, which runs whether the try succeeded or the catch ran, and is the right place for cleanup that must happen in both cases. Hiding a loading indicator is the classic example: the indicator should disappear when the data arrives and also when the request fails, because in both cases loading has ended. Without finally, you would need to hide the indicator in both the success path and the catch path, duplicating the line and risking a case where one path forgets it. With finally, the cleanup is written once and guaranteed to run. That guarantee is valuable in asynchronous code, where the success and failure paths diverge and it is easy for a cleanup step to be missed on one of them.
There are two cautions worth noting. First, try and catch only catch errors in code that runs inside the try block's asynchronous flow. An error thrown in a callback attached elsewhere, such as an event handler, will not be caught by a try block that has already finished, because the try block's scope has ended by the time the callback runs. Second, a catch block that swallows an error silently, logging nothing and showing nothing, is worse than no catch at all, because it hides the failure from both the developer and the visitor. A good catch block does three things: it logs the error for debugging, it shows a human message to the visitor, and it leaves the page in a usable state, with a way to retry. Try, catch, and finally give async code the same structured error handling that synchronous code has always had, and using them consistently turns promise rejections from silent console warnings into handled, visible outcomes.
A catch block should also distinguish between errors the visitor can act on and errors they cannot, because the message and the next step differ. A network error invites a retry, while a malformed request invites a correction, and a server error invites waiting. Lumping all errors into one message hides those distinctions, while handling them by type, where the error object allows it, gives the visitor a next step that actually fits the problem they face.
Check: If a promise inside a try block rejects, where does the code go next?
Section 14 — Checking response.ok
One of the most surprising facts about fetch is that its promise only rejects on a network failure. If the visitor has no internet connection, if the server's address does not exist, or if the request is blocked, fetch rejects, and your catch block runs. But if the server answers with a 404, a 500, or any other error status, fetch fulfils, because from fetch's point of view the request succeeded: it reached the server and a reply came back. The reply happens to say that something went wrong, but fetch does not judge the reply's status; it only reports that a reply arrived. A page that relies solely on catch to detect failures will therefore treat a 404 as a success, try to read the body as data, and either crash on an error page or display an error object as if it were a post. That is a bug that looks baffling until you understand fetch's definition of failure.
The remedy is to check response.ok after every fetch, before reading the body. The property response.ok is true when the status is in the 200s, and false for any other status, giving you a single, clear test for success. If it is false, you should throw an error yourself, including the status code in the message, so that your catch block can handle it like any other failure. Throwing inside an async function turns the function's returned promise into a rejection, which try and catch will handle, and throwing inside a .then() handler sends the chain to its .catch(). Either way, the error flows into the handling you already built, and the page shows its error state instead of pretending the request succeeded.
const response = await fetch(url);
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
This check should become a reflex, applied to every fetch, in every project, without exception. It is a single if statement, and it closes the gap between fetch's narrow definition of failure and the broader definition your page needs. A useful refinement is to include the status code in the thrown error, and, where the server provides a helpful error message in the body, to read it and include that too, though reading the body for an error reply needs care, since the body may not be JSON. Another refinement is to write a small helper function that fetches, checks response.ok, and returns the parsed data, so every call site gets the check for free instead of repeating it. Whichever form you choose, the principle is fixed: a fulfilled fetch promise means a reply arrived, not that the reply was good, and your code must ask the second question itself.
The response.ok check should also be the first thing you do after receiving a response, before any parsing or rendering, because every later step assumes the reply is good data. Parsing an error body as data, or rendering an error object as a post, are both consequences of skipping that check, and they produce confusing displays that are harder to debug than the original error. Checking first, and only then proceeding, keeps the rest of the code honest about what it is handling.
Check: Why is checking response.ok necessary even though fetch did not reject?
Section 15 — Reading the body as JSON
Most APIs reply with JSON, and response.json() is the method that reads the reply's body and parses it into a JavaScript value. It returns a promise, because reading the body takes time, so you must await it in an async function or handle it with .then(). The result is the parsed data: an object, a list, or occasionally a single value, depending on what the API sent. That result is what your rendering code works with, and storing it in a variable is the right move, because a response body can only be read once. The body is a stream that is consumed as it is read, and a second call to .json() on the same response will fail, since there is nothing left to read. Store the parsed result once, and reuse the variable, rather than calling .json() again whenever you need the data.
The single-read rule has a practical consequence for error handling. If you check response.ok and find it false, you might want to read the body's error message, but if you then also try to read it as data, the second read will fail. Decide, based on the status, which reading you need: for a successful reply, read the body as data; for a failed reply, read it as an error message if the API provides one, or skip reading it and rely on the status code alone. Another consequence concerns the shape of the data. An API may return a list directly, or it may wrap the list in an object with extra fields, such as a total count or pagination information. Your code must match the actual shape, which you learn from the documentation or by logging a real reply and inspecting it. Assuming a list when the API returns an object with a list inside it, or vice versa, is a common bug, and the fix is always the same: log the parsed data, look at its shape, and write your rendering code to that shape.
Parsing can fail, and that failure deserves a place in your error handling. If the server returns HTML, such as an error page, instead of JSON, response.json() will reject, because HTML is not valid JSON. That rejection will be caught by your try and catch, or by your chain's .catch(), and should be shown as a loading failure rather than a crash. In practice, a JSON parsing failure often indicates that something went wrong upstream, such as a wrong URL returning a "not found" HTML page, or a server error page replacing the expected JSON. Treating it as a general failure, with a message inviting a retry, is usually the right response, and logging the raw reply during development will reveal the true cause. Reading a body as JSON is a small step, but it sits at the boundary between the network's text and your program's values, and handling it carefully keeps that boundary clean.
Check: Why should you store the result of response.json() instead of calling it twice?
Section 16 — Reading the body as text
Not every reply is JSON, and response.text() is the method for replies that are plain text. It reads the body and returns a promise for a string, which you can then display, search, or process as text. A simple text reply, such as a greeting, a status message, or a short piece of content, suits .text() directly. An HTML fragment returned by a server for insertion into a page also arrives as text, though inserting it with innerHTML requires the caution of Section 20 about untrusted content. A reply in another text format, such as CSV data or a markdown document, is also read with .text() first, and then parsed by code suited to that format, since the response object itself only knows how to give you text or JSON, not every format that exists.
Choosing between .json() and .text() depends on what the server actually sent, and picking the wrong one gives you a clear, if confusing, error. Calling .json() on a plain text reply will reject, because the text is not valid JSON, even if it looks like ordinary words. Calling .text() on a JSON reply will succeed, but you will receive a string rather than an object, and accessing properties on it will give you undefined, the same confusion as Section 4's unparsed JSON. The remedy is to know, from the documentation or from the reply's Content-Type header, what format to expect, and to choose the matching method. The header application/json signals JSON, while text/plain or text/html signal text or HTML. In practice, most APIs you meet in this book's exercises return JSON, so .json() will be your default, but recognising when .text() is right keeps you from forcing every reply into a JSON shape it does not have.
There is also a debugging use for .text() that is worth knowing. When a fetch returns something unexpected and .json() fails, reading the body as text in a test script will show you what actually arrived: often an HTML error page, a plain-text error message, or a truncated reply that reveals the true problem. Because a body can only be read once, you cannot call both .text() and .json() on the same response in production code, but in a debugging session you can clone the response first, with response.clone(), and read the clone as text while reading the original as JSON. That technique is advanced, and you will rarely need it, but knowing it exists can save a debugging session where the reply's true nature is the mystery. The core lesson is simpler: match the reading method to the format, and when the format is unknown, read as text to discover it.
Text replies are also useful for simple endpoints that return a single value, such as a greeting, a version string, or a status message, where JSON would add unnecessary structure. A plain text reply is easy to display directly, easy to test in a browser, and easy to debug, because what you see is exactly what was sent. Choosing text for simple values, and JSON for structured data, matches the format to the content, rather than forcing every reply into the same shape.
Check: When would you use response.text() instead of response.json()?
Section 17 — Showing a loading state
A network request takes time, and during that time the visitor is waiting. A page that shows nothing in the meantime looks broken: the visitor cannot tell whether the page is working, whether their click registered, or whether they should wait or try again. A loading state solves that by communicating clearly that work is in progress. Before starting the fetch, show a message such as "Loading...", a spinner, or a skeleton outline of the content to come. When the data arrives, replace the loading state with the data. When the request fails, replace it with an error state, as Section 18 describes. That three-state pattern, loading, success, and error, is the backbone of every data-fetching interface, and it should be present from the first fetch you write, not added later as polish.
status.textContent = "Loading...";
const data = await fetchData();
status.textContent = "";
Implementing a loading state is straightforward, but there are details that make it good rather than merely present. First, the loading state should appear immediately, in the same place the data will appear, so the visitor's eyes do not have to move when the data arrives. A message at the top of the page while the list loads at the bottom is less helpful than a message inside the list's container. Second, the loading state should be cleared in both the success and the failure paths, which is why a finally block, from Section 13, is the natural place to hide a spinner: it runs in both cases, so the spinner cannot be left spinning after a failure. Third, for very fast requests, a loading indicator that flashes for a fraction of a second can be more distracting than helpful, and some pages delay showing it until a request has taken, say, a few hundred milliseconds, so fast replies skip the flash. That refinement is optional, but it shows the kind of judgement that separates a loading state that informs from one that merely flickers.
A loading state also communicates respect for the visitor's time and attention. It says, in effect, "We heard you, we are working, and we will show you the result when it arrives". Without it, the visitor is left to guess, and guessing visitors click again, producing duplicate requests, or leave, producing a lost visitor. With it, even a slow request feels managed, because the visitor knows the page is aware of the wait. That is why loading states belong in the first version of a data feature, alongside the fetch itself, rather than in a later round of improvements. A feature that fetches data without a loading state is unfinished, however correct its data handling is, because it fails to communicate with the person waiting for that data.
A loading state should also be accessible, so visitors using screen readers hear that loading is in progress, rather than encountering silence while data travels. A status message marked as a live region, or a simple text update that is announced, tells those visitors that the page is working, just as a spinner tells sighted visitors. Loading is a state for everyone, and communicating it only visually leaves some visitors unaware that anything is happening at all.
Check: What should a page show while a fetch is still in flight?
Section 18 — Showing an error state
Requests fail for reasons outside your control: the network drops, the server is down, the address is wrong, or the service is temporarily unavailable. When a request fails, the page must say so, plainly and helpfully, because a page that shows nothing after a failure leaves the visitor stranded. A good error state has three parts: a human message, an explanation in plain language, and a way forward. The message should say what happened in the visitor's terms, such as "Could not load the posts", rather than in technical terms, such as "Fetch failed with status 500". The explanation, if there is a useful one, should say why, in one short sentence, such as "The service is having trouble right now". And the way forward should be an action the visitor can take, most often a retry button that runs the fetch again, or a suggestion to check their connection and try later. A dead end, with no action, is a failure of design, not just of the network.
Error states should also preserve what the visitor already had. If a page already shows a list of posts and a refresh fails, it is usually better to keep the old list visible and show a small error notice, than to wipe the list and replace it with a full-page error. The old data may be slightly stale, but it is more useful than nothing, and the visitor can still read it while deciding whether to retry. Similarly, if a form submission fails, the visitor's input must be preserved, so they can retry without retyping everything. That preservation is a matter of not clearing state on failure: only clear a form or a list when the replacement data has actually arrived. A page that destroys good data because new data failed to arrive has turned a temporary failure into a permanent loss for the visitor.
There is a tone question too. Error messages should be calm and specific, not alarming and vague. "Something went wrong" is vague; "Could not load the posts. Please try again." is specific about what failed and what to do. Avoid blaming the visitor for a failure they did not cause, and avoid technical detail that does not help them decide what to do, while still logging the technical detail to the console for your own debugging. The status code, from Section 8, can guide the message: a 404 suggests "That item could not be found", a 401 suggests "Please sign in", and a 500 suggests "The service is having trouble; please try again later". Translating the technical failure into a human message with a next step is the core skill of error states, and it is what turns a failed request from a broken page into a managed, recoverable moment.
Check: What should an error message offer the visitor besides the bad news?
Section 19 — Rendering fetched data
Fetching data is only half the job; the other half is turning that data into something a visitor can see and use. Rendering is the name for that second half, and it deserves to be a separate function, with a single job: given data, produce HTML and put it in the page. The function takes a list, loops over it, builds a piece of HTML for each item, and joins the pieces into the container's content. Keeping rendering in its own function, separate from the fetching function, makes both easier to understand, test, and reuse. The fetching function worries about URLs, statuses, and errors. The rendering function worries about markup, classes, and layout. When a bug appears, the separation tells you where to look: if the data is wrong, the fetch is at fault; if the data is right but the display is wrong, the rendering is at fault.
function renderPosts(posts) {
list.innerHTML = posts.map((post) => `<li>${post.title}</li>`).join("");
}
The example uses a common pattern: map transforms each item into a string of HTML, and join("") combines those strings into one, which is assigned to the container's innerHTML. That pattern is concise and readable for trusted data, but it has a safety limit, covered in the next section: if the data contains HTML of its own, it will be interpreted as markup, which is dangerous for untrusted data. For untrusted data, building elements with document.createElement and setting .textContent is safer, though more verbose. Choosing between the two is a judgement about trust: data from your own API that you control is different from data typed by strangers, and the rendering method should match the trust level. A third option, used by many frameworks, is a templating system that escapes data automatically, but in plain JavaScript the choice between innerHTML and element creation is one you make explicitly, each time.
Rendering should also handle the cases where there is nothing to render. An empty list is not an error, but it is not a blank page either; it deserves a message such as "No posts yet", so the visitor knows the fetch succeeded and the list is genuinely empty, rather than wondering whether the page is still loading or has failed. That empty state is part of rendering, and it belongs in the rendering function, which can check the list's length and show the empty message when it is zero. Similarly, rendering should be idempotent: calling it twice with the same data should produce the same display, not duplicate the list, which is why assigning to innerHTML or clearing the container before appending are safer than appending without clearing. A rendering function that takes data in and produces a complete, correct display, for full lists, empty lists, and repeated calls, is a reliable building block, and it is the foundation on which the rest of this chapter's interface patterns rest.
Check: Why keep rendering in its own function instead of mixing it into the fetch?
Section 20 — Escaping user data before rendering
Building HTML from data with template strings is convenient, but it becomes dangerous when the data is untrusted, meaning it was typed by a stranger, fetched from a public source, or otherwise not under your control. The danger is that data containing HTML will be interpreted as HTML when it is inserted with innerHTML. A post title containing <img src="x" onerror="alert('hi')">, for example, would not display as text; the browser would create an image element and run the code in its error handler. A title containing a <script> tag, or a link with a malicious address, could do worse. This class of attack is called cross-site scripting, and it succeeds precisely because the page treats data as code. The defence is to treat data as text, unless you have a specific reason to treat it as markup, and to use a method that enforces that distinction.
The safe method in plain JavaScript is to create elements and set their .textContent. When you assign a string to .textContent, the browser treats it as plain text, displaying any angle brackets, quotes, or tags as characters on the screen, never as markup to be interpreted. A title containing <script> will be displayed, literally, as the characters <script>, which is harmless and also honest, because that is what the data actually contains. The verbose version of rendering, creating a list item, creating a heading, setting its text content, and appending it, is longer than a template string, but it is safe by construction, because at no point does untrusted text enter the HTML parser. For data you fully control, such as titles you wrote yourself in a practice API, a template string is acceptable, but the habit of asking "Do I trust this data?" before choosing a rendering method will protect you in real projects, where the answer is often no.
There are middle paths worth knowing. Some projects use a sanitising library that strips dangerous markup from HTML while allowing safe tags, such as bold or links, which suits a rich-text editor where some markup is intended. Others use framework templating that escapes data automatically, so a template expression is safe by default and only explicitly marked raw HTML is interpreted. In plain JavaScript, without those tools, the rule is simple: use .textContent for untrusted values, and reserve innerHTML for markup you wrote yourself or that has been sanitised. Also be careful with attributes: setting an attribute from untrusted data, such as an image source or a link address, can be dangerous even when the text content is safe, because a javascript: address in a link can run code when clicked. Validate and, where possible, restrict attribute values from untrusted sources, and never build a page's structure from data you have not examined. Treating data as text by default is a small discipline that prevents a serious class of security failures.
Check: Why is textContent safer than innerHTML for untrusted data?
Section 21 — Sending data with POST
To create something on a server, you send a POST request with a body containing the new data. The fetch call for a POST looks different from a GET in two ways: it includes an options object as its second argument, and that object specifies the method and the body. The method is the string "POST". The body is usually a JSON string, produced by JSON.stringify from an object holding the new item's fields. And the headers object inside the options must include Content-Type set to application/json, telling the server that the body is JSON and should be parsed as such. Without that header, the server may not know how to read the body, and may reject it or misinterpret it. Those three elements, method, header, and stringified body, are the complete recipe for a JSON POST, and they appear together in almost every create operation you will write.
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Hello", body: "My post" }),
});
The server's reply to a POST deserves as much attention as the request. A successful creation usually returns a 201 status, rather than 200, and a body containing the created item, including any fields the server assigned, most importantly its new id. That id is what lets your page refer to the item afterwards, for example to show it in a list, link to its detail page, or delete it later. Your code should therefore read the reply, check response.ok as always, parse the body, and use the returned item, rather than assuming the creation worked and guessing the id. If the server returns a 400, the body often contains a description of what was wrong with the submitted data, such as a missing field or a value that failed validation, and your code should surface that description as a form error, preserving the visitor's input so they can fix it. That server-side validation is the final net discussed in Chapter 21, and it can reject data that your front-end checks accepted, so handling its reply is not optional.
There are also practical details for POST requests in real pages. A submit button should be disabled while the POST is in flight, or guarded against double-clicking, because a second click could create a duplicate item, and a visitor who sees no immediate response will often click again. The loading and error states from Sections 17 and 18 apply here too: show that the submission is in progress, show success when it completes, and show a retryable error when it fails, without losing the visitor's input. And a POST body is not limited to JSON: forms can be sent as FormData, files as multipart bodies, and other formats as their own content types, each with its own header. JSON is the default for APIs in this book, and mastering its POST recipe, method, header, and stringified body, gives you the pattern that the other formats vary.
Check: What two things must a JSON POST request include besides the URL?
Section 22 — Headers in plain words
Headers are extra pieces of information sent alongside a request or a reply, like labels on a parcel that describe its contents, its sender, or its handling instructions, without being part of the contents themselves. A request has headers, and a reply has headers, and each header is a name-and-value pair. The most common request header you will set is Content-Type, which tells the server what format the request body is in, such as application/json for JSON or multipart/form-data for a form with files. Without it, the server must guess the body's format, and guessing is unreliable. Another common request header is Accept, which tells the server what format you would like the reply in, though many APIs ignore it and always return JSON. A third, important in real applications, is Authorization, which carries proof of who you are, such as a token, when an API requires authentication, a subject Section 40 returns to.
You set request headers in the fetch options object, under a headers key, as a plain object mapping names to values. Header names are case-insensitive, so Content-Type and content-type mean the same thing, though the capitalised form is conventional and easier to read. Reply headers are available on the response object, through response.headers, which offers a .get(name) method to read one header by name. Useful reply headers include Content-Type, telling you the reply's format, and any pagination headers an API uses, such as a total count. Reading reply headers is less common than setting request headers in beginner code, but it becomes important when an API communicates metadata, such as rate limits or page counts, through headers rather than through the body.
Headers also carry constraints and cautions. Some headers are forbidden to set from browser JavaScript, for security reasons, such as headers that would let a page impersonate the browser itself, and fetch will ignore attempts to set them. Custom headers, such as an Authorization token or an API-specific header, can also trigger a preflight request, a preliminary check the browser makes for cross-origin requests, which is part of the CORS story in Section 41. That does not mean you should avoid custom headers; it means you should expect an extra request in cross-origin cases and understand why it appears. The core idea to carry forward is simple: the body is the parcel's contents, and headers are its labels. They do not contain the data itself, but they tell the receiver how to interpret it, who sent it, and how it should be handled, and getting them right is part of getting a request right.
Reply headers can also tell you about rate limits, such as how many requests remain before you are throttled, and when that limit resets. Reading those headers, where an API provides them, lets a page warn a visitor or slow its requests before hitting the limit, rather than failing unexpectedly. Headers are therefore not only about format and identity; they are also a channel for metadata about the request itself, worth checking when an API documents them.
Check: What does the Content-Type header tell the server?
Section 23 — Sending data from a form
One of the most common patterns in front-end work ties the previous chapter to this one: a visitor fills in a form, your JavaScript checks it, and then your code sends it to a server as a POST request. The order of those steps matters, and getting it wrong is a common source of bugs. First, prevent the form's default submission, so the browser does not navigate away. Second, read the form's values, using the techniques of Chapter 21, and validate them completely, showing errors beside any failing field and stopping if there are any. Third, only when every field passes, build a JSON object from the values, stringify it, and POST it with fetch, including the Content-Type header. Fourth, handle the reply: check response.ok, read the returned data on success, and show a success message, or, on failure, show the server's error and preserve the visitor's input for correction. Validation before sending is not a courtesy; it is the gate that keeps bad data from reaching the server, and it is also a kindness to the visitor, who gets an immediate, specific correction instead of a round trip to the server and back for the same news.
This pattern also has a user-experience dimension that is easy to overlook. While the POST is in flight, the form should show that it is working, and the submit button should be protected against double-clicking, as Section 21 notes, because a duplicate submission could create a duplicate record. On success, the form should confirm what happened, in the visitor's terms, such as "Your message has been sent", and then reset, so the visitor does not wonder whether their submission is still pending. On failure, the form should not reset, and it should not lose the visitor's typing, because asking someone to retype a long message after a network failure is a sure way to lose them. Those details, loading state, double-submit protection, success confirmation, and input preservation on failure, are what turn a technically correct form-to-API flow into one that feels reliable.
There is also a question of what to send. The JSON object you build should contain the fields the API expects, with the names the API expects, not necessarily the names your form uses internally. Translating between the form's field names and the API's field names is a normal part of the sending step, and it is worth doing explicitly, building a new object with the API's keys, rather than sending the form's data wholesale. That translation layer protects you when either side changes: rename a form field, and you update one line in the translation; rename an API field, and you update the same place. It also lets you exclude fields the API does not need, such as a password confirmation field, which should never be sent, since the server only needs the password itself. Form validation, translation, sending, and reply handling, in that order, are the complete form-to-API flow, and practising it end to end is one of the most valuable exercises in this book.
Check: In what order do validation and sending happen in a form-to-API flow?
Section 24 — Updating and deleting
Creating and reading data are only part of the story. Real applications also update and delete, and HTTP provides methods for both. PUT replaces an item entirely: you send the complete new version of the item to its URL, such as /posts/1, and the server replaces the old item with it. PATCH changes part of an item: you send only the fields that changed, and the server merges them into the existing item, leaving the rest untouched. DELETE removes an item: you send a DELETE request to its URL, usually with no body, and the server removes it. The choice between PUT and PATCH depends on what the API offers and on what you know: if you have the whole item, PUT is straightforward; if you only know the changed fields, PATCH is more convenient and less risky, because you cannot accidentally overwrite a field you forgot to include.
After a successful update or delete, the page must be brought into agreement with the server. A page that still shows the old title after an update, or still lists a deleted item after a deletion, is lying to the visitor, and that lie will cause confusion when they act on stale information. There are two main ways to restore agreement. The first is to re-fetch the list or the item after the change, which is simple and guaranteed correct, because the page then shows exactly what the server has, at the cost of an extra request. The second is to update the local display directly, changing just the affected item in the page's data and re-rendering, which is faster and avoids a round trip, but requires your local update to match the server's exactly. For beginners, re-fetching is the safer choice, because it cannot drift out of sync. Optimistic updating, where the page is updated before the server confirms, is a faster-feeling variant, but it needs a rollback if the server rejects the change, and it is covered separately in Section 45.
Deleting deserves an extra moment of care, because it is irreversible in most systems. A delete button should not act on a single click without confirmation, especially for important items. A confirmation dialog, asking "Delete this post?" with clear confirm and cancel options, prevents accidental deletions from a mis-tap, and it is a small addition that protects visitors from a costly mistake. The confirmation should name the item, so the visitor can verify they are deleting the right one, and the delete request itself should only be sent after confirmation. Updating, too, benefits from confirmation of a different kind: showing the updated item after the change, so the visitor can verify the new values are as intended. In both cases, the pattern is the same: send the request with the right method, check the reply, and then make the page match the server, visibly, so the visitor can trust that the change took effect.
Check: What is the difference between PUT and PATCH?
Section 25 — Sequential requests
Sometimes one request depends on another, and the second cannot be made until the first has answered. The classic example is fetching a user's posts: you first fetch the user, perhaps by username, to learn their id, and only then can you fetch /users/{id}/posts, because the second URL needs the id the first reply provides. Another example is creating an item and then fetching its details, or fetching a list and then fetching details for a selected item. In these cases the requests must run in sequence, one after another, with the second waiting for the first. In async code, that sequence reads naturally: await the first fetch, use its result to build the second request, await the second fetch, and use its result. The code's order is the work's order, which makes sequential requests easy to read and to reason about.
Sequential requests are slower than parallel ones, because their times add up: if each request takes a second, two sequential requests take about two seconds. That cost is unavoidable when the second genuinely depends on the first, and it is the price of correctness. What you should avoid is making requests sequential when they do not need to be. Fetching a list of users and a list of posts that do not depend on each other, one after another, wastes time that parallel fetching, Section 26, would save. The skill is to distinguish dependence from mere ordering: does the second request need a value from the first reply, or does it merely come after it in your code? If it needs a value, sequence is correct. If it does not, parallel is better. Drawing the dependency, even mentally, before writing the code, helps you make that distinction deliberately rather than by habit.
There is also an error-handling consequence to sequence. If the first request fails, the second cannot be made, and the whole sequence should stop and show an error, rather than attempting the second request with a missing or guessed value. In async code, that happens naturally if both fetches are inside the same try block: a failure in the first jumps to the catch, skipping the second entirely. That is the correct behaviour, and it is another reason to keep dependent requests in one try block, rather than in separate handlers where a failure in the first might be overlooked and the second attempted anyway. Sequential requests are therefore not just a performance choice; they are a statement about dependence, and handling their failure as a whole, rather than piece by piece, is part of writing them correctly.
A sequential chain also needs a decision about partial success. If the first request succeeds but the second fails, the page has some data and not all of it, and it should show what it has, with an error for the missing part, rather than discarding the successful first result. Designing for partial success, by rendering each stage as it arrives and handling each failure separately, makes a sequential flow more resilient than an all-or-nothing approach that throws away good data because one later step failed.
Check: When must requests run one after another instead of at the same time?
Section 26 — Parallel requests with Promise.all
When requests do not depend on each other, running them one after another wastes time. Fetching a list of users and a list of posts, for example, takes about the sum of their times if done sequentially, but only about the slowest single time if done in parallel, because both requests travel at the same time. Promise.all() is the tool for parallel requests in JavaScript. You give it a list of promises, and it returns a new promise that fulfils with a list of their results, in the same order as the promises you gave it, regardless of which finished first. That order guarantee matters: the first result in the list corresponds to the first promise you passed, even if the second promise finished earlier, so you can destructure the results confidently, as in the example below, knowing which result is which.
const [users, posts] = await Promise.all([
fetch(usersUrl).then((r) => r.json()),
fetch(postsUrl).then((r) => r.json()),
]);
The example also shows a common pattern: each fetch is followed by a .then() that checks the response and parses the body, so each element of the list is a promise for parsed data, not merely a promise for a response. Promise.all then waits for all of those parsing promises, and gives you the parsed results together. That composition, a fetch plus a parsing step per item, inside a Promise.all, is a pattern you will reuse often, and it is worth learning as a unit rather than as separate pieces. Note also that the promises start as soon as they are created, which is as soon as the list is built, not when Promise.all is called on them. Creating the list starts the requests; Promise.all merely waits for them. That means you should not build the list long before you need the results, or the requests will start earlier than intended, though in most cases starting immediately is exactly what you want.
The main caution with Promise.all is its failure behaviour: if any one promise in the list rejects, the whole Promise.all rejects immediately, with that first rejection's reason, and the other results are discarded, even if some of them succeeded. That all-or-nothing behaviour suits cases where you need every result to render the page, such as a dashboard that needs both users and posts before it can show anything meaningful. It does not suit cases where each result feeds an independent widget, and one failing widget should not blank the others. For those cases, Promise.allSettled, covered next, is the right tool. Choosing between them is a judgement about independence: if the results are needed together, use all; if they are independent, use allSettled. Making that judgement explicitly, rather than defaulting to one, is part of designing a page that degrades gracefully when part of its data fails to arrive.
Parallel fetching also benefits from starting all requests as early as possible, because the total time is determined by the slowest request, and a request started late adds its full duration to that total. Building the list of promises promptly, rather than awaiting one request before creating the next, ensures that all requests travel concurrently from the start.
Check: If one promise in Promise.all rejects, what happens to the whole thing?
Section 27 — Promise.allSettled for independent results
Promise.allSettled() is the parallel tool for independent results. Like Promise.all, it takes a list of promises and waits for all of them, but unlike Promise.all, it never rejects because one of them rejected. Instead, it always fulfils, with a list of outcome objects, one per promise, in the same order as the promises you gave it. Each outcome object has a status field, which is either "fulfilled", with a value field holding the result, or "rejected", with a reason field holding the error. Your code can then loop over the outcomes, rendering the successes and showing a small error for each failure, so a page with several independent widgets can show the widgets that loaded and explain the ones that did not, rather than showing nothing because one widget failed.
That graceful degradation is the point of allSettled. Consider a dashboard with a weather widget, a news widget, and a tasks widget, each fed by a different API. If the weather API is down, the dashboard should still show the news and the tasks, with a small "Weather unavailable" note in the weather widget's place. Promise.all would reject the whole dashboard, leaving the visitor with a single error and no content, even though two of the three data sources were fine. Promise.allSettled lets the dashboard be partially successful, which matches how visitors think about a page: a failure in one section should not destroy the others. The cost is that your code must handle the outcome objects explicitly, checking each status and branching accordingly, rather than receiving a clean list of values. That extra handling is the price of resilience, and it is worth paying when the sections are genuinely independent.
There is a design question behind the choice between all and allSettled, and it is worth asking deliberately for each page: are these data sources needed together, or are they independent? A form that needs a list of countries and a list of categories before it can be shown needs both, so all fits, and a failure in either means the form cannot be shown correctly. A dashboard of independent widgets fits allSettled, because each widget stands alone. A page that mixes the two, with a core section that needs several sources together and side widgets that are independent, might use all for the core and allSettled for the widgets, combining the tools to match the page's structure. That matching, tools chosen to fit the independence of the data, is a hallmark of thoughtful asynchronous design, and it is more important than any single method's syntax.
When using allSettled, remember to handle both outcome shapes explicitly in your loop, checking status before accessing value or reason. Accessing value on a rejected outcome gives undefined, which can silently render as a blank widget, hiding the failure instead of showing an error. An explicit branch, fulfilled renders the widget, rejected renders a small error, keeps the page honest about which sections succeeded and which did not.
Check: How does Promise.allSettled differ from Promise.all when one request fails?
Section 28 — Timeouts: not waiting forever
Fetch has no built-in time limit. If a server accepts a request and then never replies, or if the network stalls midway, a fetch promise can remain pending indefinitely, and the page will remain in its loading state forever, with no error and no way forward for the visitor. That is a poor experience, and it is also a resource problem, because the page is left waiting on a request that will never complete. A timeout solves it by imposing a limit: if the request has not finished within a chosen time, say ten seconds, it is treated as failed, and the page shows its error state with a retry option. A timeout turns an endless wait into a managed failure, which is strictly better, because a managed failure can be retried, explained, and recovered from, while an endless wait cannot.
Implementing a timeout for fetch uses the AbortController from the next section, paired with setTimeout. You create a controller, pass its signal to fetch, and start a timer that calls controller.abort() after the chosen delay. If the fetch finishes before the timer fires, you clear the timer, so it does not abort a completed request. If the timer fires first, it aborts the fetch, which rejects with an abort error, and your catch block handles it like any other failure, showing the error state. The delay you choose is a judgement call: too short, and slow but working requests on poor connections will be cut off unnecessarily; too long, and visitors will wait too long before learning something is wrong. Ten seconds is a common starting point for ordinary data requests, with longer limits for large uploads or downloads, and shorter limits for suggestions or autocomplete, where a stale reply is worse than no reply.
There is a subtlety in how a timeout error should be presented. An aborted request due to a timeout is not the visitor's fault, and it is not necessarily a sign that the server is down; it may simply be slow. The message should therefore invite a retry, rather than declaring a permanent failure, and it should not blame the visitor's connection without evidence. Distinguishing a timeout abort from other aborts, such as a deliberate cancellation when the visitor navigates away, matters for logging and for messaging, and it is done by checking the error's name, which is "AbortError" for aborts, together with whether your timeout was the cause. A timeout is a safety valve, not a punishment, and designing it as such, with a sensible delay, a clear retry path, and a calm message, keeps a slow network from becoming a broken page.
Choosing a timeout also depends on the visitor's connection. A request that is slow on a fast connection may be normal on a slow mobile connection, so a timeout that is generous on desktop may cut off legitimate requests on a phone. Testing with throttling, as Section 48 recommends, helps you choose a timeout that protects against endless waits without punishing visitors on slow networks, and it is a reminder that network speed is not uniform.
Check: Why might you want to give a fetch a time limit?
Section 29 — Aborting a request
An AbortController is JavaScript's tool for cancelling an asynchronous operation, and for fetch it works through a signal. You create a controller with new AbortController(), pass its signal property in the fetch options, and keep the controller somewhere you can reach it later. When you call controller.abort(), any fetch using that signal is cancelled: the request is stopped if it is still in flight, and its promise rejects with an abort error. That cancellation is useful in several situations. A visitor typing a new search before the old search has finished should cancel the old search, so its stale results cannot overwrite the new ones. A visitor navigating away from a page while data is loading should cancel the load, so the page does not waste effort rendering data nobody will see. A timeout, from the previous section, is itself an abort, triggered by a timer rather than by a visitor action.
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();
Handling an abort correctly means recognising it and not treating it as an ordinary failure. An aborted fetch rejects, so it will reach your catch block, but showing an error message for a deliberate cancellation would be wrong: the visitor cancelled the search themselves, or left the page, and an error message about a request they no longer care about is noise. The usual pattern is to check, in the catch block, whether the error's name is "AbortError", and if so, to return quietly, without showing an error state. That check distinguishes a cancellation from a genuine failure, and it keeps the page calm when a request is superseded. It also matters for race conditions, covered in Section 48: aborting the previous request is one way to ensure that only the latest request's results are shown, because the previous request never completes to overwrite them.
Each AbortController is single-use in effect: once aborted, its signal stays aborted, and any new fetch using the same signal will abort immediately. For a new request, you need a new controller, which is why search implementations typically create a fresh controller for each search and store it in a variable, aborting the previous controller before creating the next. That pattern, abort previous, create new, fetch with new signal, is worth learning as a unit, because it appears in search boxes, autocomplete, and any interface where a new request supersedes an old one. Aborting is not about failure; it is about control, giving your code a way to say "this request is no longer needed", and letting the browser stop work that no longer matters.
Aborting also saves server resources, because a cancelled request tells the server, where possible, that its reply is no longer needed, so it can stop work that would otherwise be wasted. That saving matters for expensive requests, such as a large search or a complex calculation, where the server's work is significant. Aborting is therefore not only a front-end tidiness measure; it is a courtesy to the server and to other visitors sharing its capacity.
Check: What does calling controller.abort() do to a fetch in flight?
Section 30 — Debouncing a search
A search box that fires a request on every keystroke creates two problems. The first is load: a visitor typing a ten-letter word triggers ten requests, most of them for prefixes they never intended to search for, such as "c", "ca", and "cat" on the way to "cats". That load wastes the visitor's bandwidth, the server's capacity, and, on a metered connection, the visitor's money. The second problem is correctness: network requests do not always finish in the order they were sent, so the reply for "ca" might arrive after the reply for "cats", overwriting the correct results with stale ones, and leaving the visitor looking at results for a query they have already moved past. Both problems have the same solution: wait until the visitor pauses before sending the request. That waiting technique is called debouncing.
Debouncing works with a timer. On each keystroke, you clear any existing timer and start a new one, set to fire after a short delay, commonly around 300 milliseconds. If the visitor types again before the delay ends, the timer is cleared and restarted, so no request is sent. Only when the visitor pauses for the full delay does the timer fire and the request go out, for the complete query as it stands at that moment. The effect is that a fast typist triggers one request for their finished word, rather than ten requests for its prefixes, and the results correspond to the query the visitor actually meant. The delay is a balance: too short, and you still send requests for prefixes; too long, and the search feels sluggish, because the visitor waits after pausing before results appear. Three hundred milliseconds is a common starting point, tuned up or down for the specific interface.
Debouncing is often combined with aborting, from the previous section, for a robust search. Debouncing reduces the number of requests sent, and aborting ensures that any request still in flight when a new one is sent is cancelled, so stale results cannot overwrite fresh ones. Together they handle both the load problem and the ordering problem, and they are a standard pair in search implementations. It is worth noting that debouncing is not only for search: any input that triggers expensive work, such as a live filter, a validation that queries a server, or a preview that re-renders a large document, benefits from waiting for a pause before doing the work. The principle is general: when a visitor's actions come in a rapid burst, wait for the burst to end before responding to it, rather than responding to every event in the burst.
Debouncing should also be paired with an immediate search on Enter, so a visitor who types a complete query and presses Enter does not have to wait for the debounce delay to elapse. Enter signals that the query is finished, which is exactly the condition debouncing waits for, so handling it directly gives a faster response without breaking the debounce logic. That combination, pause-triggered search plus Enter-triggered search, covers both browsing and deliberate searching in one interface.
Check: What problem does debouncing a search input solve?
Section 31 — Encoding URL values
Values placed in a URL must be encoded, because URLs have a restricted alphabet and a structure that special characters would break. A space in a search term, for example, cannot appear literally in a URL; it must be written as %20, or as a plus sign in a query string, depending on context. An ampersand inside a search term is more dangerous: in a query string, & separates one parameter from the next, so a search for "fish & chips" written literally as ?q=fish & chips would be read as a parameter q with the value "fish ", followed by a parameter named " chips" with no value, which is not what the visitor meant. Other characters, such as question marks, hashes, slashes, and non-English letters, have similar structural meanings or are outside the URL alphabet, and must also be encoded. Encoding replaces each such character with a percent sign followed by two hexadecimal digits representing its code, a scheme called percent-encoding.
JavaScript provides encodeURIComponent(value) for encoding a single value, such as one search term or one parameter value, and it encodes everything that could interfere with the URL's structure, including spaces, ampersands, question marks, and hashes. Its companion, encodeURI(url), encodes a whole URL but leaves structural characters such as question marks, ampersands, and slashes intact, because those are part of the URL's structure. The distinction matters: use encodeURIComponent for values you are inserting into a URL, and encodeURI only when you have a complete URL that needs its non-ASCII characters encoded without breaking its structure. Using the wrong one is a common bug: encoding a whole URL with encodeURIComponent will encode its question mark and slashes, destroying the URL, while encoding a value with encodeURI will leave its ampersand intact, breaking the query string.
The safest approach for building query strings is to avoid manual encoding altogether, by using URLSearchParams, introduced in Section 7, which encodes each value correctly as it builds the string. When you do build a URL by hand, encode each value at the point of insertion, and never paste raw visitor input into a URL. That rule protects against more than broken searches: unencoded input in a URL can change the meaning of the request, sending parameters the visitor did not intend, or, in a server that mis-handles it, reaching a different resource than intended. Encoding is a small, mechanical step, but it is the step that keeps a URL's structure and its values separate, and a page that encodes its values correctly will handle searches for "fish & chips", names with accents, and terms with punctuation without surprise, while a page that does not will fail mysteriously on exactly those inputs.
Encoding also matters for values that will be stored in a URL for sharing, such as a search term in a link. A link containing an unencoded search term may break when pasted into another app, which may interpret its special characters differently, while an encoded link survives copying intact. Encoding at the point of building the URL, rather than relying on the browser to fix it later, ensures the link works wherever it is shared, not only in the page that created it.
Check: Why must a search term containing & be encoded before going into a URL?
Section 32 — Handling a 404 gracefully
A 404 status means the requested item does not exist. The visitor may have followed an old link to a deleted post, mistyped an id, or requested an item that was removed after they loaded the list. Whatever the cause, your code must handle it as a known, expected outcome, not as a crash. The first step is detection, which comes from the response.ok check in Section 14: a 404 makes response.ok false, so your code can throw an error carrying the status, rather than trying to parse and render a body that is not the expected data. The second step is translation, turning that status into a message the visitor can act on. "That post could not be found" is clear and specific. "Error 404" is technical and unhelpful. "Something went wrong" is vague and misleading, because it suggests a temporary failure that a retry might fix, when the truth is that the item is gone and retrying the same request will fail again.
The visitor's next step after a 404 should be offered, not assumed. A link back to the list, a search box, or a suggestion to check the address gives them a path forward, rather than a dead end. If the 404 arose from a list, the list itself may have changed since it was loaded, and offering a refresh of the list is sensible: the item may have been deleted, and the refreshed list will no longer show it, resolving the inconsistency. If the 404 arose from a direct link, the visitor may have a mistyped address, and a search box helps them find what they actually wanted. In both cases, the page should preserve its navigation and layout, showing the 404 message within the normal page, rather than replacing the whole page with a bare error, because the visitor is still on your site and may want to continue elsewhere.
There is also a development lesson in 404 handling. During development, a 404 often means your code asked for the wrong path, not that the item is genuinely missing. A mistyped endpoint, a missing id in the path, or a base URL with a trailing slash problem will all produce 404s that look like missing data but are really coding errors. Logging the requested URL alongside the status, during development, will reveal those errors quickly, and comparing the URL character by character with the documentation will usually show the mistake. In production, though, a 404 should be treated as a user-facing outcome, not a bug to be debugged in front of the visitor. Handle it gracefully, explain it plainly, and offer a way forward, and a missing item becomes a minor detour rather than a broken experience.
Check: What should a page show for a 404 instead of crashing?
Section 33 — Network errors versus server errors
Fetch failures fall into two families, and distinguishing them is essential, because they require different handling and different messages. A network error means the request never reached the server, or the reply never came back. The visitor has no connection, the server's address does not resolve, the network drops midway, or the request is blocked. In these cases fetch rejects, and your catch block runs, because from fetch's point of view the operation failed to complete. A server error means the server received the request, attempted to handle it, and failed, replying with a 5xx status such as 500 or 503. In these cases fetch fulfils, because a reply did arrive, and your code must detect the failure by checking response.ok, as Section 14 explains. The two families are therefore detected by different mechanisms: network errors by rejection, server errors by status, and a page that handles only one will mishandle the other.
The messages for the two families should also differ, because the visitor's next step differs. A network error suggests checking the connection, and retrying when it returns, because the problem is between the visitor and the server, and it may resolve itself. A server error suggests waiting and retrying later, because the problem is on the server's side, and the visitor's connection is fine. Conflating the two, for example telling a visitor to check their connection when the server is down, sends them on a fruitless investigation of their own network while the real problem lies elsewhere. Where your code can distinguish them, and it can, by whether the failure arrived as a rejection or as a bad status, it should say so, in plain language, and offer the appropriate next step. Where it cannot distinguish them, because a helper has merged them into a single error, a neutral message inviting a retry covers both cases, though less precisely.
There is a testing implication too. Network errors and server errors are tested differently. A network error can be simulated by disconnecting, by using a browser's offline mode, or by requesting a non-existent domain. A server error can be simulated by requesting an endpoint known to fail, or by using a mock server that returns a 500. Testing both, and confirming that each produces the right message and the right retry behaviour, is part of finishing a data feature. A page that has only been tested with successful replies is untested in the conditions where visitors most need it to work well, because success is the easy case. The hard cases, no connection and a failing server, are the ones where good error handling earns its keep, and distinguishing them is the first step to handling each correctly.
Check: Which kind of failure makes fetch reject, and which one does not?
Section 34 — Retrying a failed request
Some failures are temporary. A brief network blip, a momentarily busy server, or a connection that drops and returns within seconds can all cause a request to fail even though the same request would succeed a moment later. Retrying, sending the same request again after a short pause, can turn those temporary failures into successes, without the visitor having to click retry themselves. A common pattern is to retry once or twice, with a pause between attempts that grows each time, a technique called exponential backoff, where the first pause might be one second, the second two seconds, and the third four seconds. The growing pause gives a struggling server time to recover, rather than hammering it with immediate retries that add to its load. For a data fetch on page load, a single automatic retry after a short pause is often enough to smooth over the majority of transient failures, and it makes the page feel more reliable without the visitor ever knowing a retry happened.
Retrying is not always appropriate, and knowing when not to retry is as important as knowing when to retry. A request that failed because the data was wrong, such as a 400 for a malformed body, will fail again with the same data, so retrying it wastes time and server capacity. A request that failed because the item does not exist, a 404, will fail again, because the item is still missing. A request that failed because the visitor is not authorised, a 401 or 403, will fail again until they sign in or gain permission, so retrying without addressing the authorisation is pointless. Retrying is also dangerous for requests that change something, such as a POST that creates an order, because a retry after an ambiguous failure, where the server may have processed the first attempt but the reply was lost, could create a duplicate. For such requests, automatic retry should be avoided, or handled with an idempotency key, a unique identifier that lets the server recognise a retry as the same operation, a server-side concept beyond this chapter's scope but worth knowing exists.
The practical rule is therefore selective. Retry automatically, a small number of times, with growing pauses, for GET requests that fail with network errors or 5xx server errors, which are the failures most likely to be temporary and safe to repeat. Do not retry automatically for 400-series errors, which indicate a problem with the request itself, or for POST, PUT, PATCH, and DELETE requests, where a duplicate could cause harm, unless you have a mechanism to make the retry safe. In all cases, limit the number of retries, so a persistently failing request eventually surfaces as an error with a manual retry button, rather than retrying forever in the background. Automatic retry is a kindness that smooths over transient failures; unlimited retry is a denial of a real problem. Limiting it, and choosing its cases carefully, keeps it on the right side of that line.
Check: When is retrying a good idea, and when is it pointless?
Section 35 — Handling a bad JSON reply
A fetch can succeed at the network level, pass the response.ok check, and still fail when its body is read, because the body is not the JSON your code expects. The server may have returned an HTML error page instead of JSON, the reply may have been truncated by a network problem, or the API may have changed its format without notice. In each case, response.json() rejects, because the text it received is not valid JSON, and that rejection must be handled like any other failure, with a message and a retry option, rather than a crash. The temptation is to assume that a successful status guarantees a parseable body, but status and body are separate things, and a server can return a 200 status with a body that is not JSON, just as it can return a 500 status with a JSON error object. Checking the status and parsing the body are two separate steps, and each needs its own handling.
Debugging a bad JSON reply starts with seeing what actually arrived. Logging the reply's text, rather than only its parsed form, reveals the truth: an HTML page with a "not found" heading, a plain-text message, or a JSON document cut off halfway will each point to a different cause. During development, you can read a response clone as text, as Section 16 notes, to inspect the raw reply without consuming the original body. In production, though, the visitor should not see the raw reply; they should see a calm message such as "The data could not be read. Please try again.", with a retry button, while the technical detail is logged for you. That separation, technical detail for the developer, human message for the visitor, is a recurring theme in this chapter, and it applies as much to parsing failures as to network failures.
There is also a defensive technique worth learning: validating the shape of parsed data before rendering it. Even valid JSON can have an unexpected shape, such as an object where your code expects a list, or a missing field where your code expects a title. Accessing a property on undefined, or calling .map on something that is not a list, will throw an error at render time, after the fetch has apparently succeeded. A simple shape check, confirming that the data is a list before mapping over it, or that a required field exists before displaying it, turns a render crash into a handled error. You do not need a full validation library for this; a few explicit checks on the fields your rendering actually uses are enough. Combined with handling for parsing failures, shape checks make your data handling robust against the reality that servers, networks, and APIs do not always deliver exactly what you expected, and a page that handles those surprises gracefully is a page visitors can trust.
Check: What should your code do when response.json() fails on a reply that had a successful status?
Section 36 — Pagination: showing data a page at a time
A list with thousands of items cannot sensibly be shown all at once. Loading them all would be slow, rendering them all would be heavy, and scrolling through them would be overwhelming. Pagination solves this by showing data a page at a time, typically ten, twenty, or fifty items per page, with controls to move between pages. APIs support pagination through query parameters, commonly a page number and a page size, such as ?page=2&limit=20, or an offset and a limit, and the reply often includes the total number of items, so your page can calculate how many pages exist and show "Page 2 of 10". Your page's job is to keep track of the current page, fetch that page's items when the page changes, render them, and update the pagination controls to reflect the new state, disabling the previous button on the first page and the next button on the last.
Implementing pagination well requires attention to state and to feedback. The current page number is state, and it should live in a variable that your fetch function reads and your pagination buttons update. When a button is clicked, the page number changes, a loading state appears, the new page is fetched, and the list is replaced, not appended to, because each page is a complete set, not an addition to the previous one. Scrolling back to the top of the list after a page change is a kindness, because the visitor would otherwise be left at the bottom of the page looking at the pagination controls, with the new items above them out of view. Preserving the visitor's place in other ways, such as keeping filters applied across pages, is also important: changing page should not reset a search term or a filter, because the visitor asked for a page of the filtered results, not a fresh, unfiltered list.
There are alternatives to pagination worth knowing, though pagination remains the most common. Infinite scrolling loads more items as the visitor scrolls, which suits feeds where the visitor browses without a specific destination, but it makes it hard to return to a specific place, and it can load indefinitely on a long list. A "load more" button is a middle ground, appending the next page to the existing list on demand, which suits browsing while keeping the visitor in control of how much loads. Each pattern has trade-offs, and the choice depends on how visitors use the list: searching for a specific item favours pagination, with its direct access to pages and its stable addresses; browsing a feed favours scrolling or load-more. Whichever pattern you choose, the underlying fetch is the same, a request for a page of data, and the interface around it, controls, loading, and empty states, is what makes it usable.
Check: What query parameters does pagination commonly use, and what must your page keep track of?
Section 37 — Filtering and sorting on the client
Not every filter or sort needs a new request. When a list is small enough to have been fetched in full, filtering and sorting can be done in the browser, on the data already held, which is faster and more responsive than fetching again for each change. Filtering means keeping only the items that match a condition, such as a search term in the title, a category, or a price below a limit, and it is implemented with the array method .filter(), which returns a new list containing only the items for which your test returns true. Sorting means ordering the items, such as alphabetically by title or numerically by price, and it is implemented with .sort() and a comparison function. Both operate on the fetched data, leaving the original list untouched if you copy it first, which matters, because you will need the original list again when the filter changes or is cleared.
Client-side filtering and sorting have a clear limit: they only work on data you already have. If the full list has thousands of items, fetching them all to filter in the browser defeats the purpose of pagination and wastes bandwidth. In that case, filtering and sorting belong on the server, expressed as query parameters, such as ?category=books&sort=price, and each filter change triggers a new fetch for the filtered, sorted page. The choice between client-side and server-side filtering is therefore a judgement about size: small lists, fetched in full, suit client-side work; large lists, fetched a page at a time, require server-side work. Many real pages mix the two, fetching a page from the server with a server-side filter, then applying a client-side sort to that page, or fetching a moderate list once and filtering it locally thereafter. Naming which side does which job, in your code and in your head, prevents the confusion of filtering a page and wondering why items from other pages never appear.
There are implementation details that make client-side filtering feel good. Filtering should be case-insensitive for text searches, so "cats" matches "Cats", which is achieved by lowercasing both the search term and the item's text before comparing. It should handle empty search terms by showing the full list, not an empty one, because an empty search means "no filter", not "match nothing". Sorting should be stable and predictable: sorting by price should order numbers numerically, not alphabetically, because alphabetical ordering would place 100 before 20, since "1" comes before "2" as text. And both operations should re-render the list from the filtered, sorted result, rather than manipulating the displayed elements directly, so the display always reflects the current data and filter, not a history of incremental changes. Filtering and sorting on data you already hold are among the most satisfying uses of fetched data, because they make a static list feel interactive, instantly, without another network round trip.
Check: When should filtering happen in the browser, and when should it happen on the server?
Section 38 — Building a search interface
A search interface brings together several techniques from this chapter into one feature, and it is worth studying as a whole, because it shows how the pieces fit. The interface itself is simple: a text input, a button or an automatic trigger, and a list for results, plus loading and error states. The behaviour behind it is richer. As the visitor types, the input event fires, and a debounced handler, from Section 30, waits for a pause before searching, so prefixes do not trigger requests. When the search runs, it builds a URL with the search term encoded, from Section 31, and with any filters as query parameters, and it aborts any previous search still in flight, from Section 29, so stale results cannot overwrite fresh ones. It shows a loading state, fetches, checks response.ok, parses the body, and renders the results, or shows an error state on failure, or an empty state when the search genuinely returns nothing. Each of those steps is a technique from an earlier section, and the search interface is the place where they meet.
The empty state for search deserves particular attention, because it is easily confused with a failure. A search that returns no results is not an error; it is a successful search whose answer is "nothing matched". Showing an error message for it would mislead the visitor into retrying a search that worked correctly. Instead, show a message that states the outcome and offers a next step, such as "No results for 'quokka'. Try a different spelling, or a more general term." That message acknowledges the query, explains the outcome, and suggests a constructive action, which is far more helpful than a blank list or a generic error. Similarly, a search with an empty query should not be sent at all; it should either show a prompt, such as "Type something to search", or show a default list, depending on the design. Sending an empty search wastes a request and returns a confusing set of results.
Search also raises a question of when to search: on every pause, as above, or only when the visitor presses Enter or clicks a button. Automatic searching feels responsive and suits exploratory browsing, where the visitor refines their term and watches results change. Button-triggered searching suits deliberate searches, where the visitor composes a complete query before asking, and it avoids requests for half-formed terms. Many interfaces combine both: automatic searching after a pause, plus Enter or a button for an immediate search. Whichever trigger you choose, the underlying flow, debounce, encode, abort previous, load, fetch, check, render, or handle empty and error states, remains the same. Building a search interface end to end is therefore an excellent practical exercise, because it forces you to combine the chapter's techniques into a feature that feels complete, rather than practising each technique in isolation.
Check: Why must a search distinguish an empty result from a failed request?
Section 39 — Skeleton screens and perceived speed
A loading message tells the visitor that work is in progress, but a skeleton screen goes further: it shows a grey, simplified outline of the content to come, with blocks where titles, images, and text will appear, in the layout the real content will occupy. When the data arrives, the real content replaces the skeleton in place, without the layout shifting, because the skeleton already reserved the space. That stability is the skeleton's main benefit. A page that jumps from a loading message to a full list, with elements moving as images load and text wraps, feels janky and unfinished, while a page whose skeleton already holds the shape feels smooth, even if the actual waiting time is the same. Perceived speed, how fast a page feels, is partly about layout stability and partly about giving the visitor something to look at, and skeletons address both.
Skeletons are implemented with simple markup and CSS: placeholder elements with a grey background and perhaps a subtle pulsing animation, arranged in the same structure as the real items. They are shown before the fetch, and hidden when the rendering function runs, in the same place where a loading message would be shown and hidden. The animation, if used, should be gentle: a slow fade or shimmer, not a flashing strobe, which would distract and could trouble visitors sensitive to motion. Accessibility matters here too: skeleton elements should not be announced as content by screen readers, since they are not content, and they should be marked as decorative or hidden from assistive technology, while a proper loading message, announced as a status, tells screen reader visitors that loading is in progress. A skeleton is a visual aid for sighted visitors, not a replacement for a status message for all visitors.
Whether to use a skeleton or a simple loading message is a judgement about the content and the wait. For a list whose items have a predictable shape, such as cards with an image, a title, and a line of text, a skeleton is easy to build and effective. For a single value, such as a temperature, a simple "Loading..." message is clearer, because a skeleton for one number would be odd. For a very fast request, neither may be needed, or the loading state may be delayed, as Section 17 notes, to avoid a flash. The principle behind skeletons is general, though: show the shape of what is coming, hold the layout steady, and replace the placeholder with real content in place. That principle, applied thoughtfully, makes waiting feel shorter and pages feel more polished, without making the actual data arrive any faster.
Skeletons should also match the real content's dimensions closely, because a skeleton that is taller or shorter than the real item will still cause a layout shift when it is replaced, defeating its main purpose. Measuring the real item's height, or using the same layout structure for the skeleton as for the item, keeps the replacement seamless. A skeleton that merely suggests a shape, without matching its size, is little better than a loading message for layout stability.
Check: What problem does a skeleton screen solve that a plain "Loading..." message does not?
Section 40 — Authentication: proving who you are
Many APIs require authentication, a way for the server to know who is making a request, before they will return data or accept changes. A public practice API may need no authentication at all, but a real API for a user's messages, orders, or settings must know which user is asking, or it could return one person's private data to another. Authentication is usually proved with a token, a long, random string issued by the server when the visitor signs in, which the visitor's page then sends with each subsequent request, typically in the Authorization header, in the form Bearer <token>. The server checks the token, identifies the user it belongs to, and either serves the request for that user or rejects it. The token is therefore a kind of temporary password, and it must be treated with the same care: never written into code that is shared, never logged to the console, and never sent to a server other than the one that issued it.
In a browser page, tokens are commonly stored in memory, in a variable, or in local storage, a subject Chapter 23 examines, and each storage choice has trade-offs. Memory storage is safest against certain attacks, because the token disappears when the page is closed, but it is lost on refresh, forcing the visitor to sign in again. Local storage persists across refreshes, which is convenient, but any script running on the page can read it, so a cross-site scripting vulnerability, from Section 20, could steal it. Cookies with appropriate flags offer another set of trade-offs, managed by the browser and the server together. For this book's purposes, the key point is not to choose a storage mechanism in the abstract, but to understand that a token is sensitive, that its storage is a security decision, and that a tutorial or practice project that hardcodes a token in its source code is demonstrating a bad practice, however convenient it is for learning. Practice APIs that need no token, or that use a clearly fake token for demonstration, are the right tools for learning fetch, and real tokens belong only in real applications with proper security review.
When a request fails because authentication is missing or invalid, the server replies with a 401 status, meaning authentication is required, or a 403, meaning the authenticated user is not allowed to perform that action. Your code should translate those into appropriate messages and actions: a 401 usually means the visitor should be sent to sign in, or the token has expired and needs refreshing, while a 403 means the visitor is signed in but lacks permission, and retrying the same request will not help. Handling those two cases differently, sign-in for 401 and a permission message for 403, is part of a complete data feature, and it is why status codes, from Section 8, matter beyond success and failure. Authentication turns a public data fetch into a personal one, and handling its failures gracefully is part of respecting both the visitor and the data's owner.
Check: What is an authentication token, and where is it usually sent with a request?
Section 41 — CORS in plain words
CORS, short for Cross-Origin Resource Sharing, is the browser's security rule about when a page on one origin may fetch data from another origin. An origin is the combination of a scheme, a domain, and a port, such as https://example.com, and two pages have different origins if any of those three differ. By default, a page may only fetch from its own origin, a rule called the same-origin policy, which exists to prevent a malicious page from reading private data from another site the visitor is signed into, such as their email or bank, simply by fetching it in the background. That policy protects visitors, but it also blocks legitimate cases, such as a page on your domain fetching from a public API on another domain, which is exactly what many front-end projects need to do. CORS is the mechanism by which a server can grant permission for such cross-origin fetches, by including specific headers in its replies that say which origins are allowed.
When your page fetches from another origin, the browser checks the reply's CORS headers before giving the data to your JavaScript. If the server's reply includes an Access-Control-Allow-Origin header that permits your page's origin, or permits all origins with a star, the browser hands over the data. If it does not, the browser blocks your code from reading the reply, even though the request may have reached the server and the server may have replied, and your fetch will reject with a CORS error. That blocking happens in the browser, not on the server, which is why a request that works in a tool like curl, which has no same-origin policy, can fail in a browser: the tool is not subject to the rule, and the browser is. A CORS error in the console, mentioning the missing header, is therefore not a bug in your fetch code, and it is not fixed by changing your fetch options; it is fixed by the server granting permission, or by fetching through a server of your own that is allowed to call the API.
There are complications worth knowing, though not worth dreading. For simple requests, such as a GET with no custom headers, the browser sends the request directly and checks the reply's headers. For more complex requests, such as a POST with a JSON body or a custom header, the browser first sends a preflight request, an OPTIONS request asking the server whether the actual request would be allowed, and only sends the actual request if the preflight reply grants permission. That extra request is normal, and it explains why some cross-origin POSTs appear as two requests in the network panel. Another complication is that CORS headers cannot be added by the fetching page; they must come from the server, which is why a public API that intends to be used from browsers includes them, and a private API that does not intend browser access may not. When you choose an API for a front-end project, checking that it supports browser access, through its CORS headers or its documentation, is a practical first step that saves a frustrating debugging session later.
Check: Why does a cross-origin fetch fail in a browser even when the server replied?
Section 42 — Reading API documentation
API documentation is the contract between an API's provider and its users, and learning to read it efficiently is a professional skill, not a chore to be skipped. A good documentation page for an endpoint tells you, in a predictable order, what the endpoint is for, its method and path, the parameters it accepts, the headers it requires, the shape of the body it expects, and the shape of the reply it returns, including an example reply. It also lists the status codes it may return and what each means for that endpoint, and any limits, such as rate limits or pagination defaults. Reading that page before writing code is faster than guessing, because a guessed parameter name, a wrong method, or an assumed reply shape will each cost a debugging round trip, while a few minutes with the documentation will answer all three at once.
A practical reading order helps. Start with the endpoint's purpose and its method and path, to confirm it does what you need. Then read its parameters, distinguishing required parameters, which must be present, from optional ones, which refine the request, and noting whether each parameter goes in the path, the query string, or the body, since putting a parameter in the wrong place is a common error that the server may ignore silently. Then study the example request and the example reply, and compare the reply's shape with what your rendering code expects: is the list at the top level, or inside an object? Are the fields named as you assumed? Finally, read the error section, noting which statuses the endpoint returns and what its error bodies look like, so your error handling can translate them. That order, purpose, parameters, examples, errors, mirrors the order in which you will write the code, and it ensures you do not discover a required parameter only after your first request fails.
Documentation also deserves scepticism. It can be out of date, incomplete, or written for a different version of the API than the one you are calling. When a reply differs from the documentation, trust the reply, and check whether you are calling the version the documentation describes, or whether the documentation has a changelog or version notes. Many APIs also provide an interactive explorer, where you can try an endpoint in the browser and see a real reply, which is an excellent way to confirm your understanding before writing code. Keep a note of the endpoints you use, their parameters, and any quirks you discover, because that note becomes your project's own documentation, and the next person to work on it, who may be you in six months, will be grateful. Reading documentation well is not a detour from building; it is the first step of building correctly.
Check: In what order should you read an API endpoint's documentation, and why?
Section 43 — Practice APIs and mock data
Learning fetch requires an API to fetch from, and practice APIs exist for exactly that purpose. They provide fake but realistic data, posts, users, comments, photos, and more, through real HTTP endpoints, without requiring sign-up, payment, or authentication, so you can practise GET, POST, PUT, PATCH, and DELETE against a live server. A well-known example provides posts and users at addresses that follow the patterns in this chapter, and it returns the created or updated item in its reply, even though it does not permanently store your changes, which is fine for learning, since the point is to practise the request and the reply, not to run a real service. Using such an API lets you see real network behaviour, real statuses, and real JSON, which a purely local mock cannot fully reproduce, while keeping the stakes low: no real data is at risk, and mistakes are harmless.
Mock data has a second form worth knowing: data you define yourself, in your own code, in the same shape as the real API's replies, and use while the real API is unavailable, or while you are building the rendering before the fetching is ready. You might define a small list of posts in a JavaScript file, render them with the same rendering function your fetch will use, and only later replace the mock list with a fetch call. That separation, rendering that works with any data of the right shape, is the same separation Section 19 advocates, and it lets you develop the interface and the data fetching independently. When the real API becomes available, you swap the source, not the rendering, and if the real API's shape differs from your mock, you adjust the translation between them, rather than rewriting the display. That workflow is used in professional projects too, where the front end is often built against mock data while the back end is still being developed.
There are cautions for both forms. A practice API's data is fake, and its behaviour may differ from a real API's in important ways, such as not persisting changes, not enforcing validation, or not requiring authentication, so a feature that works against it is not proven against a real service. Mock data, similarly, can hide shape mismatches, because you defined it to match your code's assumptions, while the real API may not. The remedy in both cases is to test against the real API as soon as it is available, and to treat mock-based success as provisional. For learning, though, practice APIs and mock data are invaluable: they give you a safe, free, and immediate way to practise every technique in this chapter, from the first fetch to pagination to error handling, without needing a server of your own, and they are the right place to make the mistakes that teach you how fetch really behaves.
Check: Why is a practice API useful for learning fetch, and what is its main limitation?
Section 44 — Handling empty results
An empty result is a successful outcome, not a failure, and handling it as a failure is a common beginner mistake. When a search returns no matches, a filter excludes everything, or a list is genuinely empty because nothing has been created yet, the server replies with a success status and an empty list, or an equivalent empty shape. Your code should recognise that shape, show a message that states the outcome plainly, and offer a useful next step, rather than showing an error, a blank page, or a loading indicator that never disappears. The message should distinguish the cases: a search with no matches deserves "No results for your search", a filtered list with no matches deserves "No items match this filter", and a list that is empty because nothing exists yet deserves "No posts yet. Be the first to create one." Each message tells the visitor what happened and what they can do, which is more than a blank list ever could.
Implementing an empty state is straightforward if rendering is separated from fetching, as Section 19 recommends. The rendering function checks the list's length before building items: if it is zero, it shows the empty message; if it is not, it builds the items. That check must happen after a successful fetch, not before, because an empty list before the fetch is a loading state, not an empty state, and confusing the two will show "No results" for a moment while the data is still travelling, then replace it with the real list, a flicker that undermines trust. The sequence is therefore: loading state while fetching, then, on success, either the list or the empty message, depending on the length, and on failure, the error state. Keeping those three states distinct, loading, empty, and error, and showing exactly one at a time, is the discipline that makes a data interface feel clear.
Empty states also have a design dimension. A good empty state is not merely the absence of items; it is an invitation. A new user's empty task list can suggest creating a first task, with a button that opens the creation form. An empty search result can suggest broader terms or related categories. An empty filtered list can offer to clear the filter, with one click, to show everything again. Those invitations turn an empty outcome from a dead end into a next step, and they show the visitor that the page understands their situation. An empty list is not a bug to be hidden; it is a state to be designed, with the same care as the full list, because every visitor will encounter it at some point, whether as a new user, a searcher with an unusual term, or a filterer with a narrow choice.
Check: How should a page distinguish an empty result from a loading state and from an error?
Section 45 — Optimistic updates
An optimistic update is a technique for making an interface feel fast by updating the display before the server confirms the change. When a visitor clicks a "like" button, the page immediately shows the post as liked and increments the count, without waiting for the server's reply. The request is still sent, in the background, and if it succeeds, the display is already correct, so nothing more needs to happen. The visitor perceives the action as instant, even though the network round trip still takes time, because the feedback they care about, the button's state, arrived immediately. That perception matters: a like button that waits for a server reply before changing feels sluggish, while one that changes immediately feels responsive, even on a slow connection, and that responsiveness encourages further interaction.
Optimism has a cost, which is the need for a rollback. If the server rejects the change, because the visitor is not authorised, because the item no longer exists, or because the network failed, the page must undo its optimistic update, restoring the previous state and explaining what happened. Without a rollback, the page would show a liked post that the server does not record as liked, a lie that will be exposed the next time the data is fetched, and that undermines trust in the interface. Implementing a rollback requires keeping the previous state somewhere, so it can be restored, and handling the failure in the request's catch block by restoring it and showing a message. That extra work is the price of optimism, and it is worth paying only when the action is low-stakes and likely to succeed, such as liking, bookmarking, or toggling a setting, where a rollback is a minor correction rather than a major disruption.
For high-stakes actions, such as placing an order or deleting an important item, pessimistic updating is safer: the page waits for the server's confirmation before changing the display, showing a loading state in the meantime, so the visitor never sees a state the server has not confirmed. The choice between optimistic and pessimistic updating is therefore a judgement about stakes and likelihood: optimistic for frequent, reversible, likely-to-succeed actions where speed matters; pessimistic for rare, irreversible, or uncertain actions where correctness matters more than speed. Many pages mix the two, optimistic for likes and toggles, pessimistic for purchases and deletions, matching the technique to the action's nature. Understanding both, and choosing deliberately, is part of designing interfaces that feel fast without lying about the state of the world.
Optimistic updates also need to consider what happens if the visitor navigates away before the server confirms. If the page is closed, the optimistic change is lost with it, and the next visit will show the server's state, which may not include the change if it never completed. For important actions, that risk is another reason to prefer pessimistic updating, where the visitor waits for confirmation before leaving, rather than an optimistic change that may not have been recorded.
Check: What must happen if an optimistic update's server request fails?
Section 46 — Async functions return promises
An async function always returns a promise, and that fact has consequences that are easy to overlook. When you write async function loadData() { return data; }, calling loadData() does not return data; it returns a promise that will fulfil with data. A caller that treats the return value as data directly, for example by trying to access a property on it, will get undefined, because a promise does not have the data's properties. The caller must await the result, inside another async function, or attach a .then() handler, to receive the data when the promise settles. That requirement spreads through a program: any function that calls an async function and needs its result must itself be async, or must handle a promise, which is why asynchronous code tends to form chains of async functions from the initial event handler down to the fetch itself. That spreading is normal, not a flaw, and it reflects the reality that a value depending on a network request cannot be available immediately.
This property also means that errors inside an async function become rejections of its returned promise, rather than exceptions thrown to the caller. A caller that calls an async function without awaiting it or attaching a catch handler will miss its errors entirely, producing unhandled rejections, the silent failures discussed in Section 11. A caller that awaits it inside a try block, or attaches a .catch(), will handle them correctly. The discipline is therefore to never call an async function and ignore its return value, unless you deliberately intend to let it run in the background and you have attached error handling to it. Even for background work, attaching a catch handler is wise, so a failure does not pass silently. In event handlers, which are often async functions themselves, awaiting the data function inside try and catch is the cleanest pattern, because the handler's own promise is usually ignored by the browser, and its errors must be handled inside it, not left to propagate.
There is a related point about returning values from async functions. If an async function returns a promise, the outer promise adopts that inner promise's state, fulfilling with its value or rejecting with its reason, rather than fulfilling with the promise object itself. That flattening means you can return a fetch promise from an async function without nesting promises inside promises, and the caller will receive the fetch's value directly when it awaits. It also means that an async function that returns nothing fulfils with undefined, which is fine when the caller only cares that the work completed, not what it produced. Understanding that async functions return promises, that their values are always eventual rather than immediate, and that their errors are rejections, is the foundation for composing asynchronous functions into larger programs without losing results or errors along the way.
Check: Why does calling an async function not give you its result directly?
Section 47 — Avoiding race conditions
A race condition in a data interface happens when two requests are in flight at once, and the slower, older request finishes after the faster, newer one, overwriting the newer results with older ones. The classic case is a search box: the visitor types "ca", a request for "ca" is sent, then types "t" to make "cats", a request for "cats" is sent, and the "ca" request, delayed on the network, arrives after the "cats" request, replacing the correct "cats" results with stale "ca" results. The page then shows results for a query the visitor has already left behind, which is confusing and wrong, even though each individual request succeeded. The problem is not a failure in any one request; it is the ordering of their completions, which the network does not guarantee to match the ordering of their starts.
There are two main defences against race conditions, and they are often used together. The first is aborting, from Section 29: when a new request is sent, abort the previous one, so it cannot complete and overwrite the new results. Aborting is clean, because it stops the old work entirely, and it also saves bandwidth. The second is tracking the latest request, with a counter or a token: each request is given a number, and when a reply arrives, the code checks whether its number matches the latest request's number, ignoring the reply if it does not. That check is useful when aborting is not possible, such as when the request has already completed on the server and its reply is on its way, or when the operation cannot be cancelled. The token approach does not stop the old work, but it stops its results from being displayed, which is the outcome that matters to the visitor.
Debouncing, from Section 30, reduces race conditions by reducing the number of requests, but it does not eliminate them, because two debounced requests can still complete out of order if the network delays one. A robust search therefore combines all three: debounce to reduce requests, abort to cancel superseded ones, and a latest-request check as a final guard against any reply that slips through. That combination may sound like over-engineering for a simple search, but it is the standard approach in production interfaces, because race conditions are among the most confusing bugs a visitor can encounter: the page shows results, confidently, for the wrong query, with no error to explain it. Designing against them, by ensuring that only the latest request's results are ever displayed, is part of making a data interface trustworthy, not merely functional.
A latest-request check can be implemented simply with a counter that increments on each new request, and a closure that captures the counter's value for that request. When a reply arrives, the handler compares its captured value with the current counter, and ignores the reply if they differ, because a newer request has since been sent. That small pattern, a number and a comparison, is enough to prevent stale replies from overwriting fresh results, even when aborting is not available.
Check: How can an older, slower request overwrite newer results, and what stops it?
Section 48 — Testing async code by hand
Testing asynchronous code requires deliberate techniques, because its behaviour depends on timing, networks, and servers that are outside your control. The first technique is to test with the network panel open in developer tools, watching each request as it is sent, its method, its URL, its status, and its reply, so you can confirm that your code asks for what you think it asks for, and that the server replies as you expect. The network panel also lets you throttle the connection to a slow speed, simulating a poor mobile connection, which reveals loading states that are invisible on a fast connection, and race conditions that only appear when replies are delayed. Throttling is a simple setting with a large effect on what you learn about your code, and it should be part of every data feature's testing.
The second technique is to test failure deliberately, not only success. Use the browser's offline mode to simulate no connection, and confirm that your error state appears and your retry button works. Use a wrong URL to simulate a 404, and confirm that your 404 message appears rather than a crash. Use a mock server or a practice endpoint that returns a 500, and confirm that your server-error handling works. Each failure case should be tested as deliberately as the success case, because visitors will encounter failures, and a feature that has only been tested with successful replies is untested in the conditions where it matters most. Logging is the third technique: log the request URL, the status, and the parsed data at each stage during development, so when a test fails you can see which stage produced the wrong result, rather than guessing. Remove or reduce those logs before shipping, but use them freely while learning.
A fourth technique is to test the states, not only the data. Confirm that the loading state appears while fetching, that it disappears on both success and failure, that the empty state appears for an empty list, and that the error state appears on failure and offers a retry. Testing states means watching the page, not only the console, and confirming that what the visitor sees matches the stage the code is in. That visual testing catches bugs that data testing misses, such as a loading indicator that never hides, or an error message that appears in the wrong place. Together, network inspection, throttling, deliberate failure testing, logging, and state testing give you a practical toolkit for verifying asynchronous code by hand, without needing a testing framework, and they are habits worth building before you move on to automated testing in a later chapter.
Testing should also include the case where a request succeeds but returns unexpected data, such as an empty list or a missing field, because a page that only handles the happy path will crash or show a blank when the data shape changes. Deliberately returning an empty list from a mock, or removing a field from a test reply, and confirming that the page shows an empty state or a sensible fallback, is a quick test that catches rendering assumptions before they reach a visitor.
Check: Why should you test a data feature with a throttled connection as well as a fast one?
Section 49 — Common async mistakes
Several mistakes recur in asynchronous code, and recognising them by name will help you spot them in your own work and in others'. The first is forgetting to await a promise, which leaves you holding a promise object where you expected data, and produces undefined properties or a list that never renders. The symptom is data that looks like a promise in the console, or a rendering function that receives an object with no expected fields. The fix is to await the promise, or to attach a .then() handler, at the point where the data is needed. The second is forgetting to check response.ok, which treats a 404 or 500 reply as success, as Section 14 explains, and produces crashes or error objects displayed as data. The fix is the single if statement that checks response.ok after every fetch.
The third mistake is forgetting to catch a rejection, which produces an unhandled rejection in the console and a page that silently fails to update, with no message for the visitor. The fix is a .catch() at the end of every chain, or a try and catch around every awaited call. The fourth is reading a response body twice, which fails because a body can only be read once, as Section 15 explains; the fix is to store the parsed result in a variable and reuse it. The fifth is mixing up JSON text and parsed objects, calling JSON.parse on an object or sending an object without JSON.stringify, as Section 4 explains; the fix is to name variables by their form and to be explicit about parsing and stringifying at each boundary. The sixth is updating the display from a stale request, the race condition of Section 47, whose fix is aborting or tracking the latest request.
A seventh mistake deserves mention because it is subtle: starting a fetch and then immediately using a variable that the fetch is supposed to fill, without waiting for it. That code reads the variable before the fetch has completed, finds it empty, and renders an empty list, even though the fetch later succeeds and fills the variable, because the rendering already happened and nothing re-renders it. The fix is to move the rendering inside the await, or inside the .then() handler, so it runs after the data arrives, not before. All of these mistakes share a root cause: treating an eventual value as if it were immediate. Keeping that distinction in mind, and checking each fetch for await, for response.ok, for catch, for single body reading, and for rendering after arrival, gives you a checklist that catches most async bugs before they reach a visitor.
Another common mistake is starting several fetches and then awaiting them one by one in sequence, when they could have run in parallel with Promise.all. That sequential awaiting wastes time, because each request waits for the previous one even though they do not depend on each other, and the total wait becomes the sum of all requests. Recognising independent requests and grouping them with Promise.all is a simple optimisation that can halve a page's loading time without changing its behaviour.
Check: Name three common async mistakes and the fix for each one.
Section 50 — Putting it together: a data dashboard
A data dashboard brings together everything in this chapter into one feature, and it is a fitting place to end, because it shows how the pieces combine into a page that feels complete. Imagine a dashboard with three sections: a list of users, a list of recent posts, and a search box for posts. On load, the page shows loading states in each section, then fetches the users and the posts in parallel with Promise.all, because they do not depend on each other, checks each response, parses the bodies, and renders each list in its own rendering function. If one fetch fails, the dashboard shows an error in that section alone, using Promise.allSettled thinking for independent sections, so a failure in one does not blank the others. That combination, parallel fetching for independent data, separate rendering per section, and independent error states, is the architecture of most real dashboards, and it is built entirely from techniques in this chapter.
The search box adds the interactive layer. Typing triggers a debounced handler, which waits for a pause, encodes the term, aborts any previous search, fetches the matching posts, and renders them, or shows an empty state when nothing matches, or an error state when the search fails. Pagination, if the post list is long, adds page controls that fetch one page at a time, preserving the search term across pages. A refresh button re-fetches the data, showing loading states again, and a retry button in each error state re-runs that section's fetch. Throughout, the page's states are distinct and visible: loading, success, empty, and error, each in its proper place, each with a clear message and a next step. That state discipline is what makes the dashboard feel reliable, even when the network is not.
Building such a dashboard end to end, even with a practice API, is the practical task below, and it is worth doing slowly, adding one technique at a time and testing each addition, rather than writing the whole thing at once and debugging it afterwards. Start with one fetch and one rendering function, add loading and error states, add a second fetch in parallel, add search with debouncing and aborting, and add pagination last. Each addition is a technique from this chapter, and each one you test as you add it becomes a skill you can reuse. Asynchronous JavaScript can feel abstract when read about, but it becomes concrete when you build a page that fetches, waits, fails, retries, and finally displays data that was not in the page when it loaded. That is the power fetch gives a front-end developer, and it is the power this chapter has taught you to use responsibly.
A dashboard also benefits from a refresh control that is clearly labelled and easy to find, because data changes over time and a visitor may want the latest values without reloading the whole page. A refresh button that re-runs the fetches, shows loading states again, and updates each section independently gives the visitor control over freshness, without the disruption of a full page reload. That control, simple as it is, makes a dashboard feel like a live tool rather than a static snapshot.
Check: List the techniques a data dashboard combines from this chapter.
Practical task
Build a small data dashboard using a public practice API. On load it should fetch a list of posts and a list of users in parallel, show a loading message while fetching, render each list, and show a clear error with a retry button if either fetch fails. Add a search box for posts that debounces input, aborts the previous search, encodes the search term, and shows an empty message when no posts match. Add pagination for the posts list, ten per page, preserving the search term across pages. Test it with a fast connection, a throttled connection, a wrong URL to force a 404, and offline mode to force a network error, and confirm that each state shows correctly.
Chapter questions
- Why does fetch only reject on network errors, and what must you check to catch HTTP errors such as a 404?
- Explain the difference between Promise.all and Promise.allSettled, and when you would use each one.
- Write async code that fetches a post, checks response.ok, parses the body, and shows a loading message while it works and an error message if it fails.
- A search box shows results for an old query after the visitor has typed a new one. What has gone wrong, and how do you fix it?
- Why is CORS a browser rule rather than a server rule, and what must happen for a cross-origin fetch to succeed?