Step 01

The form already talks

The series overview promised to start where the platform already works, so here it is: a <form>, no JavaScript at all, and a real request to a real endpoint. Before you write a line of client code, the browser will read your named fields, encode them, choose a place to put them, attach headers you never wrote, send the request, handle the response, and update history so the back button does the right thing. Every step after this one takes a piece of that job away from the browser and gives it to you. This step is about knowing what you are taking.

Learning Objectives

  • Describe what a form serializes — which fields, under which names — and where in the request it puts them
  • Explain how method chooses between the query string and the request body, and why that is a privacy decision as much as a technical one
  • Read action and method as a contract with a server, stated in markup
  • Name what the browser adds to a request that you did not write, and use /api/echo to see it
  • State what constraint validation (required, pattern, type, maxlength) is for — and what it is not

A form is already a network client

It is easy to read a <form> as a layout of inputs that some script will eventually pick up. It is not. A form is a declarative HTTP client. Given a destination and a method, it collects its own named controls, encodes them, issues the request, and navigates to the response. It does all of that in a page with an empty <script> budget.

<form method="get" action="/api/posts"> <label for="limit">How many</label> <select id="limit" name="limit"> <option>3</option> <option>5</option> </select> <button>Show posts</button> </form>

Press the button and the browser navigates to /api/posts?limit=3. Nobody assembled that URL; the form did, from the action and the value of the control named limit. The endpoint has no idea whether a human clicked a button, a script fired the request, or someone typed the URL by hand. It is the same HTTP either way.

Nothing in this series replaces that machinery. Everything in this series is a decision to take some part of it over, made for a reason, and paid for in code you now have to maintain.

method decides where the data goes

A form serializes the same fields either way. What method changes is where that serialization ends up.

GET — into the query string

The fields become ?limit=3&q=forms, replacing any query string already on the action. A form pointed at /api/posts?sort=new submits as ?limit=3, not ?sort=new&limit=3.

That is part of the URL, so it is in the address bar, in browser history, in bookmarks, in server access logs, and on the screen of whoever is standing behind you. It is also in the Referer header on a same-origin navigation. Not on a cross-origin one: by default, strict-origin-when-cross-origin trims that header down to the bare origin unless a site deliberately opts back out with unsafe-url.

That visibility is a feature when the request is a question: the result is linkable, shareable, and refreshable. Asking the same question twice changes nothing, so a reload is harmless.

POST — into the request body

The fields become the body of the request, after the headers. They are not in the URL, so they do not land in history, logs, or referrers. They are still plaintext on the wire unless the connection is HTTPS. POST is not encryption.

Use it when the request is an instruction that changes something. Doing it twice does it twice. That is why the browser warns before re-submitting on a reload.

So the choice is made before it is technical. A search box takes GET because a search result deserves a URL. A password, an email address, or a note someone typed in confidence takes POST. A query string is one of the leakiest places on the web. It survives in places nobody audits.

name is what makes a field exist

A control without a name attribute is not serialized. It is not sent empty; it is not sent at all. id is for the label and for CSS and script; name is the wire format. A field that "isn't arriving" at the server is a missing name far more often than it is a broken endpoint. The same rule disqualifies disabled controls, and unchecked checkboxes and radios, which have a name but no successful value to send.

What the browser sends that you did not write

The fields are the part you authored. They are a minority of the request. On a POST, the browser adds a Content-Type header: application/x-www-form-urlencoded by default, or whatever the form's enctype attribute names instead. A form carrying a file input ends up sending multipart/form-data. That header is the server's parsing instruction, the difference between a body that arrives as fields and a body that arrives as one meaningless string. The browser also adds an Accept header describing what it is willing to render, a User-Agent, a Content-Length, and, if the origin has any, the Cookie header. That last one is how a plain form on a logged-in site is an authenticated request without anything in the markup saying so.

You do not have to take that on faith, and you should not. In the demo further down, edit the POST form's action to point at /api/echo instead:

<form method="post" action="/api/echo">

That endpoint's entire job is to reflect what it received. Submit, and the response is your request: method, path, query, every header, and the raw body, verbatim:

{ "method": "POST", "path": "/api/echo", "query": {}, "headers": { "content-type": "application/x-www-form-urlencoded", "accept": "text/html,application/xhtml+xml,...", "accept-language": "en-US,en;q=0.9", "content-length": "21", "cookie": "session=8f2c...; theme=dark", "origin": "https://cse134.site", "referer": "https://cse134.site/apis/communication/demos/posts-form", "user-agent": "Mozilla/5.0 ..." }, "bodyKind": "urlencoded", "body": "title=Hello&author=tp" }

