Chapter 7
Async behavior and APIs
How to design an interface around waiting with promises, async/await, fetch, request state, retry, debouncing, and AbortController, ending in a support ticket dashboard.
Someone opens a support dashboard and clicks “Load tickets.” For a second or two, nothing comes back. If the page shows nothing during that time, they click again, wonder whether the first click worked, and maybe reload. If the request fails and the page still shows nothing, they assume there are no tickets. Every one of those reactions comes from an interface that doesn’t say what it’s doing.
This chapter is about designing an interface around waiting. The interface you want looks like this: the button says it’s working, the page says “Loading tickets…”, and then it says either “12 tickets” with the list, “No tickets yet” with an empty state, or “Couldn’t load tickets” with a way to try again. The JavaScript for each step is short. The work is in deciding what the interface says at each moment and making sure it’s true.
The mental model
Until now, every event in this book has been handled in one go. A click changes state, and the render function updates the page before the browser paints again. A request to a server doesn’t work that way. You start it, your code keeps running, and the answer arrives later, possibly much later, possibly never.
That adds a new piece of state to remember, which is the answer to the first of the four questions: where is this request right now? A request is always in one of a few states:
| State | What the interface shows |
|---|---|
idle | Nothing has been asked for yet. |
loading | A request is in flight. Show that something is happening and prevent duplicate requests. |
success | The data arrived. Show it, or show an empty state if the list is empty. |
error | The request failed. Say so in plain words and offer a retry. |
Chapter 3 introduced this idea. Here it becomes the center of the code. You keep the request status in state next to the data, and the render function decides what to show from the status. The events that change it (question 2) are no longer only clicks and key presses. A response arriving is an event, and so is a request failing.
Promises and async/await
When you call fetch, you don’t get the data back. You get a promise, which is an object that stands for a value that isn’t ready yet. A promise starts out pending, and later it either fulfills with a value or rejects with an error. It only settles once.
You can wait for a promise with await, which works inside a function marked async:
async function loadGreeting() {
const response = await fetch('greeting.json');
const data = await response.json();
console.log(data);
}
await pauses this function until the promise settles, and the rest of the page keeps working in the meantime. Clicks still register, other code still runs, and the browser still paints. That’s why you need request state at all: the user can do things while the function is paused, including clicking the same button again.
An async function always returns a promise itself, so the code that calls it can await it too. When an awaited promise rejects, await throws the error, and you catch it with try/catch exactly as you would any other error:
try {
await loadGreeting();
} catch (error) {
console.error('Loading failed:', error.message);
}
You’ll also see promises handled with .then() and .catch() in older code and in a lot of documentation. Both styles do the same thing. This book uses async/await because it reads top to bottom like the rest of your code.
fetch, JSON, and HTTP status
fetch(url) sends an HTTP request and returns a promise for a Response. The response arrives as soon as the headers do. Reading the body is a second step, and response.json() returns another promise that parses the body as JSON into ordinary arrays and objects.
The detail that catches nearly everyone is that fetch only rejects when the request couldn’t be completed at all: the network is down, the domain doesn’t resolve, the browser blocked it, or you aborted it. A 404 or a 500 is a completed request. The server answered, just not with what you wanted, so the promise fulfills.
You check for that yourself with response.ok, which is true for status codes 200 to 299:
const response = await fetch('tickets.json');
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const tickets = await response.json();
Throwing here turns an HTTP error into a rejected promise, so one catch block handles both kinds of failure. Without the check, your code goes on to parse an error page as JSON, and the user sees a confusing message or nothing at all. The debugging challenge later in this chapter shows exactly what that looks like.
A fake network you can control
A local JSON file loads almost instantly, which hides every problem this chapter is about. To design for waiting, you need waiting, and to design for failure, you need failure. This helper adds both:
// fake-api.js
export const settings = { minDelay: 300, maxDelay: 1500, failureRate: 0.2 };
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
export async function simulateNetwork(signal) {
const delay = settings.minDelay + Math.random() * (settings.maxDelay - settings.minDelay);
await wait(delay, signal);
if (Math.random() < settings.failureRate) {
throw new Error('The server did not respond.');
}
}
wait builds a promise by hand with new Promise, which you rarely need to do in interface code. It’s here so the delay can be canceled, which matters later. Every call to simulateNetwork waits a random time and fails about one time in five. Set failureRate to 1 when you’re working on the error state and 0 when you’re not. The browser devtools can also slow down real requests: the Network panel has a throttling menu with slow connection presets and an “Offline” option, which makes fetch reject for real.
Debouncing a search
The dashboard needs a search field that filters tickets on the server as someone types. Sending a request on every input event would send seven requests for the word “invoice,” six of which nobody needs. Debouncing waits until typing pauses before doing the work:
function debounce(callback, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => callback(...args), delay);
};
}
const debouncedSearch = debounce(searchTickets, 300);
searchInput.addEventListener('input', () => debouncedSearch(searchInput.value));
debounce takes a function and returns a new one. Each call cancels the previous timer and starts a new one, so searchTickets only runs once the user has stopped typing for 300 milliseconds. ...args collects whatever arguments the new function receives and passes them along unchanged. Pick the delay by trying it: too short and you still send extra requests, and too long and the search feels unresponsive.
Race conditions and stale responses
Debouncing reduces requests, but it doesn’t prevent this sequence. Someone types “pass” and pauses, so a request goes out. They keep typing “password” and pause again, so a second request goes out. With a random delay, the second request can come back first, and the page shows results for “password.” Then the first response arrives and overwrites them with results for “pass.” The search field says one thing and the list shows another.
That’s a race condition, and network responses don’t arrive in the order you sent them. You have two ways to handle it.
The first is to cancel the old request with an AbortController. A controller has a signal you pass to fetch, and calling controller.abort() cancels any request using that signal. The canceled fetch rejects with an error named AbortError, which you catch and ignore, because nothing went wrong:
let controller = null;
async function searchTickets(query) {
controller?.abort();
controller = new AbortController();
const { signal } = controller;
state.query = query;
state.status = 'loading';
render();
try {
state.tickets = await getTickets({ query, signal });
state.status = 'success';
} catch (error) {
if (error.name === 'AbortError') return;
state.error = error;
state.status = 'error';
}
render();
}
Each search aborts the one before it, so only the newest request can ever update the page. controller?.abort() uses optional chaining, which skips the call when controller is still null on the first search. Aborting also stops the browser downloading a response nobody will see. The fake network helper accepts the signal too, which is why wait listens for abort.
The second way is to ignore stale responses. Give each request a number, remember the latest one, and drop any response that isn’t from it:
let latestRequest = 0;
async function searchTickets(query) {
const requestId = ++latestRequest;
const tickets = await getTickets({ query });
if (requestId !== latestRequest) return;
state.tickets = tickets;
render();
}
Use this when the work can’t be canceled, such as a third-party function that doesn’t accept a signal. The request still finishes, and its response is thrown away.
Telling the truth while saving
Request state applies to sending data as well as loading it. A save status indicator next to a reply box reads “Saving…”, then “Saved”, or “Couldn’t save. Your reply is still here.” That last message tells the user their typing wasn’t lost, which is what they need to know before they decide to retry.
Optimistic UI goes one step further. You show the reply in the thread immediately, marked as “Sending,” and remove or flag it if the request fails. It makes an interface feel fast, but it means writing the failure path carefully, because you’ve already shown something that might not be true. Use it for low-stakes actions like favoriting, and save it for later until request state feels routine. A “Load more” button follows the same rules as everything else here: it has its own loading and error state, and a failure on page three shouldn’t erase pages one and two.
The smallest working version
This version loads a list of tickets and shows every request state. It needs four files in one folder, served locally: fake-api.js from above, and the three below. Save this one as tickets.json:
[
{ "id": "t-101", "subject": "Can't reset my password", "status": "open", "customer": "Priya N." },
{ "id": "t-102", "subject": "Invoice shows the wrong address", "status": "pending", "customer": "Marcus L." },
{ "id": "t-103", "subject": "Export to CSV times out", "status": "open", "customer": "Dana K." },
{ "id": "t-104", "subject": "Thanks for the quick fix", "status": "closed", "customer": "Sam O." }
]
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Support tickets</title>
<script type="module" src="app.js"></script>
</head>
<body>
<main>
<h1>Support tickets</h1>
<button type="button" data-load>Load tickets</button>
<p role="status" data-status></p>
<ul data-ticket-list></ul>
</main>
</body>
</html>
// app.js
import { simulateNetwork } from './fake-api.js';
const state = { status: 'idle', tickets: [], error: null };
const loadButton = document.querySelector('[data-load]');
const statusText = document.querySelector('[data-status]');
const list = document.querySelector('[data-ticket-list]');
async function getTickets() {
await simulateNetwork();
const response = await fetch('tickets.json');
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}.`);
}
return response.json();
}
async function loadTickets() {
if (state.status === 'loading') return;
state.status = 'loading';
state.error = null;
render();
try {
state.tickets = await getTickets();
state.status = 'success';
} catch (error) {
state.error = error;
state.status = 'error';
}
render();
}
function getStatusMessage() {
if (state.status === 'loading') return 'Loading tickets…';
if (state.status === 'error') return `Couldn't load tickets. ${state.error.message}`;
if (state.status === 'success' && state.tickets.length === 0) return 'No tickets yet.';
if (state.status === 'success') {
return state.tickets.length === 1 ? '1 ticket loaded.' : `${state.tickets.length} tickets loaded.`;
}
return '';
}
function render() {
const isLoading = state.status === 'loading';
loadButton.setAttribute('aria-disabled', String(isLoading));
loadButton.textContent = state.status === 'error' ? 'Try again' : 'Load tickets';
statusText.textContent = getStatusMessage();
const items = state.tickets.map((ticket) => {
const item = document.createElement('li');
item.textContent = `${ticket.subject} (${ticket.status})`;
return item;
});
list.replaceChildren(...items);
}
loadButton.addEventListener('click', loadTickets);
render();
Click the button a few times with failureRate at 0.2 and you’ll see all four states. Three decisions in this code need explaining.
The guard clause at the top of loadTickets stops a second request while the first is still running. The button uses aria-disabled instead of the disabled property, because disabling a button that has focus drops focus to the page body, so a keyboard or screen reader user loses their place. aria-disabled tells assistive technology the button is unavailable, and the guard clause makes that true. Style it with [aria-disabled="true"] in CSS so it looks unavailable as well.
The retry is the same function. In the error state the button says “Try again”, and clicking it calls loadTickets again. You don’t need a separate retry path, because the request state already describes what to do next. Don’t retry automatically in a loop. If the server is down, repeated retries make the problem worse and keep the user waiting with no way to stop.
An empty list is a success. “No tickets yet” and “Couldn’t load tickets” can look the same if you only check whether the array is empty, and treating a failure as “nothing here” tells people there’s no data when there may be plenty.
Accessibility check
Load the tickets with a keyboard and a screen reader running (VoiceOver on macOS, NVDA on Windows). Tab to the button, press Enter, and listen.
The button is reachable and announces its name. With aria-disabled="true", screen readers announce it as dimmed or unavailable while loading, and pressing Enter again does nothing.
The status paragraph has role="status", which makes it a polite live region. When its text changes, screen readers announce the new text after they finish what they’re saying, without moving focus. You should hear “Loading tickets…” and then “4 tickets loaded.”
Here’s what commonly fails. If you create the status element at the moment you need it (main.append(statusParagraph) with the text already set), screen readers usually say nothing. A live region has to be in the page before its content changes, because assistive technology starts watching it when it appears and only announces changes after that. The fix is the one in the starter HTML: put the empty <p role="status"> in the markup from the start and only change its textContent.
The second common failure is moving focus to the list when it loads. It feels helpful, but it interrupts someone who may have moved on to the search field, and the status message already told them the list is ready. Leave focus where the user put it, and announce the change instead.
Debugging challenge: the 404 that looked like a crash
Break the code on purpose. In getTickets, delete the if (!response.ok) block, set failureRate to 0, and change the URL to 'ticket.json' (singular). Reload and click the button.
The symptom is an error message that doesn’t help. The status reads “Couldn’t load tickets.” followed by something like Unexpected token '<' or JSON.parse: unexpected character. The exact wording depends on the browser and on what your local server sends for a missing file.
Work through it from the outside in:
- The message mentions parsing, so the failure happened in
response.json(), afterfetchsucceeded. That already tells you the network request completed. - Open the Network panel and click the button again. The
ticket.jsonrow shows a 404 status in red. - Click the row and open the Response tab. The server sent an HTML “not found” page. The
<in the error message is the first character of that HTML.
So fetch fulfilled with a 404 response, nothing checked the status, and response.json() tried to parse HTML. Put the response.ok check back, and the message becomes “Request failed with status 404,” which points straight at the cause. Then fix the URL. The status check makes the next failure of this kind quick to diagnose.
This works because…
This works because the interface keeps the request’s status in state next to the data, and the render function reads that status every time something changes. A request starts by setting the status to loading and rendering, so the user immediately sees that something is happening. When the promise settles, the code sets success or error and renders again. fetch only rejects for network failures, so the code checks response.ok and throws for HTTP errors, which sends every failure through the same catch. The guard clause and the abort controller make sure only one request at a time can change the page, so what the screen says always matches the last thing the user asked for.
Project: support ticket dashboard
Build a dashboard where a support agent can load tickets, search them, filter by status, open a ticket’s details, and send a reply. Use tickets.json and fake-api.js from this chapter. Add a body field and a replies array to each ticket in the JSON so the detail view has something to show.
Starter HTML
<!-- index.html (inside <body>) -->
<main class="dashboard">
<h1>Support tickets</h1>
<form class="filters" data-filters>
<label for="search">Search tickets</label>
<input id="search" type="search" name="query" autocomplete="off" data-search>
<label for="status-filter">Status</label>
<select id="status-filter" name="status" data-status-filter>
<option value="all">All</option>
<option value="open">Open</option>
<option value="pending">Pending</option>
<option value="closed">Closed</option>
</select>
</form>
<p role="status" data-list-status></p>
<button type="button" data-retry hidden>Try again</button>
<ul class="ticket-list" data-ticket-list></ul>
<section class="ticket-detail" aria-labelledby="detail-heading" data-detail hidden>
<h2 id="detail-heading" tabindex="-1" data-detail-heading></h2>
<p data-detail-body></p>
<ul data-detail-replies></ul>
<form data-reply-form novalidate>
<label for="reply">Your reply</label>
<textarea id="reply" name="reply" rows="4" required></textarea>
<button type="submit">Send reply</button>
<p role="status" data-reply-status></p>
</form>
</section>
</main>
Key JavaScript
Keep one state object with a separate request state for each thing that loads:
// app.js
const state = {
query: '',
statusFilter: 'all',
list: { status: 'idle', tickets: [], error: null },
selectedId: null,
detail: { status: 'idle', ticket: null, error: null },
reply: { status: 'idle', error: null },
};
Put the network calls in their own module so the rest of the app only deals with data:
// api.js
import { simulateNetwork } from './fake-api.js';
async function fetchAllTickets(signal) {
await simulateNetwork(signal);
const response = await fetch('tickets.json', { signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}.`);
}
return response.json();
}
export async function getTickets({ query = '', status = 'all', signal } = {}) {
const tickets = await fetchAllTickets(signal);
const search = query.trim().toLowerCase();
return tickets.filter((ticket) => {
const matchesStatus = status === 'all' || ticket.status === status;
const matchesQuery = ticket.subject.toLowerCase().includes(search);
return matchesStatus && matchesQuery;
});
}
export async function getTicket(id, { signal } = {}) {
const tickets = await fetchAllTickets(signal);
const ticket = tickets.find((item) => item.id === id);
if (!ticket) throw new Error('That ticket no longer exists.');
return ticket;
}
export async function sendReply(ticketId, message) {
await simulateNetwork();
return { id: crypto.randomUUID(), ticketId, message, sentAt: new Date().toISOString() };
}
The list search is the abortable searchTickets from earlier, changed to read the query and the status filter from state. The element lookups are left out here, and { ...state.list, status: 'loading' } copies the current list state and changes one field, so the old tickets stay visible while new ones load:
// app.js (continued)
let listController = null;
async function searchTickets() {
listController?.abort();
listController = new AbortController();
const { signal } = listController;
state.list = { ...state.list, status: 'loading', error: null };
renderList();
try {
const tickets = await getTickets({ query: state.query, status: state.statusFilter, signal });
state.list = { status: 'success', tickets, error: null };
} catch (error) {
if (error.name === 'AbortError') return;
state.list = { ...state.list, status: 'error', error };
}
renderList();
}
const debouncedSearch = debounce(searchTickets, 300);
searchInput.addEventListener('input', () => {
state.query = searchInput.value;
debouncedSearch();
});
statusFilter.addEventListener('change', () => {
state.statusFilter = statusFilter.value;
searchTickets();
});
retryButton.addEventListener('click', searchTickets);
filtersForm.addEventListener('submit', (event) => event.preventDefault());
searchTickets();
Typing waits for a pause, and changing the select searches immediately, because a select change is already a finished decision. Both paths abort whatever request came before.
Sending a reply keeps the text until the server confirms it, and blocks double submits with the request state:
// app.js (continued)
replyForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (state.reply.status === 'sending') return;
const message = replyForm.elements.reply.value.trim();
if (!message) return;
state.reply = { status: 'sending', error: null };
renderReply();
const ticket = state.detail.ticket;
try {
const reply = await sendReply(ticket.id, message);
ticket.replies.push(reply);
state.reply = { status: 'sent', error: null };
replyForm.reset();
} catch (error) {
state.reply = { status: 'error', error };
}
renderDetail();
renderReply();
});
renderReply sets the reply status text to “Sending…”, “Reply sent.”, or “Couldn’t send your reply. Your text is still in the box.” Opening a ticket sets selectedId, loads it with getTicket (aborting any earlier detail request, so clicking two tickets quickly can’t show the wrong one), and moves focus to the detail heading once it has loaded. Moving focus is right here, because the user asked to go somewhere new on the page. The heading has tabindex="-1" so it can receive focus from script without joining the Tab order.
Done means
- The list shows distinct loading, success, empty (“No tickets match ‘xyz’”), and error states, and you’ve seen each one by changing
failureRate. - Every
fetchchecksresponse.ok, and a misspelled URL produces a message that names the status code. - Search is debounced, and typing quickly never leaves results that don’t match the search field (test with
minDelay: 0andmaxDelay: 3000). - The error state offers “Try again,” which reruns the last search, and nothing retries automatically.
- Clicking two tickets quickly always shows the one clicked last.
- Sending a reply can’t be triggered twice, and a failed send keeps the text in the box.
- Status changes are announced through live regions that exist in the HTML from the start, and focus only moves when the user opens a ticket.
- Everything works with the keyboard alone, and the “Offline” throttling option produces the error state.
- You’ve written a short “This works because…” paragraph explaining how the dashboard prevents stale results from replacing newer ones.
In the next chapter, you’ll use browser APIs that watch the page for you: observers for visibility and size, storage and the URL for remembering state, and native elements for dialogs and popovers.
Found an error? Report it.