An APIs series

Client-Side Communication

Every client-side application eventually talks to something. This series is about your half of that conversation: the half you write, the half you can get wrong, and the half a framework will happily hide from you until the day it matters.

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

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.

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.

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/delivery/patterns/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.

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." } }

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.

Next Steps

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

Step 02

The postback problem

Step 01 left the form working and unmodified, which is the honest place to start: it talks to the server, it needs nothing from you, and it is hard to beat. This step is about the shape of the answer it gets back. A form submission is a navigation, and the response to a navigation is a whole new document. Not the part that changed, the whole thing. Everything the old document was holding goes away with it. That trade was invisible for a decade because it was the only trade on offer.

Learning Objectives

The pattern

Before there was any other option, an application was a set of pages. A filter, a sort, a page of results, a saved edit, a checkbox: every one was a link or a form. Following it meant leaving the page you were on. The industry word for it was the postback. The page submits back to itself, the server reads what you asked for, and the response is the next version of the page.

The loop is short enough to write in one line. The user acts, the browser issues a request, the server does whatever work the action implies, the server renders an entire HTML document, the browser throws away the document it had and builds the new one. Then it waits for the next action and does it again.

<!-- No action attribute, so this form submits to its own URL. --> <form method="get"> <div> <label for="limit">How many posts</label> <select id="limit" name="limit"> <option value="3">3</option> <option value="5">5</option> <option value="8">8</option> </select> </div> <button>Apply</button> </form>

That is the entire client. There is no code in it, as step 01 showed. Look at the other half of the exchange, what comes back:

GET /posts?limit=5 HTTP/1.1 Host: cse134.site Cookie: session=8f2c... HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 41317 <!doctype html> <html lang="en"> ... the head, the site header, the navigation, the sidebar, the footer, and, somewhere in the middle of all of it, the five list items you actually asked to change ... </html>

Read the Content-Length next to the request that produced it. The question was eight characters long. The answer was a building.

What a full page costs

"Slow" is not a useful description of this, because a postback on a fast server over a fast link is not slow. The costs are specific, and some of them survive even when the server answers in a millisecond.

The bytes

The document comes back in full every time, whether one list item changed or none did. A page of any real complexity is tens of kilobytes of HTML before the interesting part; the interesting part is often a few hundred bytes. That ratio is the number to hold on to. Every later technique in this series is an attempt to send only the second number.

Be careful how far you push that argument. One part of it stopped being true a long time ago. The subresources usually do not come back: a stylesheet, a script bundle, or a logo with a long Cache-Control lifetime is served from the browser's own cache without a request going out at all. Compression takes a further bite out of what remains. So the honest version of the complaint is narrower than the folklore: what repeats is the HTML document and the server work that produced it, not the whole page weight. That is still the wrong thing to repeat, and it is usually the most expensive thing on the page to generate.

The wait

The classic symptom was the white flash: the old page disappearing, the window going blank, the new page painting in from the top. That specific symptom is largely gone. Chrome has held the previous page's pixels on screen across same-origin navigations since 2019 rather than blanking, and cross-document view transitions now let a site animate the swap deliberately. If you learned that postbacks flash white, check it in a current browser before you repeat it.

What did not go away is the wait itself. The old document stays live for the whole of it. It scrolls, it accepts keystrokes, and clicking a different link will cancel the navigation you started. Nothing the user does in that window is remembered: the document that would have remembered it is already on its way out, and the one that would honor it does not exist yet. Type into a field during a postback and the keystrokes land somewhere that is about to be discarded. The gap is at least network latency plus server render time. Queueing, time to first byte, and parsing the new document all add to it. And it happens on every interaction, not just the expensive ones.

The state

This is the part that is easy to underrate, because each item sounds minor and the list does not.

Read that last one as a design constraint. In this model the URL is the application's memory. State that is not in the URL, or in something the server can look up from the URL, does not exist after the next click.

Demo: the postback

The page below is the step 01 feature rebuilt as a postback. The form has no action, so it submits to its own URL; changing the limit is therefore a full navigation, and the frame loads a whole new document.

Do it in this order, because the order is what makes the cost visible. One: type something into the field marked Notes (not submitted). Two: scroll all the way down to the bottom marker, where the limit control is waiting. Three: change the limit and press Apply.

You land back at the top of the page. The notes field is empty. The load counter in the dark strip has gone up by one, because that counter is in sessionStorage. A plain variable would have been zeroed along with everything else. The page will tell you, in pixels and characters, what it was holding before you pressed the button.

Notice what it took for that page to be able to report its own losses: it had to write two numbers to sessionStorage on the way out and read them back on the way in. That is a handful of lines to produce a description of state that the browser used to just keep. Recovering the state itself costs considerably more. The rest of this series is largely about paying for it.

Why it was fine, and when it stopped being

It would be easy to read the previous two sections as an indictment. They are not. For a document, this design is not a compromise people put up with until something better arrived. It is correct, and it is still correct.

A document has one job: to be read, and to be findable and referenceable afterward. The page-per-URL model does that job exactly. Every view has an address. The address can be linked, bookmarked, mailed, cited, indexed by a search engine, opened in fifteen tabs, or printed. Find-in-page works because the content is in the document. The back button works because the browser knows where you were. None of it requires a line of JavaScript, so none of it breaks when the JavaScript fails to load, throws, or is turned off. An enormous amount of the web is documents: reference material, news, documentation, most of a course site, this page. For all of it, the correct architecture is the one described above.

Two things move an interface out of that range, and it takes both:

Either one alone is survivable. Together they are the whole problem: a filter that changes eight list items should not cost a whole document, and if the user is going to change that filter forty times in a sitting, it especially should not.

What happened next is not a story of steady improvement. The techniques in the following steps solved the round-trip problem. Then, for about a decade, a great many applications used them to break every single item in the "for free" list above: URLs that described nothing, back buttons that did nothing or the wrong thing, pages that could not be bookmarked or shared, content that was invisible without JavaScript. The postback did not lose an argument. It got replaced, unevenly, by something faster that had to spend the next fifteen years re-learning what it had thrown out.

So can we do the communication ourselves?

That is the question the next step answers. Pause on it first: the answer is yes, and yes is the easy part.

If the page could issue the request itself, the response could be just the data: the eight list items, not the building. The page would still be there when the answer came back, so the scroll position, the focus, the notes field, and every variable the page had computed would still be there too. That much follows immediately, and it is a large win.

What follows a moment later is the bill. The browser was not only transferring bytes on that navigation. It was also deciding when the page had changed, telling the history stack about it, updating the address bar, making the back button mean something, reporting failures in a way users recognize, and announcing a new document to assistive technology. Take over the request and you have taken over all of that too, whether or not you have noticed:

// The optimistic version. This is the one everybody writes first. const res = await fetch('/api/posts?limit=' + encodeURIComponent(limit)); render(await res.json()); // The version that does what the form was already doing: // a loading state, because the network is not instant // an error state, because the network is not reliable // history.pushState, so the URL still describes what is on screen // a popstate handler, because otherwise the back button leaves the app // focus moved and the change announced, because no new document // arrived to tell assistive technology that anything happened // a way to cancel, because a user can click faster than a server answers

Every line in that comment block is a real step in this series, and none of them is optional in a real application. "Can we do the communication ourselves?" has an obvious answer. The question step 01 ended on is the harder one: what was the browser doing here that you are now responsible for, and are you going to do it as well?

Step 03 is where the answer starts arriving, and where the name for all of this gets attached.

Next Steps

In Step 3: Ajax appears, the page makes the request itself for the first time:

Step 03

Ajax appears

Step 02 ended on a question: can the page do the communication itself? The answer is yes, it has been yes since 1999, and the code fits on one screen. This step writes that code and watches the same feature update without a reload for the first time in this series. Then it does the harder half: adding up what the browser quietly stopped doing the moment the page took the request away from it. A step that showed you only the code would be teaching you to write the decade of broken applications that followed.

Learning Objectives

A name for something people were already doing

On 18 February 2005, Jesse James Garrett published an essay at Adaptive Path called Ajax: A New Approach to Web Applications. It argued that a handful of technologies already present in every browser added up to an approach: the DOM, JavaScript, CSS, and an object for making HTTP requests from script. It gave that approach a word. He spelled it Ajax, as a name rather than an initialism, which turned out to be the right instinct.

Nothing in the essay was new. The interesting thing about this date is not an invention.

The lineage runs back most of a decade. Internet Explorer introduced the <iframe> in 1996, and people immediately started hiding one, pointing it at a script-generated page, and reading the result. The same trick worked with a hidden frame in a frameset. Microsoft shipped a feature called remote scripting in 1998, and libraries with names like JSRS (JavaScript Remote Scripting) circulated for years. Then the Outlook Web Access team built the object that mattered: XMLHTTP, shipped as an ActiveX control in MSXML 2 with Internet Explorer 5.0 in March 1999. Mozilla reimplemented it as a native JavaScript object under the name XMLHttpRequest, Safari and Opera followed, and Internet Explorer 7 finally exposed it natively in 2006. By the time the essay appeared, the technique was nearly six years old and had already shipped in a mass-market Microsoft product.

What 2005 added was a noun. That sounds like the least significant possible contribution and it was not. Before the word, this was a collection of tricks with no collective identity: you could do it, but you could not put it in a job posting, title a book with it, propose a conference talk about it, or argue with a colleague about whether it belonged in a project. "Should this screen use Ajax?" is a question a team can actually have. "Should this screen use the hidden-iframe thing, or the ActiveX control, or the one Mozilla added?" is not a question. It is a shrug.

The other half of the mechanism is chronology, and it is easy to get backwards. The essay did not set off a wave of applications. The applications came first, and the essay is about them. Gmail shipped in April 2004. Google Suggest shipped in December 2004. Google Maps shipped on 8 February 2005, ten days before the essay, which names all three as its evidence. By the time the word existed, a great many people had already dragged a map that did not reload and watched a search box answer them as they typed. What they did not have was a term for it. Garrett did not announce a technique. He named one everyone had already seen working. That is why the name traveled as fast as it did.

What did not travel intact was the acronym.

The X was never very true

Ajax was shorthand for Asynchronous JavaScript and XML. Two of those three have held up.

The object's own name is the fossil left behind. XMLHttpRequest will still parse an XML response into a document for you and hand it back on responseXML, and approximately nobody uses that property. In 2005 the JSON half was not free either: JSON.parse did not become a built-in until ES5 in 2009, so period code either loaded Douglas Crockford's json.js or, far too often, ran the response body through eval. That is as bad an idea as it sounds. Step 07 returns to why.

Hello Ajax World, step by step

Here is the whole thing, in eight movements. Read it as choreography rather than as a program: the order the lines appear in the file is not the order they run in, and getting comfortable with that gap is most of what this step is teaching.

1. The trigger

Something has to start it. In the postback version this was a form that submitted; here it is the same form, intercepted. The preventDefault call is the single line that stops step 02 from happening.

document.getElementById('limit-form').addEventListener('submit', (event) => { event.preventDefault(); loadPosts(document.getElementById('limit').value); });

Keeping the <form> rather than reaching for a bare button is deliberate. The form still gives you the label associations, the Enter key, and the submit semantics. You are borrowing its behavior and declining one specific part of it.

What it does not give you for free is a working page when the script never loads. Keeping the element leaves you the shape of a fallback, the markup a browser already knows how to submit. But this form has no action and no method, so with scripting off it submits nowhere and the control does nothing. Turning that shape into a fallback means giving the form a real action and having a server ready to answer it. That is work, and it is the last item on the bill below.

2. The target zone

A navigation does not need a target, because the target is the entire document. The moment you issue the request yourself you have to name the region you intend to replace. You are now responsible for everything inside it.

<ul class="posts" id="posts"> <!-- Eight posts, delivered with the document. Script replaces the contents of this element and nothing else. --> </ul>

Note what that markup implies: there is content in there already. The list arrives with the page, so it is readable before a line of script runs and readable if the script never runs. That is a choice. It is the difference between enhancing a page and requiring JavaScript to see anything at all.

3. Creating the object

const xhr = new XMLHttpRequest();

One line today. In 2005 it was five, because Internet Explorer 5 and 6 had no such global and required an ActiveX control instead. Every Ajax tutorial of the era opened with some version of this, and a surprising amount of it is still sitting in codebases:

// 2005. Do not write this. The second branch is dead code on every // browser currently shipping — Internet Explorer is retired. var xhr; if (window.XMLHttpRequest) { xhr = new XMLHttpRequest(); // everything but IE 6 and older } else if (window.ActiveXObject) { xhr = new ActiveXObject('Microsoft.XMLHTTP'); // IE 5, IE 6 }