Read that as a template rather than a transcript: the values are abridged, and the header set is the interesting part of a real one, not all of it. The cookie line in particular appears only when the origin has cookies set for it. Run the echo yourself and see whether this one does.

Keep /api/echo in reach for the rest of the series. Once you are building requests by hand, the gap between what you meant to send and what you actually sent is where most of the debugging time goes. The echo closes it in one submit instead of an afternoon.

Demo: the form on its own

The page in the frame below has no fetch, no XMLHttpRequest, and no event handlers. Submit the GET form and watch the frame navigate to real JSON with a query string it did not write by hand. Submit the POST form with a title and get a 201 back.

The waterfall in that demo stays empty — on purpose

Every demo in this series embeds <http-waterfall capture="true">, and in this one it shows nothing at all. Three things are true about that, in order:

  • The request still happened. You saw the navigation and you saw the JSON. Nothing failed and nothing was lost.
  • The tool can only see what the page's own JavaScript issues. It captures by patching fetch and XMLHttpRequest in that document. A form submission is neither: the browser issues it itself, as a navigation, with no involvement from any script on the page.
  • DevTools does show it. Open DevTools, go to the Network panel, and submit the form again. The request to /api/posts is listed there with its method, its query string or form body, and its status.

What a page can observe about itself is not what the browser knows. The browser has always known about this request. The page has no hook into it, because the page never made it. In step 03 the page starts making its own requests, and the panel fills up.

Constraint validation is a user-experience feature

HTML gives you a real validation vocabulary for free. required refuses an empty field. type="email" and type="number" check shape and, on touch devices, summon the right keyboard. pattern takes a regular expression. min, max, and step bound a number or a date. maxlength stops the typing.

<input id="title" name="title" required maxlength="80">

This is valuable, and the reason is specific: the mistake is caught in the same second it was made, next to the field that caused it, with no round trip, no page reload, and no lost form state. A server error message arriving three seconds later, at the top of a re-rendered page, is a strictly worse version of the same information. Use all of it.

What it is not is a boundary. Every constraint listed above is enforced by code running on a machine the user controls, in a document the user can edit. DevTools deletes the required attribute in one keystroke. curl never parsed your markup and does not know the attribute exists. A script on the page can call form.noValidate = true, or simply skip the form and issue the request directly. None of this is exotic.

// The same request, without the form. Nothing in the markup prevents this. await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '' }), }); // → 422 { errors: { title: "A title is required." } }

⚠️ The server revalidates, or you have nothing

Client-side validation is a courtesy extended to cooperative users. It is not a check. A check the caller can delete is not a check. Every rule whose violation would corrupt data, leak something, or cost money has to be enforced again on the server, where the user cannot reach it.

Prove it on the demo above. Remove required from the title field in the Elements panel, submit with the field empty, and the response is 422 with { "errors": { "title": "A title is required." } }. The browser stopped enforcing the rule the moment you asked it to. The server did not.

Read that pairing as the design, not as redundancy. The client-side rule makes the common case pleasant. The server-side rule makes the uncommon case survivable.

What you give up by taking over

Before moving on, take an honest inventory of what the browser is doing for the page in that frame. It serializes the fields. It picks the encoding and declares it. It issues the request and waits without blocking anything you own. It handles the response, including redirects and errors. It navigates, pushes an entry into session history, and makes the back button work. It restores the form when a user comes back to it. It reports failures in a way users already recognize. Not one line of that is your code, and not one line of it has a bug you have to fix.

Every step after this one re-implements a piece of that list by hand, and each re-implementation is an opportunity to do it worse. Step 03 buys a page that survives its own requests: the scroll position, the focus, and the half-typed field are all still there when the answer arrives. It hands back the URL, the history entry, and the back button. Step 05 buys a clean promise-based API, plus a failure mode where a 404 looks like a success until you check. Step 07 is an entire step about problems the form never had.

The question to keep asking is not "can I do this with JavaScript?" You can. It is "what was the browser doing here that I am now responsible for, and am I going to do it as well?" That question is the through-line of this series.

Next Steps

In Step 2: The postback problem, we look hard at what that free machinery costs:

  • The full-page round trip, described in order: what the browser tears down and rebuilds on every single submit
  • What gets thrown away in the process: scroll position, focus, selection, and any in-progress state the page was holding
  • The bandwidth arithmetic: re-sending an entire document to change one list
  • Why the wait registers as slowness even when the server was fast, and why the symptom everyone remembers is not the one that is still there
  • The question the rest of the series answers: can the page make the request itself, and keep everything else where it was?