fetch and asynchronous interfaces: clear loading and error states
A complete small client with explicit states, a controlled response contract and actual browser checks of both language editions.

A dynamically loaded list needs more than a successful fetch call. People must understand what is happening, whether a response is usable and which selection they are actually seeing. During rapid changes, an older request can otherwise overwrite a newer state. This guide develops a small learning-offer list with explicit loading, error and cancellation behaviour while identifying the limits that remain on the client side.
Reviewed on 9 November 2026. The example loads invented learning offers through a described API contract. Browser checks use deliberately controlled responses. No existing public booking service is claimed. The code makes read-only requests and replaces neither server-side authorization nor validation.
Define visible states before writing code
The interface offers two views: all learning offers and workshops. Selecting one starts a request. An existing list stays visible until a successful update. A valid empty response becomes an empty result. An error does not become a claim that no offers exist. Deliberate cancellation is different again from a technical failure.
This agreement belongs to the function itself. Without it, contradictory feedback is easy to create: a red message says “failed” while the list has silently disappeared or an earlier success message remains. Specify which information stays visible in each state and which next action is available. The behaviour should be understandable independently of a particular implementation technique.
The eight-second limit is an explicit interface choice for this learning example, not a universal standard for suitable APIs. A production application must adapt waiting and retry behaviour to its actual task. A threshold should have a reason rather than being copied from an unrelated example and treated as a performance guarantee.
Create a small accessible foundation
The view choices are actual buttons. Status has a dedicated region. The list is marked busy while a current request is being handled, allowing existing entries to remain visible. The cancel button is available only while that current operation is active. These elements expose both the action and its changing state.
<section id="angebote" aria-labelledby="angebote-titel">
<h2 id="angebote-titel">Learning offers</h2>
<div>
<button type="button" data-bereich="alle">All offers</button>
<button type="button" data-bereich="workshop">Workshops</button>
<button type="button" id="abbrechen" disabled>Cancel loading</button>
</div>
<p id="ladestatus" role="status">Choose a view.</p>
<ul id="angebotsliste" aria-busy="false"></ul>
</section>
The W3C explanation of status messages addresses changes communicated to assistive technology without moving focus. The selected attributes support that purpose. A DOM check nevertheless is not a real test of every screen-reader configuration. Describe your own evidence at the level actually examined.
The example does not unexpectedly move focus into the results after every update. The person who selected a view can continue operating the controls. For more complex applications, assess whether additional orientation is necessary instead of moving focus categorically whenever a response arrives. The decision should follow how the task is performed.
Describe the API contract first
The client expects application/json and an object containing an offers field. That field holds entries with unique identifiers and titles. Rendering requires neither HTML fragments nor executable commands. The contract is deliberately small so its validation remains understandable. Both the client and the actual endpoint must agree on what constitutes a valid response.
{
"offers": [
{ "id": "html-workshop", "title": "HTML workshop" },
{ "id": "css-praxis", "title": "Practical CSS" }
]
}
These records are invented. Using the client requires an appropriate endpoint at the path shown in the code. The described checks supply controlled responses. This boundary prevents a learning example from being mistaken for a real externally offered service. It also distinguishes verified browser behaviour from claims about a production backend that has not been deployed.
An empty offers array is a valid domain response. A missing field or an HTML sign-in page returned instead of the object is different. Treating these situations alike misrepresents what the client knows. A useful contract defines a genuine empty state instead of making every unexpected response look harmlessly empty.
Separate fetch resolution from HTTP success
The MDN fetch guide explains that an HTTP error status does not automatically reject the promise. A server can respond with 404 or 500 and fetch still provides a Response object. The client must consider that status explicitly.
Response.ok indicates a status from 200 through 299. The example checks it before interpreting domain information. A positive value does not establish that the response contains the expected content. Media-type and structure validation therefore follow as separate steps.
Also distinguish successful transport from a successful domain operation. A read can legitimately return no results. Another API might describe an explicit business rejection. HTTP status is an important part of the agreement without supplying its entire meaning. Decide how the application interprets the actual response contract rather than attaching a success message to every resolved promise.
The complete client for the offer list
Run the script after the HTML shown above. It deliberately assumes that structure exists. Allowed views are fixed in the program and no freely supplied URL is accepted. Each current request has its own controller and a monotonically increasing version identifier. The identifier records which operation remains responsible for the visible result.
const root = document.querySelector('#angebote');
const list = root.querySelector('#angebotsliste');
const status = root.querySelector('#ladestatus');
const cancel = root.querySelector('#abbrechen');
let activeController = null;
let requestVersion = 0;
async function loadOffers(area) {
if (!['alle', 'workshop'].includes(area)) return;
activeController?.abort();
const version = ++requestVersion;
const controller = new AbortController();
activeController = controller;
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, 8000);
list.setAttribute('aria-busy', 'true');
cancel.disabled = false;
status.textContent = 'Loading the view. The previous list remains for now.';
try {
const response = await fetch('/api/angebote?bereich=' + area, {
signal: controller.signal,
headers: { Accept: 'application/json' }
});
if (!response.ok) throw new Error('HTTP ' + response.status);
const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
if (contentType !== 'application/json') throw new Error('Unexpected response format');
const data = await response.json();
if (!Array.isArray(data?.offers) || data.offers.length > 100
|| data.offers.some(offer => !offer || typeof offer.id !== 'string'
|| !offer.id.trim() || offer.id.length > 64 || typeof offer.title !== 'string'
|| !offer.title.trim() || offer.title.length > 140)
|| new Set(data.offers.map(offer => offer.id)).size !== data.offers.length) {
throw new Error('Unexpected data structure');
}
if (version !== requestVersion || controller.signal.aborted) return;
const items = data.offers.map(offer => {
const item = document.createElement('li');
item.textContent = offer.title;
return item;
});
list.replaceChildren(...items);
status.textContent = items.length
? (items.length === 1 ? '1 offer loaded.' : items.length + ' offers loaded.')
: 'No offers are available for this selection.';
} catch (error) {
if (version !== requestVersion) return;
const unchanged = list.children.length
? ' The previous list remains unchanged.'
: ' No list has been loaded.';
status.textContent = controller.signal.aborted
? (timedOut ? 'The request took too long.' : 'Loading cancelled.') + unchanged
: 'The list could not be updated. Please try again.' + unchanged;
} finally {
clearTimeout(timer);
if (version === requestVersion) {
list.setAttribute('aria-busy', 'false');
cancel.disabled = true;
activeController = null;
}
}
}
root.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-bereich]');
if (button && root.contains(button)) loadOffers(button.dataset.bereich);
});
cancel.addEventListener('click', () => activeController?.abort());
window.addEventListener('pagehide', () => activeController?.abort());
A larger application can package this behaviour in a component with an explicit setup and disposal lifecycle. The responsibilities remain the same: active request, visible information and feedback must not be altered inconsistently by unrelated routines. A reusable helper is useful only when ownership of those states stays clear.

