Old-School Front-End / 2026 ・ No.10

Old-School Front-End

Part 10: asynchronous requests, talking to the server with fetch without leaving the page

Published

The Bootstrap claim form. Three line items: Narita Express ¥3070, Hotel, 1 night $189.50, and Conference fee $100. The total is ¥46,851, with Dollars are converted at $1 = ¥151.23 (rate for 2026-10-17) under it. At the bottom, a green bar: Your claim was received. Its number is EXP-2026-0012.
This part's goal: get the rate from the server, convert dollars to yen, send the claim and get a claim number back. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)

An "old-school" front-end course: no AI doing it for you, every line typed by hand. Part 10 is about asynchronous requests.
Since part 8 we've been saying "dollar conversion comes in part 10", and in part 9 "sending comes in part 10".
Today we settle both: with fetch we get an exchange rate from the server, send the claim and receive a claim number.
Requests keep you waiting and sometimes fail, and handling that is what this part is really about.

1

This part's goal

AraiFurui, when I press Submit it says "Sending comes in part 10". And here we are, part 10!

FuruiYes. We've also been writing "part 10" next to the dollar conversion for a while. Today we pay that off.

AraiI asked the AI to "submit the form", and the moment it sent, the screen went blank. When there was an error, everything I'd typed was gone…

FuruiThat's the classic way, where the whole page changes. Today the page stays put and we talk to the server behind the scenes.

You are here: part 10

1 Setup 2 HTML 3 Forms 4 CSS basics 5 Layout 6 Bootstrap 7 JS basics 8 DOM, events 9 Validation 10 Fetch 11 Sign-in 12 Approvals 13 Charts, print 14 jQuery 15 Finish You are here Setup HTML CSS JavaScript Real app Finish
  • Fetch the rate — when the page opens, ask the server how many yen to the dollar, and convert the dollar rows into the total
  • Send the claim — Submit sends everything, receipt included, and shows the claim number that comes back
  • Never send twice — the buttons can't be pressed while sending
  • Lose nothing on failure — server broken or connection gone, whatever was typed stays where it is