That block is a browser-support question that was live and expensive in 2005 and is gone now. If you find it in a file you are maintaining, delete it. It carries no risk.

4. Opening the request

xhr.open('GET', '/api/posts?limit=' + encodeURIComponent(limit), true);

Three arguments: the method, the URL, and whether the request is asynchronous. Despite the name, open does not open anything on the network. It configures the object; nothing is sent. This is also where the query string gets built. Note that encodeURIComponent is doing a job the form in step 01 did for free.

The third argument defaults to true and should be left that way. Passing false is the synchronous mode described above: the browser stops running your page until the answer arrives.

5. Sending

xhr.send();

This is where the request goes out. What happens next is nothing. It returns immediately, usually before the server has heard of the request. The next statement runs while the request is still in the air. Any code that assumes the answer is available on the following line is wrong. Later steps reach for promises and await because this is hard to keep straight by hand.

A GET sends no body, so the call takes no argument. A POST body goes here, which is step 06's subject.

6. The state callback

Since the answer does not arrive on the next line, you leave instructions for when it does.

xhr.onreadystatechange = function () { // 0 UNSENT, 1 OPENED, 2 HEADERS_RECEIVED, 3 LOADING, 4 DONE. if (xhr.readyState !== 4) return; // The exchange is over — one way or another. };

The handler is assigned before send and runs after it, potentially several times, as the request moves through its states. Nearly all real code checks for 4 and ignores the rest. That is defensible: state 2 only tells you the headers landed, and state 3 fires repeatedly while the body streams in, which is useful for a progress bar over a large download and for nothing else most days.

Two footnotes that step 04 makes into a whole section. First, this is not the modern way even within XMLHttpRequest: it has had ordinary load, error, and timeout events for well over a decade, and they are better in every respect. Second, readyState === 4 means finished, not succeeded. A 500, a 404, a failed connection, and a request you canceled yourself all arrive here.

7. Reading the response

if (xhr.status === 200) { const data = JSON.parse(xhr.responseText); render(data.posts); } else { showError(xhr.status); }

responseText is a string: the raw body, as it came off the wire. JSON.parse turns it into data. The status check separates "the exchange completed" from "the exchange completed and the server said yes." Those are not the same fact.

One value deserves naming now because it confuses everyone once: status === 0. That is not a status code the server sent; it means no response line was ever received. A request you aborted, a connection that failed, and a cross-origin request the browser refused to let you read all present as 0. Step 04 is about telling those apart.

8. Putting it in the page

function render(posts) { const rows = posts.map(function (post) { const row = document.createElement('li'); const title = document.createElement('div'); title.className = 'post-title'; title.textContent = post.title; // not innerHTML — see step 07 row.append(title); return row; }); document.getElementById('posts').replaceChildren(...rows); }

This movement has no counterpart in the postback version, because there the browser did it. Everything about it is now a decision you are making: which element, replaced or appended, in what order, and whether the values coming back from the server are inserted as text or as markup. That last one bites. textContent treats the response as the untrusted input it is. innerHTML treats it as code you wrote. Step 07 spends real time on the difference. Use textContent now.

Demo: the first version that does not navigate

The page below is the step 02 demo with one thing changed: the form is intercepted, and the eight movements above run instead of a submission. The instrumentation strip is in the same place and reports the same measurements as step 02's, plus two the postback version had no use for. Requests this page has made only counts above zero once a script is issuing them. Query string in the address bar only becomes interesting once it has stopped changing. One difference to note before you start: this demo opens showing all eight posts, where step 02's opened showing three.

Run the same experiment in the same order. One: type something into Notes (not submitted). Two: scroll all the way down to the bottom marker. Three: change the limit and press Apply.

Nothing moved. You are still at the bottom of the page, the notes field still has your text in it, and the list below the control has different rows in it than it did a second ago. In the strip at the top, Document loads this session has not budged. It reads 1 on a first visit, and whatever it reads when you arrive is what it will read when you leave. This document was built at still shows, to the millisecond, the moment you got here. Same document, still running, still holding everything it was holding. Press Apply four more times and both of those stay where they are while Requests this page has made climbs.

One field in that strip is there to spoil the mood, and you should look at it now rather than in the next section: Query string in the address bar. It says (none). It said (none) when you arrived and it will still say (none) after you have changed the list five times. In step 02 the query string was the application's memory. The ?limit=5 in the address bar was the only record of what you had asked for, and the only thing the next document had to go on. Here it has stopped describing the page at all.

What you just took on

The demo above is about two dozen lines of request handling, and for one interaction on one page it is fine. The trouble is that nobody stops at one. The second interaction is another fifteen lines shaped almost the same way; by the tenth, the page has ten hand-rolled request paths, ten ways of showing that something is loading, and ten opinions about what the screen should say when the server is down. There is no architecture in that code, because nothing about the technique suggested one.

That is the road the industry actually walked down between roughly 2005 and 2010, and the destination was the client-side framework. If you have ever wondered why every framework of the last fifteen years arrives with opinions about models, views, state, and routing baked in before you have written a line: this is why. Those are the pieces that fell on the floor when the page took over the request. A framework is an offer to pick them up for you.

The critics said so at the time, and they were largely right. Ajax obviously worked. The objection was that it was being sold as a small change to a page when it was a large change to an architecture, and that the bill would arrive later and be paid by users. It did, and it was. Here is the bill, itemized. Every line is something the browser was doing for the page in step 02 without being asked.

Two of those had no first-class solution in 2005 and do now. That difference matters: the 2005 limits were not a permanent state of affairs. There was no history.pushState then, so keeping the URL honest meant abusing fragment identifiers: the part of the URL after the #, which the browser records in history but never sends to the server. Google published an entire "Ajax crawling scheme" in 2009, the source of that era's #! URLs, so that such pages could be indexed at all, then deprecated it in October 2015 once its crawler could render JavaScript. Today the History API is universal and the major search engines run your script. The platform has handed back the tools for two items on that list. It has not handed back the behavior, and cannot. Every line above is still code somebody has to write, remember to write, and get right.

The conclusion is not "do not do this," which would be a strange thing to argue on a page you are reading in a browser that would be unusable without it. It is narrower: you do not have to take the whole bundle apart to fix one interaction. A page can be an ordinary document-shaped page, with real URLs and a working back button, and still update one list in place when a filter changes. The mistake of the late 2000s was not using Ajax. It was concluding that because one interaction wanted it, the whole application should be rebuilt around it. Then came a decade of rediscovering why the browser had been doing all of that.

What is actually better

A step that ended on the section above would be dishonest by omission. Ajax won for reasons, the reasons are still good, and three of them are measurable rather than aesthetic.

And then the category that is not an improvement on the postback but an escape from it: interactions that cannot exist as a navigation at all. Type-ahead suggestions that update while you are still typing. A draft that saves itself every few seconds without interrupting you. A username field that says "taken" before you reach the next field. Infinite scroll, live validation against a server, a dashboard that keeps its own numbers current. None of these is a faster version of a round trip. None has a page-per-URL equivalent, because they all require the page to still be here afterward.

So here is the rule that decides this in practice. The judgment is per interaction, not per application. The question is never "is this an Ajax app?" That framing is how the last section's mistakes got made. Ask instead: for this control, on this screen, does the result deserve its own URL that someone might share or return to, and does the user do it often enough that a round trip is in the way?

A search that produces a results page: give it a URL, let it be a navigation. A checkbox that reorders that page's results, which the same user will tick and untick eleven times in a minute: make the request from the page. Both of those can be true on the same screen. The best-behaved applications on the web made that call one control at a time.

Next Steps

Step 03 used XMLHttpRequest the way 2005 used it. In Step 4: XHR up close, we stop using it and start reading it:

Step 04

XHR up close

Step 03 used XMLHttpRequest the way 2005 used it: onreadystatechange, a check for readyState === 4, and JSON.parse on responseText. That code runs. Nobody has written it on purpose in a long time. This step goes through the object a piece at a time and names what replaced each one. Some of it you will still use. Most of it is in code somebody will ask you to maintain.

Learning Objectives

The object surface

XMLHttpRequest has a small surface: six methods and about a dozen properties. Several of them are only legal at particular moments. Get the order wrong and the object throws.

open configures. It does not open anything.

open('GET', url, true) sets the method, the URL, and whether the request is asynchronous. Nothing goes on the network. The name comes from a design that was once closer to a socket.

The full signature takes five arguments: open(method, url, async, username, password). The last two are HTTP Basic credentials. Don't use them. They put a password in your JavaScript and they only reach servers still doing Basic authentication.

The third argument defaults to true. Passing false makes the request synchronous, which blocks the main thread until the server answers. It is deprecated, it still runs, and current browsers warn about it in the console. Don't write it.

setRequestHeader has one legal window

After open, before send. Outside that window it throws InvalidStateError.

const xhr = new XMLHttpRequest(); xhr.setRequestHeader('Accept', 'application/json'); // InvalidStateError. The object is in state 0; setRequestHeader needs state 1. xhr.open('GET', '/api/posts', true); xhr.send(); xhr.setRequestHeader('X-Too-Late', 'nope'); // InvalidStateError again. The send() flag is set.

Two behaviors catch people out. Calling it twice with the same header name appends rather than replaces: two calls setting Accept produce one header carrying both values, joined by a comma and a space. And a list of header names is forbidden to script, including Host, Connection, Content-Length, Origin, Referer, Cookie, and anything starting with Proxy- or Sec-. Setting one of those does not throw. The call returns, the header is not set, and the browser prints a refusal in the console, where nobody is looking. The demo below does this on purpose.

send is the only line that touches the network

send(body) puts the request on the wire and returns immediately, usually before the server has heard of it. GET and HEAD ignore the body argument. What belongs in that argument is step 06.

Reading headers back

getResponseHeader(name) is case-insensitive and returns null when the header is absent. getAllResponseHeaders() returns the whole set as one string. Lines are separated by CRLF and names come back lowercased, whatever case the server used.

cache-control: no-store content-encoding: gzip content-type: application/json; charset=utf-8 transfer-encoding: chunked

It is a string, not an object, so you parse it yourself.

function responseHeaders(xhr) { const raw = xhr.getAllResponseHeaders().trim(); if (raw === '') return {}; return Object.fromEntries(raw.split(/[\r\n]+/).map((line) => { const at = line.indexOf(': '); return [line.slice(0, at), line.slice(at + 2)]; })); }

Headers are missing from that list even same-origin. Set-Cookie is never handed to script in any browser, so a cookie the server just set is invisible to the code that asked for it. Cross-origin the list collapses to seven names: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma, plus whatever the server chooses to name in Access-Control-Expose-Headers. The demo below runs same-origin and prints everything it is allowed to. Step 08 runs the same call against a second origin and prints the short version.

responseType decides what you get back

Set xhr.responseType = 'json' and the browser parses the body for you. Your code reads xhr.response and there is no JSON.parse call anywhere in it. A body that will not parse yields null instead of throwing, which moves that failure from a try block to an if. MDN puts responseType at Baseline widely available, in every major browser since November 2016.

Setting it has one consequence to know about in advance. responseText then throws InvalidStateError. responseText is readable only when responseType is '' or 'text'. Any library reading responseText behind your back breaks the moment you set the property. The demo below runs into this and says so on the page.

const xhr = new XMLHttpRequest(); // Configures the object. Nothing has left the machine. xhr.open('GET', '/api/posts?limit=5', true); // Legal here and nowhere else: after open, before send. xhr.setRequestHeader('Accept', 'application/json'); // The browser parses the body. Reading responseText now throws. xhr.responseType = 'json'; xhr.addEventListener('load', () => { console.log(xhr.status, xhr.statusText); console.log(xhr.getResponseHeader('Content-Type')); console.log(xhr.getAllResponseHeaders()); console.log(xhr.response); }); xhr.send();

readyState, and why you stopped needing it

readyState reports where the object is. Five values.

onreadystatechange fires on every transition, so most of the calls have nothing in them for you. So every example from the era opens by checking for 4 and returning.

XMLHttpRequest Level 2 replaced the arrangement with named events.

Exactly one of load, error, timeout, and abort fires per request, then loadend. The readyState version has to reach state 4, read status, and infer which of the four happened. The events say so directly.

None of this is new. Internet Explorer 10, released in 2012, was the last major browser to add these events. Everything else had them earlier.

const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/posts?limit=5', true); xhr.responseType = 'json'; xhr.timeout = 8000; xhr.addEventListener('load', () => render(xhr.response)); xhr.addEventListener('error', () => report('the request never completed')); xhr.addEventListener('timeout', () => report('eight seconds, no answer')); xhr.addEventListener('abort', () => report('canceled')); xhr.addEventListener('loadend', () => setBusy(false)); setBusy(true); xhr.send();

status is not success

load fired. The exchange completed. Nothing more.

A 404 fires load. A 500 fires load. A 403 fires load. The server answered in every one of those cases, and an answer is what load is for. Checking the status is a separate line you have to write.

xhr.addEventListener('load', () => { // Getting here means the exchange finished. It does not mean 200. if (xhr.status >= 200 && xhr.status < 300) { render(xhr.response); } else { report('the server answered ' + xhr.status); } });

status === 0 confuses everyone once. No server sends 0. It means no status line was ever handed to your code, and there are three ways to arrive there.

The events separate two of them: abort for the first, error for the other two. A cross-origin block presents as a network failure. From the page's side, it is one. Step 08 is about living with that.

statusText carries the reason phrase over HTTP/1.1, OK or Not Found. HTTP/2 and HTTP/3 removed the reason phrase from the protocol, so over those connections it is an empty string. This site is served over HTTP/2. The demo below prints the value quoted, and it reads "". Do not build anything on it.

fetch looks at the same facts and draws its success line somewhere else. Step 05 argues that out.

Timeouts and abort

With no timeout, a request waits as long as the connection stays open. xhr.timeout is a number of milliseconds and defaults to 0, which means no limit. Set it whenever you like. The object has no ordering rule for it, and open does not reset it. Old Internet Explorer did require it after open. Most code still puts it there out of habit.

When it elapses the object fires timeout, then loadend, and status is 0. The server may still be working on the request. A client timeout tells the server nothing.

Setting timeout on a synchronous request in a document throws InvalidAccessError. You are not writing synchronous requests, so this will not come up.

xhr.abort() stops the request. It fires abort, then loadend, and leaves status at 0. Calling it on an object that has not been sent does nothing and fires nothing.

A user can press a control faster than a server can answer, and two answers in flight can arrive in either order. Holding a reference to the request and aborting it before starting the next one is the smallest correct fix, and it is what the step 03 demo was already doing. Step 07 does it properly.

XMLHttpRequest shipped with both of these. fetch shipped with neither. AbortController reached the last of the three major engines in 2019 and gave fetch cancellation. AbortSignal.timeout() reached all three in 2022 and gave it a deadline. Step 05 covers both.

Upload progress

xhr.upload is a second event target hanging off the same object. It fires the same event names for the request body going out that xhr fires for the response coming in. Listen on xhr for the download, on xhr.upload for the upload.

A progress event carries three values. lengthComputable is true when the total is known. loaded is the bytes sent so far. total is the size of the whole body. When lengthComputable is false, total is 0 and you have no denominator to divide by.

xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); bar.style.width = percent + '%'; bar.setAttribute('aria-valuenow', String(percent)); });

How many events you get depends on the connection. The XMLHttpRequest standard puts a ceiling of roughly one event every 50 milliseconds and sets no floor at all. An 8 MB body to a server on the same machine finishes in a single event with loaded === total. A 20 MB file over hotel wifi fires hundreds. Write the handler so that one event is a valid run.

What fetch can and cannot do here

fetch handles download progress fine. response.body is a ReadableStream, and counting bytes as you read it gives you the same numbers. Upload is the gap, and it is the reason XMLHttpRequest is still on the table in 2026.

Sending a ReadableStream as a request body requires the duplex: 'half' option. That shipped in Chromium 105 and exists in no other engine. Firefox has had the bug open since 2017. Safari accepts a stream on a Request object and then refuses to send it through fetch.

Even in Chromium the restrictions rule out most of the cases you would want it for: HTTPS only, HTTP/2 or HTTP/3 only, always preflighted, and only 303 redirects are followed. What you would be counting is bytes your code handed to the browser, not bytes that reached the server. Those two numbers separate as soon as anything buffers.

If a user is uploading something big enough that a progress bar matters, use XMLHttpRequest.

Demo: the same feature with the instruments on

The step 03 demo, rewritten. Every event the object fires is written to a log in the order it fires. responseType is 'json', so nothing on the page calls JSON.parse. getAllResponseHeaders() is dumped verbatim. A second control posts a large body so xhr.upload has something to report. The instrumentation strip carries over from steps 02 and 03, with one field added: which event ended the last request.

Four things to run, in any order.

The header dump is the same-origin case from the section above. Count the lines, then remember the number when step 08 makes the same call across origins.

What to actually write

If something stops you from using fetch, this is the XMLHttpRequest to write.

function request(method, url, options = {}) { const { body = null, headers = {}, timeout = 8000, signal = null } = options; return new Promise((resolve, reject) => { if (signal && signal.aborted) { reject(new Error('abort')); return; } const xhr = new XMLHttpRequest(); // One controller owns every listener attached below. const listeners = new AbortController(); const opts = { signal: listeners.signal }; xhr.open(method, url, true); for (const [name, value] of Object.entries(headers)) { xhr.setRequestHeader(name, value); } xhr.responseType = 'json'; xhr.timeout = timeout; xhr.addEventListener('load', () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, data: xhr.response, }), opts); xhr.addEventListener('error', () => reject(new Error('network')), opts); xhr.addEventListener('timeout', () => reject(new Error('timeout')), opts); xhr.addEventListener('abort', () => reject(new Error('abort')), opts); // One call takes all five listeners back off the object. xhr.addEventListener('loadend', () => listeners.abort(), opts); // XMLHttpRequest predates AbortSignal and knows nothing about it. if (signal) { signal.addEventListener('abort', () => xhr.abort(), { once: true, signal: listeners.signal, }); } xhr.send(body); }); }

Five differences from step 03's version. Events instead of onreadystatechange. responseType = 'json' instead of JSON.parse. A timeout with a real number in it. One AbortController holding every listener, so the whole set comes off in a single call. And an AbortSignal bridged by hand, because the object predates it.

A 404 resolves that promise. A dropped connection rejects it. Hold on to that shape for step 05. fetch makes the same call and takes constant criticism for it.

Then stop writing it. fetch does this in fewer lines and composes with await. Step 05 writes the same function again and then spends the rest of the step on the decision everyone trips over.

Next Steps

Step 5: fetch, and what changed puts the two side by side and takes the argument that step 04 kept deferring:

Step 05

fetch, and what changed

Step 04 ended with a 45-line function that wraps XMLHttpRequest in a promise. Most of those lines translate events into promise outcomes. fetch does that translation itself, and the same function comes out at 18 lines. It also puts the error/data line in a place that surprised people. It has taken criticism for that since 2015. Step 04 set that argument up and left it alone.

Learning Objectives

The same job, fewer lines

fetch shipped in Chrome 40 in January 2015 and Firefox 39 that July. Safari was last, in 10.1, on 27 March 2017. It has been available everywhere for nine years.

Here is step 04's function, abridged to 21 lines. A promise constructor wrapping the object, five listeners, an AbortController holding all five so one call removes them, and a bridge from AbortSignal to xhr.abort() written by hand because the object predates signals.

function request(method, url, options = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const listeners = new AbortController(); const opts = { signal: listeners.signal }; xhr.open(method, url, true); xhr.responseType = 'json'; xhr.timeout = options.timeout ?? 8000; xhr.addEventListener('load', () => resolve({ /* … */ }), opts); xhr.addEventListener('error', () => reject(new Error('network')), opts); xhr.addEventListener('timeout', () => reject(new Error('timeout')), opts); xhr.addEventListener('abort', () => reject(new Error('abort')), opts); xhr.addEventListener('loadend', () => listeners.abort(), opts); options.signal?.addEventListener('abort', () => xhr.abort(), { once: true }); xhr.send(options.body ?? null); }); }

The same request, the same result, on fetch.

async function request(method, url, options = {}) { const { body = null, headers = {}, timeout = 8000, signal = null } = options; const deadline = AbortSignal.timeout(timeout); const response = await fetch(url, { method, body, headers, signal: signal ? AbortSignal.any([deadline, signal]) : deadline, }); return { ok: response.ok, status: response.status, data: await response.json(), }; }

The promise constructor, the five listeners, the controller, and the bridge are gone. fetch takes an AbortSignal as an option, so there is nothing to bridge.

You still write the status check, the parse, and the failure decision. Two lines in that function are wrong. await response.json() throws on a body that will not parse, and nothing in it can tell a timeout from a cancel. The next three sections fix both.

fetch does not reject on 404 or 500

A fetch promise resolves whenever the exchange completed. A 403, a 404, and a 500 all completed. The promise rejects when there is no response to hand you.

Five things reject it. MDN's Exceptions list names AbortError, TypeError, and a NotAllowedError for two APIs you are not using. It does not name TimeoutError, because the rejection value for any aborted fetch is whatever the signal's reason is, and AbortSignal.timeout() sets that reason to a TimeoutError DOMException.

No status code appears in that list. response.ok is true when status is 200 through 299 and false for everything else. You have to read it.

// Wrong. There is no status check anywhere in it. fetch('/api/nope') .then((response) => response.json()) .then(render) .catch(showError);

Against this site that URL answers 404 with {"error":"No such endpoint","path":"/api/nope"}. The promise resolves. response.json() parses the body successfully, because it is valid JSON. render runs with an object that has no posts in it and draws an empty list. showError never runs. Nothing appears in the console. The user gets a page that looks like it worked and has nothing on it.

async function loadPosts() { const response = await fetch('/api/nope'); if (!response.ok) { // The server answered. Read the answer. const problem = await response.json().catch(() => null); report(response.status, problem); return; } render(await response.json()); }

This behavior is deliberate. A 500 is a response. It has a status line, headers, and usually a body saying what went wrong. Rejecting the promise would leave you holding an exception and would throw the explanation away with the response that carried it.

Where the error/data line sits

fetch's choice is a judgment about who decides what a response means. A transport layer knows whether bytes arrived. Only the caller knows whether a 404 is a bug, an expected empty result, or a sign the user typed the wrong id.

So the contract is one function, two implementations, and a single shape coming back.

transport(url, { timeoutMs, signal }) -> Promise<Result> Result = { ok: boolean // HTTP status in the 2xx range status: number statusText: string data: unknown // parsed JSON, or null if the body would not parse }

A 404 or a 500 resolves with ok: false. The server answered, that answer is data, and the caller decides what it means. Only a failure to complete the exchange rejects: network down, timed out, canceled.

Both transports produce those three failures and report them differently.

Did not completeXMLHttpRequestfetchkind
Network down, DNS, cross-origin blockerror eventTypeError'network'
Deadline elapsedtimeout eventDOMException, name TimeoutError'timeout'
Your code canceled itabort eventDOMException, name AbortError'abort'

XHR gives you three event names and one status of 0 for all three. Step 04 told you to listen for the events rather than read the number. fetch gives you one rejection and three values of error.name. Your calling code wants neither. Give it a third.

export class TransportError extends Error { /** @param {'network'|'timeout'|'abort'} kind */ constructor(kind, message, options) { super(message, options); this.name = 'TransportError'; this.kind = kind; } }

On the fetch side, the whole translation is one catch.

export async function fetchTransport(url, { timeoutMs = 8000, signal } = {}) { const deadline = AbortSignal.timeout(timeoutMs); const combined = signal ? AbortSignal.any([deadline, signal]) : deadline; let response; try { response = await fetch(url, { signal: combined, headers: { Accept: 'application/json' }, }); } catch (cause) { // fetch throws one exception type for several situations. `name` tells // them apart. if (cause.name === 'TimeoutError') { throw new TransportError('timeout', 'No response in time.', { cause }); } if (cause.name === 'AbortError') { throw new TransportError('abort', 'Request canceled.', { cause }); } throw new TransportError('network', 'Network error.', { cause }); } // Match the XHR contract: an unparseable body becomes null, not a throw. // Error responses often carry a non-JSON body, so this is the common path. let data = null; try { data = await response.json(); } catch { data = null; } return { ok: response.ok, status: response.status, statusText: response.statusText, data, }; }

{ cause } is the second argument to the Error constructor. It keeps the original DOMException attached, so a log can print the real message while your code branches on kind.

The XHR half of the same contract is step 04's function, with its four listeners resolving and rejecting into that shape. The demo below runs both. Switch the selector and nothing above the transport changes.

Timeout is a signal, not a property

XMLHttpRequest has xhr.timeout, a number of milliseconds sitting on the object. fetch has no timeout option and never has. For seven years the answer was a setTimeout that called controller.abort(), plus a clearTimeout in a finally block that people forgot to write.

async function getJson(url) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { // Without this the timer runs to completion on every fast request. clearTimeout(timer); } }

AbortSignal.timeout(ms) returns a signal that aborts itself when the time runs out. There is no controller to hold and no timer to clear.

const response = await fetch(url, { signal: AbortSignal.timeout(8000) });

The clock starts when you call the method, not when the request goes out. Build the signal with no fetch attached to it and it still aborts on schedule, so build it next to the call that uses it. MDN adds that the timer counts active time rather than elapsed time: it pauses while the document sits in the back/forward cache or a worker is suspended.

Two reasons to give up, one signal

You have two reasons to stop waiting: your deadline, and the user pressing Cancel. Neither knows about the other, and a request can only take one signal.

AbortSignal.any([a, b]) returns a signal that aborts when the first of them does, carrying that one's reason. If one is already aborted when you call it, the result comes back already aborted with that reason.

// The caller owns this and can abort it whenever it likes. const cancel = new AbortController(); cancelButton.addEventListener('click', () => cancel.abort()); const deadline = AbortSignal.timeout(8000); const signal = AbortSignal.any([deadline, cancel.signal]); const response = await fetch('/api/slow?ms=20000', { signal });

Whichever fires first, fetch rejects. Both signals are still readable in the catch block, and asking them is how the corrected block in the last section tells the two apart.

Support, and one version range that gets it wrong

AbortSignal.timeout() landed in Firefox 100 in May 2022, Chrome 103 that June, and Safari 16 in September 2022. AbortSignal.any() landed in Chrome 116 in August 2023, Safari 17.4 in March 2024, and Firefox 124 later that month. The two are a year apart in Chrome, so a page that combines a deadline with a cancel needs Chrome 116, not 103.

Chrome 103 through 123 shipped AbortSignal.timeout() with the wrong abort reason. A timed-out fetch rejected with an AbortError, never a TimeoutError. Read name on those versions and every timeout comes back kind: 'abort'. Chromium issue 40263649. Chrome 124 fixed it on 16 April 2024. MDN dates Baseline for the method to that day.

This is the second reason the corrected catch asks deadline.aborted. The signal reports what happened to it whatever the engine decided to call the exception, and it survives abort(reason). Write that version and neither problem reaches you.

Step 04 said this about xhr.timeout and it holds here too. A client timeout tells the server nothing. The handler keeps running, the database write still lands, and your page has stopped listening.

Request and Response are objects

XMLHttpRequest is one object that holds the request and the response at once, and you read the parts off it by name. The Fetch API splits them: Request, Response, and Headers, each constructible, each passable to a function that has never heard of fetch.

const listPosts = new Request('/api/posts?limit=5', { method: 'GET', headers: { Accept: 'application/json' }, }); // Somewhere else entirely, possibly much later. const response = await fetch(listPosts);

Headers is a real collection. Step 04 parsed the CRLF string that getAllResponseHeaders() hands back; response.headers.get('content-type') replaces that whole helper. The visibility rules did not change. Cross-origin you still see only the seven safelisted names plus whatever the server puts in Access-Control-Expose-Headers, and Set-Cookie is invisible to script everywhere. Step 08 runs that experiment.

The body reads once

json(), text(), arrayBuffer(), blob(), formData(), and bytes() all consume the same stream. After any one of them response.bodyUsed is true and the stream is spent. bytes() is the recent one: Firefox 128 in July 2024, Safari 18 that September, Chrome 132 in January 2025.

const response = await fetch('/api/posts'); const data = await response.json(); // fine const text = await response.text(); // TypeError: body already read

The second call returns a rejected promise, so with await it throws at that line. The error is a TypeError, not a SyntaxError. Read the name before you conclude the server sent garbage.

response.clone() gives you a second independent body, and you have to call it before the first read. clone() is synchronous. On a response whose body is used it throws TypeError at the call.

async function getJsonAndLogIt(url) { const response = await fetch(url); // Clone first. After the next line the original is spent. logRawBody(await response.clone().text()); return response.json(); }

The waterfall panel at the bottom of every demo in this series does this. It wraps fetch, calls clone() on whatever comes back, reads the clone to fill in the panel, and returns the untouched original to the page. Without the clone, opening the panel would break every demo on the site.

Demo: one contract, two transports

The step 04 demo again, with the transport behind a selector. Choose fetch or xhr and press Send. The posts list, the outcome panel, and the status line are driven by the same result object either way, and nothing downstream of the transport branches on which one ran.

What changed from step 04: the event log is gone, because fetch has no events to log. In its place is an outcome panel that says whether the promise resolved or rejected, and the scroll ruler from steps 02 through 04 has been dropped. The instrumentation strip carries over with the transport name added to it.

Four things to run.

The demo's fetchTransport asks deadline.aborted rather than reading name, so it is right on Chrome 116 as well as on Chrome 124. It prints what the exception called itself on a line of its own. On a current browser that line reads TimeoutError. On Chrome 123 it reads AbortError beside a kind of 'timeout'. Below Chrome 116 the demo runs on xhr, because AbortSignal.any() is not there to combine the deadline with the Cancel button.

What fetch still does not do

Upload progress, portably. Step 04 covers this in full: xhr.upload fires progress events for the request body, and the fetch equivalent needs duplex: 'half', which exists only in Chromium. If a progress bar for an upload matters, that request stays on XMLHttpRequest. Download progress is fine on fetch, through response.body.

Synchronous mode. There is none and there will not be. fetch returns a promise. xhr.open(method, url, false) still runs and is deprecated. Don't write either.

Surviving the page. A fetch started while the user navigates away gets canceled with the document. Step 09 covers navigator.sendBeacon and the keepalive option.

Between 2015 and 2022 fetch had no deadline of any kind, and every codebase grew its own setTimeout wrapper. AbortSignal.timeout() closed that one.

Next Steps

Every request so far has been a GET with nothing in it. Step 6: What to send fills in the body:

Step 06

What to send

Almost every request in steps 03 through 05 was a GET with an empty body. The exception was step 04's progress bar, which pushed a block of padding at /api/echo so the upload events had something to count. No step so far has picked an encoding on purpose. Step 01's form did have a body, and the browser built it, chose an encoding for it, and wrote the header describing it. Take the request over and you take that job too. This step covers the three encodings, the header that tells a server which one arrived, what JSON loses on the way through, and how much of the object in front of you belongs on the wire at all.

Learning Objectives

Three encodings

A browser can serialize a body three ways with no help from you. The enctype attribute on a <form> picks between application/x-www-form-urlencoded, which is the default, multipart/form-data, and text/plain. Step 01 used the first two. text/plain writes name=value one pair per line, and the HTML standard says its payloads are meant to be human readable and are not reliably interpretable by computer, because a newline inside a value looks like the newline that ends it. Do not send it to a real endpoint.

JSON is not on that list. No enctype value produces it, so a JSON body only exists because JavaScript built one.

Three fields run through this whole step. The title carries an accented character and two CJK characters.

const fields = { title: 'Café 東京 opens at nine', author: 'tp', tags: 'json', };

application/x-www-form-urlencoded

One line. Pairs joined by &, name and value joined by =, and spaces written as +. Four punctuation characters survive, *, -, ., and _, along with the ASCII letters and digits. Everything else is percent-escaped one UTF-8 byte at a time. é is two bytes and becomes %C3%A9. 東 is three and becomes %E6%9D%B1.

POST /api/echo HTTP/1.1 Content-Type: application/x-www-form-urlencoded;charset=UTF-8 Content-Length: 68 title=Caf%C3%A9+%E6%9D%B1%E4%BA%AC+opens+at+nine&author=tp&tags=json

URLSearchParams builds it. It applies the same serializer the form does, so a space comes out +, not %20. encodeURIComponent('a b') gives you a%20b.

const body = new URLSearchParams(fields); body.toString(); // 'title=Caf%C3%A9+%E6%9D%B1%E4%BA%AC+opens+at+nine&author=tp&tags=json' await fetch('/api/echo', { method: 'POST', body });

It cannot carry a file. Every value is a string. To nest anything you and the server agree on a convention; the format has none.

multipart/form-data

A random string called the boundary, chosen per request, separates the fields. Each part gets a Content-Disposition: form-data header naming it, a blank line, then the value as raw bytes with no escaping at all. The last boundary carries a trailing --. Chrome's boundary is ----WebKitFormBoundary followed by sixteen random characters.

POST /api/echo HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundarybfAW8K1ZKCmsQzck Content-Length: 352 ------WebKitFormBoundarybfAW8K1ZKCmsQzck Content-Disposition: form-data; name="title" Café 東京 opens at nine ------WebKitFormBoundarybfAW8K1ZKCmsQzck Content-Disposition: form-data; name="author" tp ------WebKitFormBoundarybfAW8K1ZKCmsQzck Content-Disposition: form-data; name="tags" json ------WebKitFormBoundarybfAW8K1ZKCmsQzck--

Those three fields cost 68 bytes urlencoded and 352 multipart, both measured through /api/echo in Chrome 151. The multipart figure moves between browsers, because the boundary token is theirs to choose and Chrome's is 38 characters long. The overhead is fixed per field, so it stops mattering as the values get long and matters a great deal when they are short. Files are what the format is for: a part can carry a filename and its own Content-Type, and the bytes go through unescaped.

const body = new FormData(); for (const [name, value] of Object.entries(fields)) body.append(name, value); // No headers option. The next section is about why. await fetch('/api/echo', { method: 'POST', body });

application/json

Nested objects, arrays, numbers, booleans, and null, all typed. Both other formats have exactly one type: string.

POST /api/echo HTTP/1.1 Content-Type: application/json Content-Length: 66 {"title":"Café 東京 opens at nine","author":"tp","tags":"json"} await fetch('/api/echo', { method: 'POST', body: JSON.stringify(fields), // A string body gets text/plain unless you say otherwise. Say otherwise. headers: { 'Content-Type': 'application/json' }, });

JSON came out smallest here, at 66 bytes, because the values are short and the field names are the only overhead. Add a 2 MB image and the ranking inverts: JSON has no binary type, so the image has to be base64'd into a string, and base64 costs a third more bytes than the file it encodes. Multipart sends the same file as bytes.

EncodingBuilt withFilesTypesBytes for the three fields above, Chrome 151
application/x-www-form-urlencodedURLSearchParamsNoStrings only68
multipart/form-dataFormDataYes, as bytesStrings and files352
application/jsonJSON.stringifyOnly base64'd into a stringObjects, arrays, numbers, booleans, null66

FormData from a form

new FormData(formElement) reads the form and collects its successful controls, the same set the browser would submit on its own.

ControlCollectedEntry
Text input, select, or textarea with a nameYesIts current value
Any control with no nameNo
disabled, or inside a disabled <fieldset>No
Unchecked checkbox or radioNo
Checked checkbox with no value attributeYesThe string "on"
<select multiple>, two options selectedYesTwo entries under one name
Two controls sharing a nameYesTwo entries, in document order
Submit buttonNoIts name and value, but only when it is passed as the second argument
<input type="image">, which is also a submit buttonNoPassed as the second argument it adds two entries, name.x and name.y, both 0 outside a real click
Reset button, or any element that is not a submit buttonNeverPassing one as the second argument throws TypeError
<input type="file"> with nothing chosenYesAn empty File: name "", size 0, type application/octet-stream
<input type="file" multiple>, two files chosenYesTwo File entries under one name

The empty File on an untouched file input catches people. The entry is there, the name is right, and the value is a zero-byte file. A server that checks whether the field exists will conclude the user uploaded something. Check the size.

Buttons are the other surprise. A submit button is a successful control only when it is the button that submitted, and the one-argument constructor has no submitter to compare against, so it drops every button in the form. Pass the button as a second argument when you want it.

That argument takes a submit button and nothing else. Hand it a reset button, a type="button" button, or any other element and the constructor throws TypeError: Failed to construct 'FormData': The specified element is not a submit button. null is allowed and means the same as leaving the argument off, which matters because event.submitter is null when a form submits without a button.

form.addEventListener('submit', (event) => { event.preventDefault(); // Without the second argument, event.submitter's name and value stay out // of the body, even though the browser's own submit would have sent them. const data = new FormData(form, event.submitter); });

The second argument arrived in Firefox 111 on 14 March 2023, Safari 16.4 on 27 March 2023, and Chrome 112 on 4 April 2023. Older engines ignore it rather than throwing, so the button quietly stays out of the body.

Do not set Content-Type yourself

Hand a FormData to fetch as body and it writes the header for you, including the boundary it generated for that particular body.

const data = new FormData(form); await fetch('/api/posts', { method: 'POST', body: data }); // Content-Type: multipart/form-data; boundary=----WebKitFormBoundarybfAW8K1ZKCmsQzck

Write the header yourself and you have no way to know the boundary, because it is generated with the body.

await fetch('/api/posts', { method: 'POST', body: data, // Nothing warns you. The bytes are fine and the header is wrong. headers: { 'Content-Type': 'multipart/form-data' }, });

The request leaves. The bytes arrive intact. Then the server tries to parse them and finds no boundary to split on. Against /api/posts, which calls request.formData(), the measured result is a 500:

{ "error": "Handler failed", "detail": "TypeError: No boundary string in Content-Type header. The multipart/form-data MIME type requires a boundary parameter, e.g. 'Content-Type: multipart/form-data; boundary=\"abcd\"'. See RFC 7578, section 4." }

The same request with the header left alone returns 201. Both buttons are in the demo below.

Content-Type is a parsing instruction

A body is bytes. Content-Type is how the server decides which parser to run on them.

fetch writes the header for you based on what you passed as body, and gets out of the way if you wrote one yourself. Measured in Chrome 151:

bodyContent-Type fetch writesbodyKind from /api/echo
FormDatamultipart/form-data; boundary=…multipart
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8urlencoded
A stringtext/plain;charset=UTF-8other
Blob with a typeThat typeWhatever the type says
Blob with no typeNoneother
ArrayBuffer or a typed arrayNoneother
ReadableStreamNone, and duplex: 'half' is requiredother
Any of the above with a header you setYours, unchangedWhatever you claimed

Two of those rows are traps. The string row is the common one: JSON.stringify(fields) is a string, so a fetch with no headers option sends valid JSON labeled text/plain, and /api/echo reports bodyKind: "other". Many servers reject that, and the ones that sniff the body instead are doing you no favors. The last row is the one that produced the 500 above. Set the header only when you built the bytes yourself.

JSON's limits

JSON.stringify takes a JavaScript value and gives back a JSON string, and JSON has six types: object, array, string, number, boolean, and null. Everything else converts, disappears, or throws.

ValueAs an object propertyAs an array element
DateISO 8601 stringISO 8601 string
undefinedKey omittednull
FunctionKey omittednull
SymbolKey omittednull
Infinity, -Infinity, NaNnullnull
Map, Set{}, contents gone{}, contents gone
BigIntThrows TypeErrorThrows TypeError
An object referring to itselfThrows TypeErrorThrows TypeError

The two throws announce themselves. V8's messages are Do not know how to serialize a BigInt and Converting circular structure to JSON. The rest are silent. The request succeeds, the server stores what it got, and the field is missing.

JSON.stringify({ d: new Date(0) }); // '{"d":"1970-01-01T00:00:00.000Z"}' JSON.stringify({ a: 1, b: undefined }); // '{"a":1}' JSON.stringify([1, undefined, 3]); // '[1,null,3]' JSON.stringify({ m: new Map([['a', 1]]), s: new Set([1, 2]) }); // '{"m":{},"s":{}}' JSON.stringify({ x: Infinity, y: NaN }); // '{"x":null,"y":null}' JSON.stringify(undefined); // undefined, the value. Not a string, and not the text "undefined". // Passed straight to fetch as body it sends no body at all: // Content-Length: 0, no Content-Type, and /api/echo says bodyKind "none".

Date goes out as a string and comes back one

Date.prototype.toJSON calls toISOString, so JSON.stringify(new Date(0)) is "1970-01-01T00:00:00.000Z", in UTC, quotes included. JSON has no date type, so nothing on the way back turns it into one.

const sent = { postedAt: new Date() }; const back = JSON.parse(JSON.stringify(sent)); typeof sent.postedAt; // 'object' typeof back.postedAt; // 'string' back.postedAt.getTime(); // TypeError: back.postedAt.getTime is not a function

Agree on the string format with whoever runs the server, and call new Date(back.postedAt) on the way in. Pick ISO 8601 with an explicit offset.

Getting the others across

A Map becomes [...map], an array of pairs. A Set becomes [...set]. A BigInt becomes a string, and the server has to know it is one. Cycles need a real graph format, or a redesign of what you are sending. JSON.stringify takes a replacer function as its second argument if you want the conversion in one place.

Text encoding

Assume UTF-8 on every transmission, in both directions. Do not override it: JSON is UTF-8 by definition, and RFC 8259 states that no charset parameter is defined for application/json and that adding one has no effect on a compliant recipient. Check that the bytes you send are encoded the way you say. The file, the database column, and the header have to agree. Online the details matter: a title that renders as Café on somebody else's screen is UTF-8 bytes decoded with a single-byte table.

What is worth sending

Every byte in the body is paid for by somebody. On a slow connection it is paid in seconds.

Three fields cost 68 bytes urlencoded in the demo below. Turn on six more that /api/posts never reads and the same request grows by about 300 bytes, most of it a user agent string the server already has in a header. The endpoint validates title and author.

Don't send the whole object because you have it. A POST that updates a display name does not need the user's entire profile attached to it.

Don't send fields the server ignores. They cost bytes on every request, they end up in access logs, and the next person to read the code cannot tell which fields matter. A form with 40 fields where the endpoint reads 3 is a bug.

Don't send personal data you did not need to collect. A session id the server already has in a cookie, a user agent string it already has in a header, a precise location for a feature that wanted a city. Each one is now in a log, in a backup, and in whatever the logs get shipped to.

The reverse mistake exists too. Splitting one submit into four requests to keep each one small buys you four round trips, four chances to fail halfway, and four things to undo. Send what the endpoint reads, in one request.

Demo: one form, three encodings

The same fields, serialized three ways, POSTed to /api/echo, with the reflected bodyKind, Content-Type, byte count, and raw body in three panels side by side. The byte count is the Content-Length the server read off the request.

What changed from step 05: the transport selector is gone, because everything here runs on fetch. The outcome panel is replaced by the three payload panels. The instrumentation strip keeps the load counter, the request counter, the build time, and the query string, and adds the encoding, the bodyKind, and the byte count of the last body sent.

Five things to run.

The waterfall records all three POSTs to /api/echo as three rows, all 200. The demo builds its JSON body from the same FormData the other two use rather than from Object.fromEntries, which keeps only the last value when two controls share a name.

Next Steps

Every request in the demo above returned 200 or 201, except the one engineered to fail. Step 7: When it goes wrong is about the rest of the time:

Step 07

When it goes wrong

Steps 03 through 06 assumed an answer. One request went out, one response came back, and the only failure on any of those pages was a 500 built on purpose to make a point about boundaries. A request can also fail before it leaves the machine, hang with the connection open, come back with a status you did not want, or come back 200 with a body that will not parse. It can even come back correct, after a newer request already painted the screen. This step covers the eight ways it goes wrong, which of them are worth trying again, and what to do with a response body once you have it. Three endpoints were built for it: /api/slow, /api/flaky, and /api/bad-json.

Learning Objectives

What actually fails

Eight things go wrong. Five of them reject the fetch promise or throw on the way to the data, two resolve with a status you have to read, and one produces no error anywhere.

What failedWhat your code seesHow you tell
DNS did not resolveTypeError. In Chrome the message is Failed to fetch.You cannot, from the page. See below.
Connection refused, reset, or no network at allTypeError, Failed to fetchnavigator.onLine is false for the offline case only, and it lies on captive portals
TLS handshake rejected: expired certificate, name mismatch, unknown authorityTypeError, Failed to fetchThe console gets a net::ERR_CERT_ line. Your catch block does not.
Connected, request sent, nothing comes backA promise that stays pending until you impose a deadlineAbortSignal.timeout(), which rejects with a TimeoutError DOMException
4xx: the request was wrongThe promise resolves. response.ok is false.response.status: 400, 401, 403, 404, 409, 422, 429
5xx: the server or a gateway failedThe promise resolves. response.ok is false.response.status: 500, 502, 503, 504
200, correct Content-Type, body that will not parseThe promise resolves and response.ok is true. response.json() rejects.A SyntaxError from the parser, and only if you caught it
A response arrives after one you sent laterNothing. Every request succeeded.Your own bookkeeping, because the platform has none

The first three arrive as one exception with one message. Reporting which of them happened would let any page probe hosts it has no other way to see: whether a name resolves on this network, whether a port is open, whether an internal service is running. Chrome writes a net::ERR_NAME_NOT_RESOLVED or net::ERR_CERT_DATE_INVALID line to the console, where a human can read it. JavaScript gets TypeError: Failed to fetch for all of them. Firefox uses one message too, NetworkError when attempting to fetch resource.

Step 05 mapped those exceptions onto three kinds: 'network', 'timeout', and 'abort'. This step adds three more. 'status' for a response you got and did not want, 'content' for a response whose bytes are not what the header claimed, and 'body' for a transfer that broke before the last byte arrived.

The last row produces no error. Nothing on the platform reports it, so the Ordering section below is about finding it yourself.

Timeouts

fetch has no timeout option. If a server accepts the connection and then says nothing, the promise stays pending. The browser will eventually drop a socket that produces nothing, on a schedule that is not specified, not readable from JavaScript, and not the same on every network. Set your own deadline.

Picking the number

Pick it from what the user is doing, not from the endpoint's average response time. Jakob Nielsen's three limits, from Usability Engineering in 1993, still describe the attention you are spending: 0.1 seconds feels instant, 1 second is the limit for uninterrupted thought, and 10 seconds is the limit for keeping someone's attention on the task at all.

RequestDeadlineWhy that number
Autocomplete, one per keystroke1000 msThe answer is worthless once the user has typed the next letter. A slow one is not worth waiting for.
A list the user is watching load8000 msInside Nielsen's 10 seconds, with room for a retry to start before the user gives up.
An export the user asked for and expects to take a while30000 msThey chose to wait. Show progress and honor the choice.
A background write of a draft5000 msNobody is watching. Fail fast, keep the draft locally, and try again on the next tick.

Step 05 covered AbortSignal.timeout(), the clock that starts when you call it, and AbortSignal.any() for combining a deadline with a user's cancel.

const response = await fetch('/api/slow?ms=20000', { signal: AbortSignal.timeout(8000), });

AbortSignal.timeout() fires once and stays fired. Measured in Node 25.4.0, and the same in Chrome: after the timeout elapses, signal.aborted is true forever, signal.reason stays the TimeoutError, and AbortSignal.any([firedSignal]) comes back already aborted. Hand that signal to a second fetch and the second fetch rejects before it opens a connection. A retry loop needs a new signal on every pass.

Retries, and when not to

Retry when the failure was about the moment, not the request. Four kinds qualify.

What to retry

What not to retry

429 is the exception to the 4xx rule. It means you sent too many requests, so waiting is the fix, and the server usually says how long in a Retry-After header.

Idempotent is not the same as safe

RFC 9110 section 9.2.1 calls a method safe when its semantics are read-only. GET, HEAD, OPTIONS, and TRACE are safe. Section 9.2.2 calls a method idempotent when the intended effect of several identical requests is the same as the effect of one, and names PUT, DELETE, and the safe methods. DELETE is idempotent and not safe: send it twice and the resource is gone, which is the same state as sending it once.

MethodSafeIdempotentSafe to repeat when no response arrived
GET, HEADYesYesYes
OPTIONS, TRACEYesYesYes
PUTNoYesYes. It writes the same representation either way.
DELETENoYesYes, though the second one may answer 404
POSTNoNoNo
PATCHNoNoNo. {"count": "+1"} applied twice is +2.

Backoff, jitter, and a cap

Retrying immediately aims a second request at a server that just failed to answer the first. Exponential backoff spaces the attempts out: wait 300 ms, then 600, then 1200. The doubling does not stop on its own, so cap it. At a 300 ms base, an uncapped attempt 10 waits 154 seconds and attempt 12 waits over 10 minutes.

Add jitter. A server that falls over drops every connected client at once, and every one of those clients waits the same 300 ms and comes back together. Multiplying the delay by a random fraction spreads the returning clients across the whole window.

function backoffMs(attempt, options) { const settings = options || {}; const base = settings.base || 300; const factor = settings.factor || 2; const cap = settings.cap || 5000; const ceiling = Math.min(cap, base * Math.pow(factor, attempt - 1)); // Full jitter: a random point in [0, ceiling), not the ceiling itself. // Two clients that failed in the same second come back at different times. return Math.random() * ceiling; }

Retry-After is yours to read

fetch does not honor it. The string Retry-After does not appear anywhere in the Fetch standard. A 429 or a 503 carrying it resolves as fast as any other response, and the header sits in response.headers until you read it.

RFC 9110 section 10.2.3 defines two formats: Retry-After = HTTP-date / delay-seconds, where delay-seconds is a non-negative decimal integer. Both are in the RFC's own examples: Retry-After: 120 and Retry-After: Fri, 31 Dec 1999 23:59:59 GMT. The spec attaches it to 503 and to any 3xx; RFC 6585 attaches it to 429; RFC 9110 section 15.5.14 says a 413 SHOULD carry it when the condition is temporary. Handle both formats and clamp the result, because a server under load is entitled to say 3600 and your page is not going to wait an hour.

function retryAfterMs(response, cap) { const limit = cap || 30000; const header = response.headers.get('Retry-After'); if (!header) return null; // 1*DIGIT, and nothing else. Number() would take '0x10' as 16 and '1e3' // as 1000, neither of which is a delay-seconds value. const value = header.trim(); if (/^\d+$/.test(value)) { return Math.min(Number(value) * 1000, limit); } // Date.parse handles the IMF-fixdate form. A clock skewed the wrong way // gives a negative number, so floor it at zero. const when = Date.parse(header); if (Number.isNaN(when)) return null; return Math.min(Math.max(when - Date.now(), 0), limit); }

What the browser retries without asking

Three cases. A 500 is a completed exchange and a timeout is your deadline, so neither is on the list.

The loop

Step 05's transport returned one Result object for both fetch and XMLHttpRequest. This is that function with a loop around it, five kinds instead of three, and a fresh signal per attempt.

async function attemptOnce(url, init, combined, deadline, cancel) { let response; try { response = await fetch(url, Object.assign({}, init, { signal: combined })); } catch (cause) { // Ask the signals rather than the exception. Step 05 explains why // error.name is the fragile way to do this. The cancel is checked first // here: a user's cancel misread as a timeout would get retried. if (cancel && cancel.aborted) return { kind: 'abort', cause }; if (deadline.aborted) return { kind: 'timeout', cause }; return { kind: 'network', cause }; } // The headers arrived. The body still has to. A deadline can fire between // the two, and a stream can break on its own, so this read needs the same // treatment as the one above: every failure leaves as a kind, never as a // throw. Without this guard the rejection escapes the classifier and the // caller sees a raw DOMException it has no branch for. let text; try { // One read, kept as text, so a parse failure still has the bytes that // caused it. The body reads once; step 05 covers that. text = await response.text(); } catch (cause) { if (cancel && cancel.aborted) return { kind: 'abort', cause }; if (deadline.aborted) { return { kind: 'timeout', status: response.status, cause }; } return { kind: 'body', status: response.status, cause }; } let data = null; if (text !== '') { try { data = JSON.parse(text); } catch (cause) { return { kind: 'content', status: response.status, sample: text.slice(0, 200), cause, }; } } return { kind: response.ok ? 'ok' : 'status', status: response.status, retryAfterMs: retryAfterMs(response), data, }; } // Question one, about the failure: might a second attempt come out // differently? function retryable(outcome) { switch (outcome.kind) { case 'network': case 'timeout': case 'body': // the transfer broke, so the bytes were lost in transit return true; case 'abort': // the user said stop case 'ok': case 'content': // the bytes arrived intact and were not JSON return false; case 'status': // 429 is the one 4xx to repeat, and it usually tells you when. if (outcome.status === 429) return true; return outcome.status === 500 || outcome.status === 502 || outcome.status === 503 || outcome.status === 504; default: return false; } }

One function is not enough. retryable() reads the status, and no status tells you whether the server already did the thing. A 504 means a gateway stopped waiting for an origin that may have committed the write a moment later, which makes it more likely to be sitting on a completed side effect than a 500 is. The axis for side effects is the method, and RFC 9110 section 9.2.2 is where it lives. Ask both questions.

const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'PUT', 'DELETE']); // Question two, about the request: is sending it again safe? function repeatable(method, idempotencyKey) { if (IDEMPOTENT.has((method || 'GET').toUpperCase())) return true; // A key is the "means to detect that the original request was never // applied" that RFC 9110 asks for. Without one, a POST stops here. return Boolean(idempotencyKey); } async function request(url, options) { const settings = options || {}; const attempts = settings.attempts || 3; const timeoutMs = settings.timeoutMs || 8000; const cancel = settings.signal; const method = (settings.init && settings.init.method) || 'GET'; let outcome = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { // New signal every pass. A fired AbortSignal.timeout() stays fired, so // reusing one aborts attempt 2 before it opens a connection. const deadline = AbortSignal.timeout(timeoutMs); const combined = cancel ? AbortSignal.any([deadline, cancel]) : deadline; outcome = await attemptOnce(url, settings.init, combined, deadline, cancel); outcome.attempt = attempt; if (attempt === attempts || !retryable(outcome)) return outcome; // Both questions, every pass. A POST with no key stops on the first // failure however retryable the status looks. if (!repeatable(method, settings.idempotencyKey)) { outcome.stoppedBecause = 'not repeatable'; return outcome; } // The server's number if it gave one, otherwise your own. const server = outcome.retryAfterMs; await sleep(server === null || server === undefined ? backoffMs(attempt) : server, cancel); } return outcome; } function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal && signal.aborted) { reject(signal.reason); return; } const timer = setTimeout(resolve, ms); if (signal) { // Cancel during the wait, not just during the request. signal.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); } }); }

Count the worst case before you ship it

Three attempts at an 8-second deadline is 24 seconds of request time. Add the backoff waits, up to 300 ms and 600 ms, and a user watching a list can wait 24.9 seconds to be told it failed. Nielsen's outer limit was 10. Either cut the attempts, cut the deadline, or put a total budget around the whole loop and stop when it runs out. The demo below defaults to a 2000 ms deadline and 3 attempts for the same reason.

Content errors

/api/bad-json answers 200 with Content-Type: application/json; charset=utf-8 and this body:

{"posts": [{"id": 1, "title": "truncated"

Everything a status check looks at is correct. response.ok is true, response.status is 200, and the Content-Type is the one you wanted. The bytes stop in the middle of an object.

async function load() { const response = await fetch('/api/bad-json'); if (!response.ok) { showError(response.status); return; } // Rejects. The measured message in Chrome 151 is // SyntaxError: Failed to execute 'json' on 'Response': Expected ',' or // '}' after property value in JSON at position 41 (line 1 column 42) render(await response.json()); }

Real causes: a proxy that truncated the body, a load balancer that returned its own HTML error page under the upstream's headers, a handler that wrote the header and then threw, a captive portal answering for a network you have not signed into yet. Checking Content-Type does not help here, because this endpoint sends the right one.

Guard the parse and keep the text. response.json() consumes the body, so a rejection there leaves you with nothing to log. Read response.text() once and parse the string yourself, and the first 200 characters are still in hand when it fails.

async function load() { const response = await fetch('/api/bad-json'); const text = await response.text(); let data = null; try { data = text === '' ? null : JSON.parse(text); } catch (cause) { // Its own kind. Not a network failure, and not a bad status, because the // network worked and the status was 200. report('content', response.status, text.slice(0, 200), cause); return; } render(data); }

Parsing is not the last check either. A body can be valid JSON and still be the wrong shape: a 404's error object where you expected a list, null where you expected an array, a count that arrived as a string.

function usable(data) { // A 404's error object is valid JSON. So is null, and so is an empty // string once you allow for it. return Boolean(data) && Array.isArray(data.posts); } if (usable(data)) { render(data.posts); } else { report('content', response.status, 'parsed, and carried no posts array'); }

Ordering

A search box fires a request per keystroke. The user types abcd in under half a second, so four requests are open at once. HTTP has no rule that says answers come back in the order the questions went out, and they frequently do not: different connections, different cache states, different amounts of work per query.

Sent atQueryServer tookPainted at
0 msa900 ms900 ms, last
120 msab400 ms520 ms
240 msabc200 ms440 ms
360 msabcd100 ms460 ms

At 900 ms the input says abcd and the list underneath it is the answer for a. All four requests returned 200. No exception was thrown and nothing was logged. On a fast connection the spread is small enough that the bug never shows up in development.

Fix one: abort the previous request

Keep the controller for the request in flight. When a new one starts, abort the old one. The old fetch rejects with an AbortError, the connection is released, and the response never reaches your render function.

let inFlight = null; async function search(query) { if (inFlight) inFlight.abort(); const cancel = new AbortController(); inFlight = cancel; try { const response = await fetch('/api/posts?q=' + encodeURIComponent(query), { signal: cancel.signal, }); render(await response.json()); } catch (error) { // Your own abort lands here. It is not a failure and it is not the // user's problem, so do not put it on the screen. if (error.name !== 'AbortError') showError(error); } finally { if (inFlight === cancel) inFlight = null; } }

Fix two: number the requests

Give every request a ticket. After the await, compare the ticket against the highest one already painted and drop anything older. Take the comparison and the claim in the same synchronous step, because two responses can be sitting in the microtask queue at the same time.

let issued = 0; let painted = 0; async function search(query) { const ticket = issued + 1; issued = ticket; const response = await fetch('/api/posts?q=' + encodeURIComponent(query)); const data = await response.json(); if (ticket <= painted) return; // something newer already won painted = ticket; render(data); }

Use abort

The ticket lets every request run to completion. The bytes still arrive, the connection stays busy, and the server does the work for three queries nobody will read. Abort stops the transfer, frees the connection for the request you care about, and gives the server a chance to notice the client left.

The ticket earns its place when you cannot cancel: a POST already on the wire, a request shared by two call sites, or work that has to finish for reasons other than painting. The demo has all three.

Never trust the response

A response body is input from outside your program. That is true of a third-party API, and it is true of your own endpoint, because your own endpoint stores what somebody typed into it. Somebody typed <img src=x onerror=alert(document.cookie)> into your title field and pressed Submit.

JSON.parse executes nothing. It is not eval, it does not run functions, and there is nothing in the JSON grammar that can call anything. Parsing that title gives you a 42-character string.

const post = await response.json(); // post.title is the 42 characters // <img src=x onerror=alert(document.cookie)> // Text. The angle brackets render as angle brackets and nothing loads. titleEl.textContent = post.title; // HTML. The parser builds an <img>, the load fails, onerror runs. titleEl.innerHTML = post.title;

textContent writes characters. It never invokes the HTML parser, so no element, attribute, or handler can come out of it. Use it for every value that came off the wire.

What textContent does not cover

It protects the text of an element. Four things sit outside that.

function safeHref(value) { let url; try { url = new URL(value, location.href); } catch { return null; } // javascript:, data:, and blob: all parse into a valid URL. Allow two. if (url.protocol === 'https:' || url.protocol === 'http:') return url.href; return null; } const href = safeHref(post.url); if (href) link.href = href;

"__proto__" in a payload

JSON.parse handles this key safely and the code around it often does not. Parsing {"__proto__": {"isAdmin": true}} gives an object with an own data property named __proto__. The prototype is untouched, the setter on Object.prototype is never invoked, and no other object in the page changes. An object literal with the same text behaves differently: it sets the prototype.

const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}'); Object.getPrototypeOf(parsed) === Object.prototype; // true Object.keys(parsed); // ['__proto__'] ({}).isAdmin; // undefined // Spread copies the key as an ordinary own property. Object.getPrototypeOf({ ...parsed }) === Object.prototype; // true // Object.assign writes with [[Set]], which finds the __proto__ setter on // Object.prototype and swaps the target's prototype for the payload. const copy = Object.assign({}, parsed); Object.getPrototypeOf(copy) === Object.prototype; // false copy.isAdmin; // true // A recursive merge is worse. It reads target['__proto__'], gets // Object.prototype, and writes the payload's keys onto it. merge({}, parsed); ({}).isAdmin; // true, on every object in the page

Reject the key on the way in if you merge parsed data into anything. JSON.parse(text, reviver) takes a reviver that can return undefined for a key named __proto__, which drops it. Object.create(null) for the target of a merge removes the setter from the picture entirely.

Demo: retries, timeouts, and a race

Two experiments on one page. The top half runs the loop from this step against whichever failure endpoint you pick and logs every attempt with its status and its elapsed milliseconds. The bottom half fires three requests at /api/slow with descending delays so the answers come back in reverse, with a guard you can switch between off, a ticket, and abort.

What changed from step 06: the three encoding panels are gone, because every request here is a GET. An endpoint selector, a timeout field, an attempt count, and a backoff toggle replace them. The instrumentation strip keeps the load counter, the request counter, the build time, and the query string, and adds the attempts on the last run, how it ended, and the total time spent waiting between attempts.

Six things to run.

The waterfall records every attempt as its own row, so a run of three against /api/flaky is three entries with three statuses. Aborted requests appear there too. The ticket guard leaves three completed rows; the abort guard leaves one completed and two canceled.

Next Steps

Everything so far has talked to cse134.site from a page served by cse134.site. Step 8: Other people's data changes the origin:

Step 08

Other people's data

Every request in this series so far went from a page on cse134.site to an endpoint on cse134.site. Change the host name in the URL and the same code stops working, with an error that names nothing. This step is CORS as your code experiences it: what you get told when a read is refused, what makes the browser ask permission before it sends anything, what turning on cookies costs, and what you take on when you route the call through a server of your own. The mechanism and the reasoning behind it are in The security model, module 7 of the browser series.

Learning Objectives

The failure has no detail

An origin is a scheme, a host, and a port. Any one of the three is enough to make two URLs different origins. https://cse134.site and https://cse134site.tpowell.workers.dev differ in the host. http://cse134.site and https://cse134.site differ in the scheme. http://localhost:8787 and http://localhost:3000 differ in the port. The two host names in this step's demo run the same Worker, off the same source file, in the same account.

Ask one of them for something from a page served by the other and your code gets this:

What you readfetchXMLHttpRequest
The callThe promise rejects with a TypeErrorThe error event fires. load does not.
statusThere is no Response object to ask0
statusTextSameThe empty string
Response headersSamegetAllResponseHeaders() returns the empty string
Response bodySameresponseText is the empty string
Why it was blockedNothing. The message is the same one a dead DNS name produces.Nothing. The error event carries no reason.

Step 07 listed TypeError: Failed to fetch as the report for DNS failure, connection failure, and a rejected certificate. A CORS block is the fourth thing that produces it. Firefox uses its one message, NetworkError when attempting to fetch resource., for the same set.

The browser knows the reason and writes it to the console. Chrome prints Access to fetch at '…' from origin '…' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Firefox prints Cross-Origin Request Blocked with a Reason: clause naming the missing or wrong header. Neither string is reachable from JavaScript.

The Network panel does not rescue you either. Measured in Chrome 151: the row for a blocked read shows the request and every header it sent, including Origin, and its Status column reads CORS error. The status the server returned and the headers it returned are not in it. The console line beside it names the reason, and the Issues panel repeats it.

Even a read that works is filtered

A cross-origin response you are allowed to read still arrives with most of its headers hidden. Seven names are on the safelist:

Cache-Control Content-Language Content-Length Content-Type Expires Last-Modified Pragma

Everything else reads as null until the server names it in Access-Control-Expose-Headers. This is the answer to the question step 04 left open about getAllResponseHeaders(): same-origin it returns everything, cross-origin it returns these seven plus whatever was exposed.

const response = await fetch('https://api.example.com/v1/posts'); response.headers.get('Content-Type'); // 'application/json', safelisted response.headers.get('X-Request-Id'); // null, until the server exposes it response.headers.get('X-RateLimit-Remaining'); // null, same reason Access-Control-Allow-Origin: https://cse134.site Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining

Access-Control-Expose-Headers: * works, and only for a request without credentials. Debugging an integration where the server sends a request ID you cannot see is a normal afternoon. Ask for it to be exposed.

What triggers preflight

Some cross-origin requests go straight out. The rest are held while the browser sends an OPTIONS request to ask whether the real one is allowed. MDN calls the first kind simple requests and notes that the Fetch standard, which defines CORS, does not use that term. The standard states conditions instead, and a request avoids the preflight when all three hold.

HeaderWhat the value may containStays simple
AcceptAnything with no CORS-unsafe byte in itapplication/json
Accept-LanguageOnly 0-9, A-Z, a-z, space, and *,-.;=en-US,en;q=0.9
Content-LanguageThe same seten-US
Content-TypeNo CORS-unsafe byte, and the essence must be application/x-www-form-urlencoded, multipart/form-data, or text/plaintext/plain;charset=UTF-8
RangeA single byte range with a start, the shape a media element sendsbytes=0-1023

Two size rules sit on top of that. A single safelisted header whose value runs past 128 bytes stops being safelisted. If the safelisted headers add up to more than 1024 bytes together, all of them are treated as unsafe at once.

A CORS-unsafe request-header byte is any byte below 0x20 other than tab, plus ", (, ), :, <, >, ?, @, [, \, ], {, }, and DEL. The parameter on Content-Type is allowed: text/plain;charset=UTF-8 contains no unsafe byte and its essence is text/plain, so it is safelisted. Only the essence is compared, so parameters cannot rescue an unsafe type.

What goes over the wire

The preflight is an OPTIONS request with no body, carrying the origin and a description of the request the browser is holding.

OPTIONS /api/posts HTTP/1.1 Host: cse134site.tpowell.workers.dev Origin: https://cse134.site Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type

Access-Control-Request-Headers lists the unsafe header names, lowercased and sorted. The preflight carries no cookies and no Authorization header, whatever the real request is going to carry.

HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://cse134.site Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Requested-With Access-Control-Max-Age: 86400 Vary: Origin
Response headerWhat it answers* allowedWith credentials
Access-Control-Allow-OriginWho may read the responseYesNo. Name the origin.
Access-Control-Allow-MethodsWhich methods the preflight approvesYesNo. List them.
Access-Control-Allow-HeadersWhich unsafe headers may be sentYes, except AuthorizationNo. List them.
Access-Control-Expose-HeadersWhich response headers script may readYesNo. List them.
Access-Control-Max-AgeHow long this answer may be cachedNot applicableUnchanged

Authorization is the one exception the Fetch standard writes into the algorithm by name. It calls it a CORS non-wildcard request-header name, and a * in Access-Control-Allow-Headers does not cover it. Name it or the preflight fails.

The preflight response also has to succeed. A 204 or a 200 works; a 401, a 404, or a 500 on the OPTIONS fails the preflight and the real request never leaves. Servers that require authentication on every route, OPTIONS included, produce this and it looks like a CORS bug rather than an auth bug.

Access-Control-Max-Age is a suggestion

The browser caches a preflight answer per origin, per URL, per method, and per header set, so a second identical call skips the OPTIONS. The header names a number of seconds. Every browser caps it.

BrowserLongest it will cacheDefault when the header is absent
Firefox86400 seconds, 24 hoursNot documented
Chromium, version 76 and later7200 seconds, 2 hoursCaches for 5 seconds
Chromium, before version 76600 seconds, 10 minutesCaches for 5 seconds

The Worker behind this series sends 86400. Firefox honors it, Chrome stores 7200 instead, and both drop the entry early when the cache is cleared or the network changes. Send the header, and do not build a latency budget that depends on the answer surviving.

What the extra round trip costs

One request becomes two, in sequence. On a 40 ms round trip a preflighted call starts 40 ms late; on a phone on a bad connection it is 200 ms or more, before the server has done any work. Three ways to stop paying it, best first:

Credentials

A cross-origin fetch sends no cookies by default. The credentials option defaults to 'same-origin': cookies on your own origin, nothing on anybody else's. XHR does the same with withCredentials defaulting to false.

// fetch const response = await fetch('https://api.example.com/v1/me', { credentials: 'include', }); // XMLHttpRequest const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.example.com/v1/me'); xhr.withCredentials = true; xhr.send();

That one line moves the whole exchange into a stricter mode, and the server has to answer differently or the browser blocks the read.

Naming the origin means reading Origin off the request and reflecting it. The response now varies by request, so send Vary: Origin or a shared cache will hand one origin's response to another. worker/api/http.js in this repository does all three: an allowlist, a reflected origin, and the Vary.

Getting all four right and still seeing no cookie is common, and the reason is usually the cookie rather than CORS. Chrome treats a cookie with no SameSite attribute as SameSite=Lax, which is not sent on a cross-site request. Reaching it needs SameSite=None; Secure from the server that set it. Firefox does not apply the Lax default, having tried and reverted it, so the same page can work in one browser and not the other. Module 7 covers the cookie flags.

mode: 'no-cors' is not a way around any of this

The name suggests it skips the CORS check. It hands you a response object with nothing in it.

const response = await fetch('https://api.example.com/v1/posts', { mode: 'no-cors', }); response.type; // 'opaque' response.status; // 0 response.ok; // false response.headers; // empty, every get() returns null await response.text(); // ''

The promise resolves. Nothing threw, and there is no data. The request is also restricted to GET, HEAD, and POST with safelisted headers, the same list that avoids a preflight. Two jobs it does honestly are firing a request whose answer you do not need, and storing a third-party asset in the Cache for a service worker to serve later.

The proxy

CORS is enforced by browsers. Your server is not a browser. It can fetch anything, and it can serve the result from your own origin, where the same-origin policy has nothing to say.

const UPSTREAM = 'https://api.example.com'; // Pin the host and the paths. A proxy that forwards whatever URL it is // handed is an open proxy, and open proxies get found and used. const ALLOWED = new Set(['/v1/posts', '/v1/tags']); export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname.replace('/api/upstream', ''); if (!ALLOWED.has(path)) { return new Response('Not proxied', { status: 404 }); } const target = new URL(path + url.search, UPSTREAM); const cache = caches.default; const hit = await cache.match(target.href); if (hit) return hit; const response = await fetch(target.href, { headers: { // The key lives here. No client-side build tool can hide it; a // server does not have to. Authorization: 'Bearer ' + env.UPSTREAM_KEY, Accept: 'application/json', }, }); // Your cache policy now, not theirs. Say a number and own it. const out = new Response(response.body, response); out.headers.set('Cache-Control', 'public, max-age=300'); ctx.waitUntil(cache.put(target.href, out.clone())); return out; }, };

From the page it is fetch('/api/upstream/v1/posts'). No preflight, no allowlist, no exposed headers, no opaque failure. Six things moved onto your side.

JSONP, and why it is gone

Before CORS there was one hole in the same-origin policy that had always been there: <script src> may point anywhere. JSONP used it. You added a script tag pointing at the remote API with a callback name in the query string, and the server answered with JavaScript instead of JSON: handlePosts({"posts": […]}). The browser ran it, your function received the data, and no policy was violated because nothing had read a response.

The remote server is executing arbitrary JavaScript in your page, with your cookies and your DOM. It is GET-only, it has no error handling worth the name, and one compromised provider owns every page that included it. CORS replaced it.

Demo: two origins, one Worker

The site answers on two host names, cse134.site and cse134site.tpowell.workers.dev. Both serve the same Worker and the same demo file, so which one is same-origin depends entirely on the address bar. The page resolves both from location.origin when it loads and labels them at runtime. Open it on the other host and the two lines at the top trade places, along with every result underneath them.

What changed from step 07: the retry loop, the endpoint selector, and the race are gone. Every call here is one attempt, because a blocked read is not a failure a second try can fix. The instrumentation strip keeps the load counter, the request counter, the build time, and the query string, and adds a count of the reads the browser refused to hand over.

Six things to run.

The waterfall at the bottom of the demo does not show the OPTIONS. It records what the page's script does, by wrapping fetch and XMLHttpRequest, and the preflight is sent by the browser between your call and your request. No wrapper inside the page can see it. Use DevTools for that row.

Data nobody promised you

CORS is about permission to read. The rest of this step is about what you are reading and whether it will be there tomorrow.

Scraping

Screen scraping is parsing a page that was never meant to be parsed. There is no contract. A designer renames a class, a framework upgrade changes the DOM, an A/B test serves half your requests a different layout, and your selector returns an empty list.

The failure mode is worse than an outage. A selector that matches the wrong element keeps returning strings, and the strings are wrong. Check the shape of what you extracted before you use it, the same way step 07 checked a parsed body.

Anything served to a browser can be read by a program. Whether you may is a different question, answered by the site's terms, its robots.txt, and the volume you send. Scraping cases have gone both ways in court and the law is still moving. The engineering answer does not depend on how they come out: identify yourself, request at a human rate, cache what you take, and stop when you are asked to.

Mashups

A mashup combines sources into something none of them offered. The canonical one is a map with somebody else's listings on it, and around 2006 that pattern was most of what "Web 2.0" meant. Every source is a dependency you do not control and cannot fix.

Google Maps moved to pay-as-you-go pricing on June 11, 2018, with a $200 monthly credit that covered small sites. That credit was retired in March 2025 and replaced with a smaller per-product free tier. Twitter announced the end of free API access for February 9, 2023, and the $100 entry tier arrived that March. Both changes were announced in advance and permitted by the terms the developers had agreed to.

What to actually do

Next Steps

Every request in this series so far has been one question and one answer, started by the page. Step 9: Beyond request and response covers the shapes that are not that:

Step 09

Beyond request and response

Every request in the last eight steps had the same shape. The page asked, the server answered, your code read the answer. The course deck splits communication along two axes: one-way or two-way, synchronous or asynchronous. Steps 03 through 08 filled one box, two-way and asynchronous. This step covers the other boxes: a message you send and cannot read an answer to, a connection the server writes to without being asked again, and a connection both ends write to.

Learning Objectives

One way out: navigator.sendBeacon

Analytics, error reports, and "how long did they read this" all want to send something as the user leaves. The obvious code does not work. When a document goes away the browser tears down its fetch group and cancels the requests in it, and a fetch started in an exit handler is usually one of them.

window.addEventListener('pagehide', () => { // Started, then canceled with the document that started it. fetch('/api/beacon', { method: 'POST', body: report() }); });

navigator.sendBeacon(url, data) is the fix. It hands the data to the browser, which owns the request from then on, and returns a boolean immediately. There is no promise, no callback, and no response object. You never learn what the server said.

window.addEventListener('pagehide', () => { const queued = navigator.sendBeacon('/api/beacon', JSON.stringify(report())); // true means the browser took it. Nothing here can tell you it arrived. if (!queued) saveForNextVisit(report()); });
What it returnsWhat that means
trueThe browser queued the data for transfer. Not that it was sent, not that it arrived, not that the server answered 2xx. The transfer happens after your code returns. The Beacon specification says so.
falseThe browser refused to queue it. The specification names one cause: the payload would push the keepalive budget over its limit.
A thrown TypeErrorThe URL did not parse, or its scheme is not http or https. Chrome 151 says Beacons are only supported over HTTP(S).

The request is a POST, always, with credentials included, so cookies go with it. The Content-Type is decided by what you pass, and that choice decides whether a cross-origin beacon preflights. Measured on the wire in Chrome 151 against /api/echo:

What you passContent-Type sentRequest modePreflights cross-origin
A stringtext/plain;charset=UTF-8no-corsNo
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8no-corsNo
FormDatamultipart/form-data; boundary=…no-corsNo
ArrayBuffer, a typed array, or an untyped BlobNoneno-corsNo
new Blob([json], { type: 'application/json' })application/jsoncorsYes

fetch with keepalive

fetch(url, { keepalive: true }) sets the same flag on the request that sendBeacon sets, so the browser keeps it alive past the document. It is the more capable of the two: any method, your own headers, and a real Response, if the page survives to read it.

document.addEventListener('visibilitychange', async () => { if (document.visibilityState !== 'hidden') return; // No AbortSignal on this call. A controller that aborts on pagehide // would cancel the request keepalive exists to preserve. const response = await fetch('/api/beacon', { method: 'POST', body: JSON.stringify(report()), keepalive: true, }); console.log(response.status); // 202, if this page is still running });

Both share one budget. The Fetch standard adds up the bodies of every keepalive request still in flight in the document's fetch group, adds the new one, and returns a network error if the total is over 64 KiB.

navigator.sendBeacon('/api/beacon', 'x'.repeat(70 * 1024)); // false, over the limit on its own navigator.sendBeacon('/api/beacon', 'x'.repeat(60 * 1024)); // true navigator.sendBeacon('/api/beacon', 'x'.repeat(1 * 1024)); // true, 61 KiB in flight navigator.sendBeacon('/api/beacon', 'x'.repeat(10 * 1024)); // false, 71 KiB would be

The 1 KiB call and the 10 KiB call are both small, and only the second one fails. The budget is summed. Over it, sendBeacon returns false and fetch rejects with a TypeError. Send small payloads, and if you have a lot to report, send it during the session rather than at the end of it.

Fire it on the right event

Having a transport that survives the page does not tell you when to use it. Pick the wrong event and the code never runs, or it runs and costs the user a slower back button.

unload is not the answer, and is going away

beforeunload is not a substitute. Its job is the "you have unsaved changes" prompt and nothing else. Chrome caches a page that has one; MDN says Firefox will not.

The two events to use

Send on visibilitychange when document.visibilityState is 'hidden'. That fires when the tab is switched, when the window is minimized, when the phone goes to the home screen, and on the way out of the page. Add pagehide for the desktop case where a tab is closed without ever being hidden.

let dirty = true; function send(trigger) { if (!dirty) return; dirty = false; navigator.sendBeacon('/api/beacon', JSON.stringify(report(trigger))); } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send('hidden'); else dirty = true; }); window.addEventListener('pagehide', (event) => { send('pagehide'); // persisted true means the document went into the back/forward cache and // can come back. Tearing down listeners here would restore a dead page. if (!event.persisted) teardown(); }); window.addEventListener('pageshow', (event) => { if (event.persisted) dirty = true; });

A page entering the back/forward cache fires pagehide with persisted set to true, then visibilitychange, and no unload. Coming back fires pageshow with persisted true, and visibilitychange again. Both handlers above run more than once in a session that uses the back button. Make the payload safe to send twice.

Say what you are sending

An exit beacon is a request the user cannot see, cannot cancel, and gets no feedback about. Analytics beacons routinely carry a session ID, the URL, the referrer, scroll depth, and how long the page was open, which together identify a person's reading across a site. Write down what is in the payload, put it in the privacy policy, and drop the fields nobody has asked a question about. The demo below prints its payload on the page before it sends it.

One way in, before the browser could receive

HTTP is a request and a response. The client speaks first, every time. For the first decade of the web there was no way for a server to say anything to a page that had not just asked, and applications that needed it built three workarounds. Alex Russell named the family Comet on March 3, 2006, in a post called Comet: Low Latency Data for the Browser. His own gloss on the name was "it doesn't stand for anything, and I'm not sure that it should."

Poll faster

Ask again on a timer. setInterval and a fetch every two seconds, and the page is never more than two seconds behind. Every one of those requests costs a round trip, a set of headers, and a handler invocation on the server, and almost all of them answer "nothing new." Halving the delay doubles the cost and halves the lag.

Long polling

Send the request and let the server hold it. No answer comes back until there is something to say or a timeout fires, and the client sends the next request the moment the last one lands. The lag drops to the network time. The cost is a server thread or connection parked per idle client, which killed it on the thread-per-request servers of 2006, and a gap between the response arriving and the next request leaving where an event can be missed.

The long slow load

Return a response and never finish it. The server sends a chunk, flushes, and keeps the connection open, and the browser processes what has arrived. The 2006 version put a hidden <iframe> on the page and wrote <script> tags into it, each one calling a function in the parent document. Proxies that buffered responses broke it, the browser's loading indicator spun the entire time, and the document's memory use grew for as long as the stream lasted.

All three are the same request-response protocol with the client arranging to always have a request in flight. Server-sent events and WebSocket replaced the arrangement with a protocol feature. RFC 6455 standardized WebSocket in December 2011.

Server-sent events

One line opens a connection the server writes to for as long as it wants.

const es = new EventSource('/api/stream?count=10'); // The default event name is 'message'. Anything the server names in an // event: field needs its own listener. es.addEventListener('tick', (event) => { const data = JSON.parse(event.data); console.log(data.n, 'of', data.of); }); es.addEventListener('done', () => { es.close(); }); es.addEventListener('error', () => { // No status, no reason. readyState says what happens next. console.log(es.readyState); // 0 means it is reconnecting, 2 means it stopped });

The server answers with Content-Type: text/event-stream and writes records. A record is one or more fields, one per line, followed by a blank line. The blank line dispatches the event.

event: tick data: {"n":1,"of":10} event: tick data: {"n":2,"of":10} event: done data: {}
FieldWhat it doesDefault when absent
event:Names the event, so addEventListener('tick', …) receives itmessage, delivered to onmessage and to addEventListener('message', …)
data:The payload. Repeat the line and the values are joined with a newline.The event is not dispatched at all
id:Stores an ID the client sends back as Last-Event-ID on its next reconnectNo header on reconnect, so the server cannot resume
retry:Sets the reconnection delay in milliseconds, for this and every later reconnectImplementation-defined. Chrome uses 3000, Firefox 5000.
: at the start of a lineA comment, ignored. Servers send one every so often to keep an idle connection from being closed by a proxy.Nothing

The stream is UTF-8, and the specification allows no other encoding. The client sends a GET and cannot send anything else, cannot set a request header, and cannot receive binary. The constructor takes a URL and one option, withCredentials. Cross-origin, an EventSource is subject to CORS like any other read, so step 08 applies to it in full.

Resuming, and the connection budget

Send an id: with each record and the client stores it. On the next reconnect it sends the last one back in a Last-Event-ID request header, and a server that keeps a log can send what was missed. Without id: there is no header, and a reconnect is a fresh start with a gap in it. The endpoint in this series sends no id:, so the demo's lastEventId column is empty in every row.

Over HTTP/1.1 a browser opens at most six connections per origin, and an open EventSource holds one of them for its whole life. Six streams to one host and every other request to that host waits, across all tabs, because the limit is per browser and origin rather than per page. Over HTTP/2 the streams are multiplexed on one connection and the ceiling is SETTINGS_MAX_CONCURRENT_STREAMS, which the server advertises and RFC 9113 recommends be no lower than 100. Serve SSE over HTTP/2.

WebSocket

Both ends send whenever they have something to send, over one connection, in text or binary.

const socket = new WebSocket('wss://example.com/room/42'); socket.addEventListener('open', () => { socket.send(JSON.stringify({ type: 'join', room: 42 })); }); socket.addEventListener('message', (event) => { // A string, or a Blob or ArrayBuffer if binaryType says so. console.log(event.data); }); socket.addEventListener('close', (event) => { console.log(event.code, event.reason, event.wasClean); });

It starts as HTTP. The browser sends a GET with an Upgrade header, and a server that agrees answers 101 and stops speaking HTTP.

GET /room/42 HTTP/1.1 Host: example.com Origin: https://cse134.site Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Key is a random 16-byte nonce, base64 encoded. The server appends a fixed public GUID, takes the SHA-1 of that string, base64 encodes the digest, and returns it as Sec-WebSocket-Accept. It proves the responder understood the request as a WebSocket handshake rather than a cache or a plain HTTP endpoint being talked into forwarding frames.

What you give up

After the 101 the connection is a frame stream. Nothing from HTTP applies to what travels on it.

A close event carries a code. 1000 is a normal close and 1001 means one end is going away, including the browser navigating off the page. 1006 never travels on the wire: the browser writes it locally when the connection died without a close frame, and event.wasClean is false. Most production reconnect logic is written against 1006.

ws: and wss: are the two schemes, on ports 80 and 443. A page served over HTTPS cannot open a ws: connection: it is blockable mixed content, it is not upgraded for you, and there is no prompt. Use wss:.

When it earns the connection

Use a WebSocket when the server initiates and the messages are frequent. Chat, multiplayer game state, collaborative editing, live cursors, a trading feed. All of them carry traffic both ways, often enough that a round trip per message is the cost that matters.

A notification badge that changes twice an hour is not that. Neither is a dashboard that refreshes on the minute, or a progress bar for a job on your own server. Poll the first two and use SSE for the third. Each held socket is a connection, a load-balancer slot, and per-client state on a server that could otherwise have forgotten you between requests. You write the reconnect and the heartbeat.

PollingSSEWebSocket
DirectionClient asksServer sendsBoth
ProtocolHTTPHTTPHTTP for the handshake only
PayloadAnythingUTF-8 textText or binary
ReconnectNot applicableAutomatic, with Last-Event-IDYours to write
Caching, status codes, headersAll of HTTPAll of HTTPHandshake only
Cross-originCORSCORSOrigin header, checked by the server
Cost while idleA request per intervalOne held connectionOne held connection

Demo: a stream in, a beacon out

What changed from step 08: the second origin, the preflight, and the posts list are gone. Every call is same-origin, and none of them is a question with an answer you read. The instrumentation strip keeps the load counter, the build time, and the query string, and counts ticks, beacons, and restarts.

Five things to run.

The panel records this page's traffic by wrapping fetch and XMLHttpRequest, which covers every call steps 03 through 08 made and one of the three this page makes. EventSource and navigator.sendBeacon are separate APIs, so nothing wrapping fetch sees them. Open DevTools and select the Network panel: the stream is one row of type eventsource that stays pending for the length of the run, and each beacon is its own POST row.

The demo has no WebSocket in it. The Worker behind this series has no WebSocket route and adding one to teach the client API is more server than the series is willing to write. The handshake above is what goes over the wire; run it against a local ws:// server or a public echo service to watch it happen.

Closing the series

Step 01 was a form with no JavaScript in it. It serialized its fields, chose a method, sent the request, handled the response, and told the user what happened, and you wrote none of that. Every step since replaced one piece of it with code of your own. You took the request, the encoding, the error handling, the retry, the permission check, and finally the connection.

Each replacement bought something the form could not do. Each one also moved a job the browser was already doing onto you, permanently. Ask two questions of the next network API you pick up: what was the browser doing here, and what happens to that now.

The APIs series continues into storage and the observers. The Browser for Programmers is the other half of every step here: module 2 for what happens between the URL and the first byte, module 7 for the security model behind step 08, and module 8 for what the browser keeps after the response is read.