Why an older response can be dangerous
Imagine two consecutive choices. First, all offers are requested; shortly afterwards, workshops are requested. The first response may arrive after the second. Without protection, the older selection can reappear even though the person's most recent choice was workshops. The network's completion order is not necessarily the order of user intention.
The client first aborts the previous request and assigns a new version. Before adopting a response, it checks whether that version is still current. Catch and finally also respect the version. An older operation must neither replace newer feedback nor remove the busy state belonging to a newer request. Protecting only the result list would leave those other races unresolved.
This relationship matters more than simply using await. Asynchronous code can read neatly while still applying obsolete results. State which response remains responsible, then test the rule using intentionally different response orders. A successful unhurried first request does not exercise this property at all.
Cancellation does not undo server work
AbortController supports cancelling applicable web requests and response-body consumption. The interface can react to a new choice or an explicit cancel action. Each new operation gets a fresh controller rather than reusing a signal already aborted.
Client-side cancellation does not guarantee that the server stops or reverses work it has begun. This matters particularly for mutations. The example makes read-only GET requests. Payment or booking requires another business contract with deliberately assessed retry behaviour. Do not infer transaction rollback from an aborted fetch.
The additional version comparison provides neither authorization nor transaction management. It protects only the relationship between a response and the currently intended visible result. Preserve that boundary when moving the pattern into a larger application. It should not quietly become evidence that other operational concerns are solved.
Treat the time limit as a separate state
The example marks a timed cancellation separately. Pressing Cancel gets different feedback from exceeding the chosen wait. In both situations, a previous list is not falsely presented as newly loaded. If no information was ever obtained, the message says that too. This keeps feedback aligned with the actual history of the interface.
A JavaScript timer is not a hard real-time guarantee. If the main thread is blocked, the callback can run later. The limit also does not establish whether the server continues working. The function uses it as a rule for its own request handling rather than as a statement about every system involved.
Every timer is cleared on completion. Otherwise an earlier operation might trigger cancellation later. With overlapping requests, cleanup must affect only resources owned by that particular operation. A generic reset that ignores request identity can create a race even when the original result update was guarded correctly.
Validate media type and data structure separately
Media type provides an initial signal. The function normalizes its case and ignores parameters such as character encoding. It then reads JSON. The Response.json() documentation identifies possible errors including syntax failure when the body cannot be parsed as JSON.
Valid JSON is not automatically a valid offer record. The function checks the expected array, basic types, titles, identifiers and duplicate entries. It does not partially adopt an unexpected object. Only after complete validation does it construct the replacement list. This gives the visible update one clear commitment point.
The hundred-item and character limits are rules of this example contract. They do not automatically bound total network transfer because the body is already being read. A real endpoint additionally needs suitable server limits. Structural checks establish neither substantive accuracy nor permission to access the records. Do not describe them as a complete trust assessment.
Render text as text
Titles are assigned through textContent to newly created list items. A supplied title is therefore not interpreted as an HTML instruction. A string containing angle brackets appears as text instead of creating another image or script element. This is the appropriate output form for a simple list of titles.
Do not switch to innerHTML merely for convenience. Applications genuinely needing third-party structured HTML require their own sanitization approach and explicit trust model. That is a different contract. The existence of a valid JSON envelope does not make embedded markup safe to interpret.
Information rendered safely must still be intended for the right recipient. Output safety and access control answer different questions. This public learning example therefore contains no confidential records and claims no account-management capability. A production endpoint must enforce its own data-access rules independently of what the interface displays.
Distinguish errors, empty results and retained information
| Situation | Visible information | Suitable example message |
|---|---|---|
| Valid response with entries | Replacement list | Offers loaded |
| Valid empty response | Empty list | No offers for this selection |
| Failure after an earlier success | Previous list retained | Update failed, list unchanged |
| Failure before any success | No loaded list | No list has been loaded |
| Superseded older request | Current operation stays responsible | No new feedback from the old request |
These distinctions prevent a common misrepresentation: missing data must not become a domain claim that nothing exists without a successful response establishing that. The interface should communicate what it knows. Clear feedback is more helpful than an unexplained blank region or an invented success message.
Do not bypass CORS with an unreadable request
The relative path belongs to the same origin as the page serving the example. Moving it to another domain requires that domain's permissions and other access conditions to agree. The MDN CORS guide explains the browser-enforced origin restrictions.
Changing to no-cors is not a general repair for a client that needs readable JSON. Investigate actual responses and server configuration instead. JavaScript receives limited details for some origin failures. Diagnosis therefore also needs the browser console and network tools. Do not assume every generic fetch failure has the same underlying cause.
Separate diagnosis from public feedback. People using the service need an understandable next action. Internal networking details, credentials or complete confidential responses do not belong automatically in their error message. The example deliberately gives a usable retry instruction rather than copying arbitrary exception text into the interface.
Plan retries around the actual operation
The list allows another deliberate selection as a further attempt. It does not start an endless stream of automatic requests. A bounded retry strategy can be useful for a read, but its conditions and frequency must fit the service. It should not amplify an existing problem through unnecessary extra load.
For mutations, first establish whether the initial operation already took effect. An uncertain payment response cannot be retried indiscriminately like a failed offer lookup. The Fetch standard defines the technical mechanism without supplying a universal business decision for those workflows.
Preserve the distinction as the application grows. An existing loading helper is not sufficient reason to give every operation identical error handling. Shared implementation is useful when it retains the meaningful differences in each contract. Otherwise convenience can conceal consequences that the original read-only example never had.
Account for the interface lifecycle
The example aborts the active request when the page is left. An application with replaceable components must additionally account for component disposal. A request or listener should not continue without ownership after its relevant interface region has been removed. Page-level cleanup does not automatically cover every client-side navigation architecture.
Assign a clear owner for the work. Who starts the request, cancels it after another choice and cleans up when the view disappears? These questions prevent updates aimed at obsolete interfaces. Explicit ownership is easier to investigate than several unrelated callbacks each assuming they control the current state.
A framework can support parts of this lifecycle, but the expected behaviour remains your decision. Test a transition during an active request rather than only an uninterrupted initial load. The most revealing cases often occur between normal states, where different parts of the program briefly overlap.
A useful asynchronous-behaviour test matrix
| Case | Essential expectation |
|---|---|
| Normal and empty responses | Result and feedback agree |
| HTTP failure or invalid JSON | No false success or claimed empty offering |
| Unexpected structure | No partial unchecked adoption |
| Rapid selection change | Old response cannot replace the latest choice |
| Cancellation and timeout | The operation ends understandably |
| Title containing HTML syntax | It is rendered as text |
The article's browser checks execute the embedded language editions using controlled responses. They inspect visible content, feedback and busy state. This is a bounded functional check of the client, not a load test of an actual API or a real investigation of every assistive technology. Match the claim to the evidence actually produced.
Handle an empty HTTP body deliberately
Our contract expects a JSON object even when its offers array is empty. A response without a body therefore does not automatically meet that contract. A successful status with no content can fail during JSON reading. It should not silently become the substantive claim that no offers exist for the selection.
If an actual API expressly permits such an empty response type, add handling for its agreed meaning. Unexpected format and a permitted empty state need not receive the same feedback. Write the contract precisely enough for both sides to assess their response or expectation against it instead of relying on informal assumptions.
Avoid accidentally consuming the body twice
A Response is not simply a string that can always be read afresh. Reading its body is itself asynchronous. Inspecting it as text for diagnosis and then attempting to read JSON can already have changed the body's usable state. Response.json() documents related failure conditions. Consider the ownership of body consumption alongside request ownership.
This function therefore has one clear reading path. Status and media type are inspected through the appropriate properties, then the body is read once as JSON. Additional diagnostic processing must be designed deliberately. Writing complete responses indiscriminately into logs is also unsuitable as a default approach for confidential information.
Assess the endpoint independently of the browser
A client with understandable errors cannot make an incorrect server correct. Verify that the actual API understands the intended views, returns the agreed types and limits data to what is permitted. A fixed browser selection does not stop someone calling the same endpoint directly with other parameters. Enforce server rules where requests are actually accepted.
The handover between development and operations therefore needs an example request, response contract and important error conditions together. This checked client shows how an interface can react. Whether a real installation produces those responses and which operational boundaries apply must be examined in that installation's own environment.
Checklist for the next fetch implementation
- Loading, success, empty, error and cancellation states are described.
- HTTP status and domain response are assessed separately.
- Media type and expected structure are considered.
- Older requests cannot overwrite newer selections.
- Cleanup affects only the responsible request.
- Text is rendered without unnecessary HTML interpretation.
- A time limit is not mistaken for server rollback.
- Retries fit the actual operation.
- Status changes are programmatically identifiable.
- The real endpoint and lifecycle are assessed separately.
Frequently asked asynchronous-list questions
Why is try/catch insufficient? HTTP failures need explicit consideration. A technically resolved response may also have the wrong structure or belong to an obsolete selection. Those questions go beyond merely catching exceptions.
Should the previous list always be cleared? It depends on the task. Here it remains until a successful update and is explicitly described as unchanged after failure. The important requirement is a clear understandable agreement about what the information represents.
Does a read need cancellation? Cancellation helps avoid obsolete work and supports understandable states. The complete relationship between requests and visible results matters most. A controller alone does not define the entire interface behaviour.