The server on the other end is a small practice one made for this part (about 60 lines of Node.js; it's not the topic, so knowing where to send things and what comes back is enough). The rate of 151.23 is a made-up value that this server returns.

2

Waiting, or not

A server's reply doesn't arrive instantly. With today's practice server it takes about 0.6 seconds; with a distant or busy server it can take several. If the page froze the whole time, that would be a problem.

On hold on the phone, or hand over an order slip and sit down

Wait (imagined)  on hold on the phone page frozen until the reply A C B Don't wait (fetch) order slip, back to seat you can still type and scroll A B C 0 ms 200 ms 400 ms 600 ms 800 ms A: before fetch B: the next line C: the reply (in then) fetch asks, then moves on (measured: B 0 ms, C 605 ms) So anything that needs the reply goes inside then or after await

JavaScript's fetch is the second kind. It sends the request, moves straight on to the next line, and runs "the rest" when the reply arrives. I checked that with three lines.

console.log('A');
fetch('/api/rates').then(() => console.log('C'));
console.log('B');

# The order things ran in, and the time since A (measured in Chromium)
A: before calling fetch    0 ms
B: the line after fetch    0 ms
C: the reply arrived (inside then)  605 ms

Written A, C, B, they ran A, B, C. What fetch hands back at once is not the reply but a ticket saying "I'll give you the reply when it comes" (a Promise). You write the rest inside then, or inside a function marked async, using await to say "wait for the ticket here". The code in this part uses the easier-to-read await.

AraiSo "asynchronous" basically means "doesn't wait".

FuruiRight. The page keeps working while you wait. The catch: put the code that uses the reply in the wrong place, and it uses a value that's still empty.

3

Requests and replies: HTTP and JSON

The browser and the server talking

browser server GET /api/rates 200 OK {"rate": 151.23, …} POST /api/expenses body: FormData (multipart) 201 Created {"id": "EXP-2026-0012"} rate, please a claim answers in JSON returns a number Request = method + address (+ body). Response = status code + body JSON is the usual body format; res.json() turns it back into an object

A request is a method plus an address. Use GET to just receive something and POST to hand something over. Every response carries a three-digit status code saying how it went.

CodeMeaningWhen we return it here
200OKreturning the rate
201Createdclaim accepted, number returned
400Bad requestthe server's checks failed
404No such addressa typo in the URL
500The server broke(the server's fault, not the user's)

The body of a reply is usually JSON: text written much like a JavaScript object. The practice server returns the rate like this.

{"base":"USD","quote":"JPY","rate":151.23,"date":"2026-10-17"}

What arrives is still text, so res.json() turns it back into an object before we use it as data.rate.

4

Fetching the exchange rate

Here's loadRate, added to form.js. It's called once, at the end of the page's script.

/** Fetch the exchange rate from the server */
async function loadRate() {
  rateNote.textContent = 'Loading the exchange rate…';
  try {
    const res = await fetch('/api/rates', { signal: AbortSignal.timeout(10000) }); // give up after 10 seconds
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404 and 500 are not errors as far as fetch is concerned
    const data = await res.json();                       // turn the JSON body into an object
    rate = data.rate;
    rateNote.textContent = `Dollars are converted at $1 = ¥${rate} (rate for ${data.date})`;
  } catch (error) {
    rateNote.textContent = "Couldn't load the exchange rate. Dollar rows are not in the total.";
  }
  updateTotal();
}
  • async and await — await means "carry on with this function once the ticket becomes a reply". Only this function waits; the page doesn't stop
  • null to begin with — until the rate arrives, rate is null. Dollar rows stay out of the total meanwhile, and the page says it's loading
  • The caller doesn't wait — the final loadRate(); has no await, so the first row is set up without waiting for the reply
  • Decide on a timeout — fetch has none of its own. AbortSignal.timeout(10000) says "give up after 10 seconds"
MEASURED

Open form.html by double-clicking it, as in earlier parts (the address starts with file://), and the rate never arrives. Chromium's console says "Cross origin requests are only supported for protocol schemes: … http, https …" and the page shows "Couldn't load the exchange rate". When trying fetch, the rule is: run a server and open the page over http://.

5

What fetch doesn't call an error

The code above has a line if (!res.ok) throw …. To see why, I pointed fetch at all sorts of targets.

# What fetch gives you (measured in Chromium)
target throws? what you get
an existing URL (/api/rates) no ok: true / 200
a missing URL (/api/nothing) no ok: false / 404   <- treated as success unless you check ok
a server fault (500) no ok: false / 500   <- treated as success unless you check ok
the server is not running yes TypeError: Failed to fetch
a slow reply (1 s timeout) yes TimeoutError: signal timed out

A missing address (404) and a broken server (500) are both, as far as fetch is concerned, "a reply arrived", so a success. It only throws (jumping to catch) when it couldn't reach the server or ran out of time. So check res.ok yourself (true for status codes in the 200s), and throw to hand the rest to catch.

The ways a fetch can end

fetch(…) ask reached? unreachable, timeout catch couldn't send got a reply res.ok ? true 2xx success false 400 ask for fixes 404・500 server trouble throw, so catch runs fetch only throws when no reply came (measured: 404 and 500 don't) So check res.ok, and throw yourself when it's false

AraiThe fetch the AI wrote never looked at res.ok…

FuruiIf you only try it on good days, you'll never notice. The day the server goes down always comes.

6

Dollars to yen: money in whole numbers

With the rate in hand, we convert. Dollars have cents, like the $189.50 hotel. It's tempting to just write dollars × rate, but try it…

# Converting $100.00 at $1 = ¥151.23 (measured in Chromium)
100 * 151.23 → 15122.999999999998   // multiply the decimals
Math.floor(100 * 151.23) → 15122   <- one yen short
centsToYen(10000, 151.23) → 15123   // with whole cents and hundredths of a yen (centsToYen)

A hundred dollars came out one yen short. Just like part 7's 0.1 + 0.2, a computer can't hold a decimal like 151.23 exactly. So we turn dollars into cents and the rate into whole hundredths of a yen before multiplying.

Whole numbers first, multiply, divide once at the end

Dollars to yen: whole numbers first, then multiply $189.50 dollars 18950 cents 151.23 yen per dollar 15123 1/100 yen 18950 × 15123 ÷ 10000 → 28658 Multiplying the decimals (measured) 100 * 151.23 → 15122.999999999998 Math.floor(…) → 15122 <- one yen short The same trap as 0.1 + 0.2 in part 7. Count money in whole numbers
// ---- Part 10: converting dollars ----

/**
 * Turn the text in a dollar field into whole cents. '189.5' -> 18950
 * Returns null for an empty field, NaN if it can't be read (same deal as toAmount)
 */
function toCents(text) {
  const s = String(text).normalize('NFKC').replaceAll(',', '').trim();
  if (s === '') return null;
  if (!/^[0-9]+(\.[0-9]{1,2})?$/.test(s)) return NaN; // at most two decimal places
  const [dollars, cents = ''] = s.split('.');
  return Number(dollars) * 100 + Number(cents.padEnd(2, '0')); // '.5' means 50 cents
}

/**
 * Convert cents to yen (anything under ¥1 is dropped)
 * rate is the 151.23 in $1 = ¥151.23. Multiplying decimals gives rounding errors, so make it whole numbers first
 */
function centsToYen(cents, rate) {
  const sen = Math.round(rate * 100);     // 151.23 -> 15123 (whole hundredths of a yen)
  return Math.floor(cents * sen / 10000); // whole numbers times whole numbers: no rounding errors
}

The regex in toCents carries on from part 9's /^[0-9]+$/. ( … ) groups things, ? means "optional" and {1,2} means "one or two times": digits, optionally followed by a point and one or two more digits. Dropping anything under ¥1 is a company-policy question, so in real work, check with accounts.

The total (part 8's updateTotal) now treats dollar rows like this.

  for (const row of tbody.rows) {
    const text = row.querySelector('[name="amount"]').value;
    const usd = row.querySelector('[name="currency"]').value === 'USD';
    const amount = usd ? toCents(text) : toAmount(text);
    if (amount === null) continue;                          // Empty fields don't count
    if (Number.isNaN(amount)) { unreadable++; continue; }  // Can't be read as an amount
    if (!usd) items.push({ amount });
    else if (rate === null) waiting++;                     // no rate yet
    else items.push({ amount: centsToYen(amount, rate) }); // convert dollars to yen
  }

Part 9's validateClaim also takes the rate as a third argument, so dollar rows count towards the total (and the ¥30,000 receipt rule). The home-made tests grew by four, to 18.

✓ A correct claim passes
✓ Purpose is only a full-width space
✓ All line items empty
✓ Empty rows are skipped
✓ Amount 約1万
✓ Amount 0
✓ A date of tomorrow
✓ A date of today is fine
✓ Total ¥29,999, no receipt
✓ Total ¥30,000, no receipt
✓ Total ¥30,000, with receipt
✓ Dollar rows are converted into the total (receipt from ¥30,000)
✓ Policy box not ticked
✓ Only row 2 has no description
✓ Without a rate, dollar rows can't be claimed
✓ toCents('189.5') is 18950
✓ toCents('1.234') can't be read
✓ centsToYen(10000, 151.23) is 15123
Input check and conversion tests: 18 tests, 18 passed
7

Sending the claim: POST and FormData

What we send is made with new FormData(form, button). It gathers the form's fields by name and packs the receipt files in too, in the same shape as the enctype="multipart/form-data" we put on the form in part 3.

/** While sending, the submit buttons can't be pressed (no double submissions) */
function setSending(sending, button) {
  button.dataset.label ??= button.textContent;
  for (const b of form.querySelectorAll('button[type="submit"]')) b.disabled = sending;
  button.textContent = sending ? 'Sending…' : button.dataset.label;
  form.setAttribute('aria-busy', String(sending));
}

/** Show the result (kind is 'success' or 'danger') */
function showResult(kind, message) {
  result.className = `alert alert-${kind}`;
  result.textContent = message;
  result.hidden = false;
}

/** Send the claim to the server */
async function send(data, button) {
  setSending(true, button);
  try {
    const url = form.getAttribute('action'); // form.action returns the buttons named "action" instead (measured)
    const res = await fetch(url, { method: 'POST', body: data, signal: AbortSignal.timeout(15000) }); // give up after 15 seconds
    if (res.status === 400) { // the server's checks failed (it answers in the same shape as part 9)
      const body = await res.json();
      showErrors(body.errors);
      summary.focus();
      return; // finally still runs after return
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const body = await res.json();
    showResult('success', body.draft ? `Draft saved (${body.id}).` : `Your claim was received. Its number is ${body.id}.`);
  } catch (error) {
    showResult('danger', "Couldn't send. Check your connection and press it again. Nothing you typed has been lost.");
  } finally {
    setSending(false, button); // success or failure, put the buttons back
  }
}

form.addEventListener('submit', async (event) => {
  event.preventDefault(); // stop the page change; send with fetch instead
  result.hidden = true;
  const button = event.submitter;
  if (!button.formNoValidate) { // Save draft is not checked (part 9)
    const errors = validateClaim(readClaim(), localDate(), rate);
    showErrors(errors);
    if (errors.length > 0) {
      summary.focus();
      return;
    }
  }
  await send(new FormData(form, button), button); // pack the form, files and all (plus the button that was pressed)
});

FuruiConfession: my first try was fetch(form.action, …), and it posted to "/[object RadioNodeList]".

AraiWhat on earth is that?

FuruiIn part 3 we gave the buttons name="action". A form exposes its named controls as its own properties, so form.action pointed at the two buttons, not the address.

I checked the other pitfalls of sending a form with fetch as well.

# Pitfalls when sending a form with fetch (measured in Chromium)
form.action → [object RadioNodeList] (the two buttons named "action")
form.getAttribute('action') → '/api/expenses'
new FormData(form).get('action') → null (the pressed button is missing)
new FormData(form, button).get('action') → 'submit'
receipt with nothing attached → one file arrives, named '' with 0 bytes
writing 'Content-Type' in headers yourself → the server answers 400 (no boundary, unreadable)
  • Read the address with getAttribute — form.action loses to a control named "action"; MDN warns about the same thing
  • Pass the pressed button too — give FormData the submitter as its second argument and, like a normal submission, action=submit goes along. That's how the server tells a draft from a claim
  • Empty file fields — with nothing attached, a file with no name and 0 bytes still arrives. The server counts by size, not by number
  • Don't write Content-Type — with FormData the browser writes it, including the boundary marker. Write it yourself and the marker disappears, so the server can't read the body

On the server side, the claim is checked again with the same validate.js as the page. Because part 9 made it a function that doesn't touch the page, the server can use it as is (real servers are often written in another language; then you copy the same rules across). Pretending to use the developer tools, I removed the page's checks and sent the claim with no purpose…

Above the form, the red summary Please check your entries (1) with the link Enter a purpose. Below it the purpose field is empty and has a red border
Even with the page's checks removed, JavaScript and all, the server stopped it with the same validate.js, and its reply drew the red border. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)

The server returned 400 and { errors: [{ field, message }] }, the same shape as in part 9. Because the shapes match, the page just calls showErrors with it.

8

Showing sending, success and failure

The life of a submit button

Submit can be pressed Sending… disabled (both) Submit restored in finally The life of a submit button On a double-click (measured) buttons left enabled POST POST disabled while sending POST Success or failure, it has to go back, so write that once, in finally

If the button still works while sending, an impatient person presses it twice and the same claim arrives twice. Sure enough, double-clicking the version that leaves the button enabled reached the server twice. setSending makes both buttons disabled while sending and changes the pressed button's label to "Sending…".

Putting things back goes in finally. Success, failure, or a return halfway through on a 400: finally always runs, so nothing is left disabled by mistake.

Three moments. 1 While sending, neither Save draft nor Sending… can be pressed. 2 The reply arrived and a green bar shows the claim number. 3 When the server returned 500, a red bar says it couldn't send. In all three, receipt.pdf is still in the receipt field
1 sending, 2 received, 3 server trouble. Even on failure, nothing typed is lost. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright actually typed and clicked. 17 October 2026. The browser frame was drawn in for illustration.)

The failure message says two things: what to do, and that nothing typed has been lost. With the classic whole-page submission, this is where input could vanish. With fetch the page stays as it is, so pressing again is all it takes.

9

Taking it for a spin

I ran the practice server and let Playwright actually type and click. The rate reply takes about 0.6 seconds and the claim reply about 1.2 seconds. The browser's clock was fixed at 10:00 on 17 October 2026 (Tokyo).

# Values recorded while actually using the form (Chromium; practice server)
action total buttons shown
just opened ¥0 Save draft Submit Loading the exchange rate…
about 0.6 s later (rate arrives) ¥0 Save draft Submit Dollars are converted at $1 = ¥151.23 (rate for 2026-10-17)
3 rows typed (2 in dollars) ¥46,851 Save draft Submit -
Submit ¥46,851 Save draft Submit The total is ¥30,000 or more, so attach a receipt.
receipt attached, Submit ¥46,851 [×]Save draft [×]Sending… -
about 1.2 s later (201) ¥46,851 Save draft Submit Your claim was received. Its number is EXP-2026-0012.
Submit while the server returns 500 ¥46,851 Save draft Submit Couldn't send. Check your connection and press it again. Nothing you typed has been lost.
page checks off, no purpose, sent (400) ¥46,851 Save draft Submit Enter a purpose.
  • Just after opening it said it was loading; about 0.6 seconds later the rate arrived and the note changed
  • With the two dollar rows converted, the total is 3,070 + 28,658 + 15,123 = ¥46,851. That's over ¥30,000, so part 9's rule asked for a receipt
  • While sending, neither button could be pressed; about 1.2 seconds later the claim number appeared
  • When the server returned 500, the purpose, the line items and the receipt were all still there
POINT

Write network code assuming it will be slow, fail, and be pressed twice. There's one way for things to go right and several ways for them to go wrong. Whichever way it goes, the user should know what's happening now and what to do next. That's the page's job.

§ — On one page, and what is next

  1. fetch doesn't wait. It hands back a ticket (a Promise); the rest goes after await.
  2. 404 and 500 are not errors to fetch. Check res.ok and throw yourself. Set a timeout with AbortSignal.timeout.
  3. Money in whole numbers: dollars as cents, the rate as hundredths of a yen, multiply, then divide once at the end.
  4. Send FormData(form, button). Get the address with getAttribute('action') and don't write Content-Type.
  5. Disable the buttons while sending and restore them in finally. Never wipe the input on failure. Check the same rules on the server.

AraiI got a claim number! …But Furui, who is this claim actually from?

FuruiGood question. Right now "Taro Arai" is just text on the page. Next time: signing in and moving between screens. We'll check who's using the system and let them move between the claim list and the approval screens.

References

The page photos show the author's demo pages, served by a practice server (Node.js) and captured on 17 October 2026 in Chromium (Playwright) in the cloud. Segoe UI isn't available there, so the screenshots fall back to other system fonts. The exchange rate of 151.23 and the claim numbers are made-up values returned by the practice server. The order and timing of execution, the fetch results, the decimal arithmetic, the FormData and form pitfalls, the double-submission counts, the message when opened from file://, how the page changed, and the 18 home-made tests are real values recorded by driving and running the same Chromium with Playwright. form.html was checked with the Nu Html Checker (vnu) and showed no errors. The characters are fictional.

Old-School Front-End, part 10: asynchronous requests / 2026.10

← Back to all posts