Practice: building a live data page
A page that loads real data and survives everything the network does to it. Same rule as module 7 — state is the truth, the page is a picture of it — now with the data arriving from somewhere else and sometimes not arriving at all.
Build it in order, run it after every step, and type it rather than pasting it.
The data
Save this as orders.json beside your page. In a real application it comes from
a server; a file is enough to build against, and you can break it deliberately.
[
{ "id": 1, "customer": "Priya", "plates": 3, "paid": true, "area": "Kharadi" },
{ "id": 2, "customer": "Arjun", "plates": 0, "paid": false, "area": "Wagholi" },
{ "id": 3, "customer": "Meera", "plates": 5, "paid": true, "area": "Kharadi" }
]
Note Arjun's zero plates — a real value that module 2's truthiness would throw away.
Step 1: the markup
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Today's orders</title>
</head>
<body>
<h1>Today's orders</h1>
<input type="search" id="search" placeholder="Search by customer or area" />
<p id="status" role="status"></p>
<button type="button" id="retry" hidden>Try again</button>
<ul id="orders"></ul>
<p id="summary"></p>
<script src="orders.js" defer></script>
</body>
</html>
role="status" on the status line means a screen reader announces changes to it
— so "Loading orders…" and any error reach somebody who cannot see the spinner.
One attribute, and the page works for more people.
The retry button starts hidden and appears only for failures worth retrying.
Step 2: error types
Module 5's custom errors, so the page can tell failures apart:
class HttpError extends Error {
constructor(status, message) {
super(message);
this.name = 'HttpError';
this.status = status;
}
}
class NetworkError extends Error {
constructor(message) {
super(message);
this.name = 'NetworkError';
}
}
class TimeoutError extends Error {
constructor(message) {
super(message);
this.name = 'TimeoutError';
}
}
Three types because the page will do three different things. If it were only ever going to say "something went wrong", one would do.
Step 3: fetchJson
The whole of the fetch lesson in one function:
async function fetchJson(url, { timeout = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let response;
try {
response = await fetch(url, { signal: controller.signal });
} catch (error) {
if (error.name === 'AbortError') {
throw new TimeoutError('The server took too long.');
}
throw new NetworkError('Could not reach the server.');
} finally {
clearTimeout(timer);
}
if (!response.ok) {
throw new HttpError(response.status, `Request failed with ${response.status}`);
}
try {
return await response.json();
} catch {
throw new HttpError(response.status, 'The server sent something that was not JSON.');
}
}
Five decisions worth naming.
Only the fetch call is inside the first try. Wrapping the whole function
would catch the errors you are throwing yourself and re-label them as network
failures.
finally clears the timer on every path, so a fast response does not leave a
timer running that aborts a later request.
The ok check comes before reading the body, because a 404 usually has an
HTML body and you would get Unexpected token '<' instead of the real problem.
response.json() gets its own try. A 200 with a broken body is a genuine
case — a truncated response, or a proxy that returned a login page.
It throws rather than returning null. The caller cannot then forget to
check.
Step 4: messages and retryability
function messageFor(error) {
if (error instanceof TimeoutError) return 'That took too long. Please try again.';
if (error instanceof NetworkError) return 'Could not reach the server. Check your connection.';
if (error instanceof HttpError) {
if (error.status === 404) return 'We could not find today’s orders.';
if (error.status >= 500) return 'The server is having trouble. Try again shortly.';
return 'Something was wrong with that request.';
}
return 'Something went wrong. Please try again.';
}
function isRetryable(error) {
return (
error instanceof NetworkError ||
error instanceof TimeoutError ||
(error instanceof HttpError && error.status >= 500)
);
}
A 404 is not retryable. Offering "Try again" for something that will fail identically forever wastes the user's data and their patience. A network blip or a 500 might genuinely work on the second go.
The final return in messageFor catches the error you did not predict.
Step 5: state and render
const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' });
const RATE = 80;
let orders = [];
let query = '';
const statusEl = document.querySelector('#status');
const listEl = document.querySelector('#orders');
const summaryEl = document.querySelector('#summary');
const retryEl = document.querySelector('#retry');
const searchEl = document.querySelector('#search');
function visibleOrders() {
const q = query.trim().toLowerCase();
if (q === '') return orders;
return orders.filter(
(order) =>
order.customer.toLowerCase().includes(q) ||
order.area.toLowerCase().includes(q),
);
}
function render() {
listEl.textContent = '';
for (const order of visibleOrders()) {
const item = document.createElement('li');
item.dataset.id = order.id;
const name = document.createElement('span');
name.textContent = order.customer;
const detail = document.createElement('span');
detail.textContent =
` — ${order.area} — ${order.plates} plates — ${inr.format(order.plates * RATE)}`;
item.append(name, detail);
listEl.append(item);
}
if (orders.length === 0) {
summaryEl.textContent = '';
return;
}
const revenue = orders.reduce((total, order) => total + order.plates * RATE, 0);
summaryEl.textContent =
`${visibleOrders().length} of ${orders.length} shown · ${inr.format(revenue)} total`;
}
searchEl.addEventListener('input', () => {
query = searchEl.value;
render();
});
textContent, never innerHTML — the data came from a server, and module 7
established that a server is exactly as untrusted as a form.
The revenue total is over orders, not visibleOrders(), so filtering the view
does not change the day's takings. Deciding which number a summary refers to is a
real product decision, not an implementation detail.
Searching re-renders from state rather than hiding rows — same rule as module 7's filters.
Step 6: load, with all four states
async function load(url = 'orders.json') {
statusEl.textContent = 'Loading orders…';
retryEl.hidden = true;
listEl.textContent = '';
summaryEl.textContent = '';
try {
const data = await fetchJson(url);
orders = Array.isArray(data) ? data : [];
statusEl.textContent = orders.length === 0 ? 'No orders today.' : '';
render();
} catch (error) {
console.error(error);
orders = [];
statusEl.textContent = messageFor(error);
retryEl.hidden = !isRetryable(error);
render();
}
}
retryEl.addEventListener('click', () => {
load().catch(console.error);
});
load().catch(console.error);
Four things, each of which is a bug if you leave it out.
The list is cleared at the start. Otherwise a failed second load leaves yesterday's rows on screen under an error message — stale data that looks current, which is worse than either alone.
orders = [] in the catch keeps the state honest. The page says the data
could not be loaded, so the state must not still hold it.
Array.isArray(data) guards against a response that parsed fine and is the
wrong shape.
console.error(error) logs the real error for you while the user sees the
friendly one. Module 5's rule: log the technical detail, show something human.
And the empty case is not an error. orders.length === 0 gets "No orders
today." on the success path, with no retry button. if (orders.length) would
have conflated an empty day with a failure.
Step 7: break it on purpose
The whole reason for steps 2 to 4. Run each of these and check the page says something useful.
| Do this | Expect |
|---|---|
Point load at a file that does not exist |
"We could not find today's orders." No retry button |
| Point it at an HTML file | "Something was wrong with that request." |
Point it at http://localhost:9/nope |
"Could not reach the server." Retry button shows |
Replace orders.json with [] |
"No orders today." No error, no retry |
Corrupt orders.json to {not json |
A message, not a blank page |
| Throttle to Slow 3G in devtools | The loading state, visible for once |
Verified on this exact code: the 404 gives the "could not find" message with the retry button hidden, the network failure gives "Could not reach the server" with the button shown, and the empty file gives "No orders today." with neither an error nor a retry.
Load good data, then make the second load fail. The list must be empty, not three stale rows under a red message.
Step 8: the summary
With the real data — 3 + 0 + 5 plates at ₹80 — the page reads:
3 of 3 shown · ₹640.00 total
Search for kharadi and it becomes 2 of 3 shown, with the total unchanged at
₹640.00, because revenue is the day's, not the filter's.
Note Arjun's row shows 0 plates — ₹0.00 rather than being skipped. Module 2's
falsy zero, handled correctly by not testing truthiness anywhere.
What is deliberately missing
No debounce on the search. It filters data already in memory, so it is fine. The moment search hits the server, you need to wait for a pause in typing and abort the previous request — otherwise an early response can arrive late and overwrite a newer one.
No caching. Every load is a fresh request.
No pagination. Three orders. Three thousand would need the server to send a page at a time.
It re-renders everything. Module 7's honest limitation, unchanged.
Check your work
fetchJson wraps only the fetch call in its first try, or it would
catch and mislabel the errors it throws itself.
finally clears the timeout on every path, so a fast response does not leave
a timer that aborts a later request.
response.ok is checked before reading the body, or a 404's HTML body gives
Unexpected token '<' instead of the real problem.
A 404 is not retryable; a network failure and a 5xx are. The retry button appears only for the second kind.
The list and summary are cleared at the start of load, so a failed second
load does not leave stale rows under an error.
orders = [] in the catch keeps state and screen in agreement.
An empty array is a success state — "No orders today.", no retry button.
textContent throughout, because a server response is untrusted data.
The revenue total is over orders, the count over visibleOrders() — with
the sample data, 3 of 3 shown · ₹640.00 total, becoming 2 of 3 shown when
filtered to Kharadi with the total unchanged.
Arjun's zero-plate order renders rather than being skipped, because nothing tests the plate count for truthiness.
Practice
- Build it step by step, running after each.
- Work through every row of the step 7 table. This is the lesson. A page that only works when the network does is not finished.
- Remove the
response.okcheck and produceUnexpected token '<'yourself from the 404. Put it back. - Remove the line clearing the list at the start of
load, then make a second load fail, and look at the stale rows under the error message. - Change
orders.length === 0toif (orders.length)and watch an empty day report as a failure. - Set the timeout to 50ms and confirm you get the timeout message and a retry button.
- Add a "last updated" time using module 6's
toLocaleTimeString('en-IN'), set on every successful load. - Add a filter for unpaid orders only. Keep it in state, not in the DOM.
- Make the search also match the plate count, and decide what should happen
when somebody searches
0. - Load two endpoints in parallel with
Promise.all— orders and a delivery area list — and render both. Then make one fail and switch toallSettledso the other still renders. - Harder. Add
withRetryfrom the error-handling lesson so retryable failures are retried twice automatically with a backoff before the button ever appears, and show "Retrying…" in the status line while it happens. Then confirm a 404 is still not retried — and count the requests in the Network panel to prove it rather than trusting the code.
That is module eight, and the hardest idea in the language is behind you. You
know why JavaScript cannot wait, what a promise is, what await does to one, and
what a network actually does to a page — which is fail in three different ways
that need three different answers.
The habit worth keeping: write the failure states first. They are the ones you cannot test by looking at the happy path, and they are what the user gets on a train.
Next module: modules and tooling — splitting this file up, npm, and debugging
properly with breakpoints instead of console.log.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship