Skip to main content
Adapted, not copied

Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.

Chapter 18: HTTP and Forms

Estimated time: ~30 minutes · Chapter type: Concept

What you'll learn

  • The shape of an HTTP request and response
  • Making network requests with fetch, including error handling
  • Reading data out of HTML forms
  • Handling form submission without a full page reload

HTTP, briefly

HTTP (HyperText Transfer Protocol) is the request/response protocol the web runs on. Every request has a method describing the intent, a URL identifying the resource, optional headers (metadata), and an optional body (data being sent); every response has a status code summarizing the outcome, its own headers, and usually a body:

MethodTypical meaning
GETFetch a resource, no side effects
POSTCreate something new, or submit data
PUTReplace a resource entirely
PATCHPartially update a resource
DELETERemove a resource
Status rangeMeaning
200299Success
300399Redirection
400499Client error (you did something wrong — bad input, not authenticated)
500599Server error (something broke on their end)

Two specific codes worth knowing by number because you'll see them constantly: 404 (not found) and 401/403 (not authenticated / not authorized).

Making requests with fetch

fetch is the standard, Promise-based way to make an HTTP request from JavaScript (in both the browser and Node.js):

async function loadUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json(); // also returns a Promise — parses the body as JSON
}

A crucial detail that trips people up: fetch only rejects on a network failure (no connection, DNS failure, and similar) — a 404 or 500 response is still a "successful" fetch as far as the Promise is concerned. You must check response.ok (or response.status) yourself to detect an HTTP-level error, as the example above does.

Sending data (a POST, for instance) means setting the method, headers, and a body explicitly:

async function createTask(title) {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!response.ok) throw new Error(`Failed to create task: ${response.status}`);
return response.json();
}

The Content-Type: application/json header tells the server how to interpret the body — without it, many servers won't correctly parse the JSON you sent.

Reading form data

An HTML form collects user input across multiple fields:

<form id="signup">
<input name="email" type="email" required />
<input name="age" type="number" />
<button type="submit">Sign up</button>
</form>

FormData reads every named field from a form element in one step, without you manually querying each input individually:

const form = document.getElementById('signup');
form.addEventListener('submit', async (event) => {
event.preventDefault(); // stop the browser's default full-page submission (Chapter 15)

const data = new FormData(form);
const email = data.get('email');
const age = Number(data.get('age'));

await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, age }),
});
});

event.preventDefault() is essential here: without it, submitting the form triggers the browser's default behavior — a full page navigation to the form's action URL — which would throw away your JavaScript's control of the flow entirely.

Basic client-side validation

The browser gives you some validation for free through HTML attributes (required, type="email", min/max), but real applications almost always add their own checks too, both for a better error message and because HTML validation alone isn't sufficient (the server must always re-validate — never trust data purely because the client-side check passed):

function validateSignup({ email, age }) {
const errors = [];
if (!email.includes('@')) errors.push('Enter a valid email address.');
if (!Number.isInteger(age) || age < 13) errors.push('Age must be a whole number, 13 or older.');
return errors;
}

Putting it together: showing errors without losing the form

form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
const values = { email: data.get('email'), age: Number(data.get('age')) };

const errors = validateSignup(values);
if (errors.length > 0) {
document.querySelector('#errors').textContent = errors.join(' ');
return; // stop here — don't send an invalid request
}

try {
await createTask(values); // reusing the fetch helper from earlier
form.reset();
} catch (err) {
document.querySelector('#errors').textContent = 'Something went wrong. Please try again.';
}
});

Key takeaways

  • HTTP requests have a method, URL, headers, and optional body; responses have a status code you must check explicitly — fetch doesn't reject on a 404 or 500.
  • Always set Content-Type: application/json (and JSON.stringify the body) when sending JSON with fetch.
  • FormData reads all of a form's fields at once; event.preventDefault() stops the browser's default full-page submission so you can handle it in JavaScript.
  • Validate on the client for a fast, friendly experience — but never skip validating again on the server, since client-side checks can always be bypassed.

Try it yourself

  1. Write a fetch-based function deleteTask(id) that sends a DELETE request and throws a descriptive error if the response isn't ok.
  2. Add a required password field to the signup form example, and extend validateSignup to require at least 8 characters.
  3. Explain, in your own words, why checking response.ok is necessary even though you already awaited the fetch call without it throwing.
Hints
  1. Call fetch with the task's URL (built with a template literal) and {method: 'DELETE'} as the second argument, then reuse the same "check response.ok, throw if not" pattern as loadUser.
  2. data.get('password'), and if (password.length < 8) errors.push(...).
  3. fetch's Promise only reflects whether the network round-trip itself succeeded — the server can still respond with a 4xx/5xx "successfully," which is a normal, awaited response, not a rejection.

Summary

fetch and forms are how the vast majority of real applications get data on and off the page — everything from a login screen to an infinite-scrolling feed is some variation of these patterns. Our next project brings the DOM, canvas, and event handling together into one interactive tool; after that, we leave the browser behind entirely for JavaScript's other home: the server.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →