AJAX: When a Request Stopped Meaning a Page Reload
- date
- category
- Frontend
- also in
- Backend · Networking
- reading
- 3 min / 696 words
A form ties the HTTP request to document navigation. The browser reads the response: it checks the status, follows redirects on its own and renders the HTML.
AJAX separated those two things.
navigation:
request -> the browser reads the response -> new document
XHR:
request -> JavaScript reads the response
The protocol stayed the same. What changed is who receives the response.
XMLHttpRequest is an HTTP client
The mechanism that made it possible was XMLHttpRequest.
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/users/42");
xhr.onload = () => {
const user = JSON.parse(xhr.responseText);
console.log(user);
};
xhr.send();
The server receives an ordinary HTTP request:
GET /api/users/42 HTTP/1.1
Host: example.com
Accept: */*
Cookie: session=8f2c...
It is worth noticing what is missing from that code. There is no opening a connection, no attaching cookies, no checking the cache, no handling of 3xx.
The request travels through the same network stack as navigation, so it gets exactly the same things: origin cookies, the connection pool, the HTTP cache and automatic redirect following.
XHR is not a separate protocol or a separate channel. It is a programmatic entry point into the same HTTP the browser uses to load pages.
The response status stopped being the browser's business
During navigation the browser handles the response codes:
200 -> renders the document
301 -> goes to the new address and changes the address bar
404 -> renders the error document sent by the server
500 -> renders the error document sent by the server
With XHR none of that happens.
xhr.onload = () => {
if (xhr.status === 404) {
// this is a successfully completed request
}
};
onload means "a response arrived", not "everything went well". onerror says that no response could be obtained at all: DNS, TLS, a dropped connection, a browser policy block.
That distinction is worth remembering, because it later carries over to fetch() unchanged:
a 500 response -> the request succeeded, the app has to notice for itself
no response -> the request failed
Redirects are the exception in the other direction. The browser still follows them itself, and JavaScript only sees the final response and its address in xhr.responseURL.
Request headers now belong to the application
During navigation the browser decides the headers. With XHR some of them are set by code:
xhr.open("POST", "/api/users");
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ name: "Alice" }));
That enables something a form could not do: the same address can serve both a navigation and a call from code, with Accept deciding the format of the response.
Accept: text/html -> a document for navigation
Accept: application/json -> data for the application
The client stopped being an anonymous browser and became a specific consumer of a format.
Not everything is up to it. Host, Cookie, Content-Length, Origin or Connection belong to the browser as the user agent, and attempts to overwrite them are silently ignored. The response is read separately:
xhr.getResponseHeader("Content-Type");
The lifecycle of a request became visible
Navigation goes through the same response stages, there is simply nobody to show them to. XHR exposed them to the application:
0 UNSENT
1 OPENED
2 HEADERS_RECEIVED
3 LOADING
4 DONE
Between HEADERS_RECEIVED and DONE the application already knows the status and the headers, while the body is still arriving.
That made it possible to show upload progress or to stop a request in flight:
xhr.abort();
Aborting concerns the client only. The server may already have performed the operation, which the part on fetch() returns to.
The origin boundary
Here comes a problem a form never had.
A form could always send a request to a foreign origin:
<form method="post" action="https://other.example/transfer">
The browser will send it together with that site's cookies. This is exactly why CSRF exists.
What a form cannot do is read the response. The document gets replaced, but the page's script has no access to its content.
XHR gives programmatic access. If that access were unrestricted, any page could make a request to somebody's mailbox using the user's cookies and read the result. That is why a cross-origin response is unavailable by default, and permission is granted by the server:
Access-Control-Allow-Origin: https://app.example
For requests that go beyond what a form can express, the browser asks first:
OPTIONS /api/users/42 HTTP/1.1
Origin: https://app.example
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: content-type
Only after permission does it send the actual DELETE.
Cross-origin cookies also have to be enabled explicitly on both sides:
xhr.withCredentials = true;
Access-Control-Allow-Credentials: true
The security model moved together with the reader of the response. With navigation the boundary was what the user sees. With XHR the boundary is what a script can read.
Why XHR was awkward
The XMLHttpRequest API grew over the years and follows a mutable object + events model:
xhr.open(...);
xhr.setRequestHeader(...);
xhr.onload = ...;
xhr.onerror = ...;
xhr.send(...);
On top of that came:
readyState,- callbacks,
- separate events,
- unintuitive response handling,
- historical compatibility baggage.
The request, the response, the headers and the transfer state all sit in one object that changes over time. Such a response cannot be passed around or composed across layers.
The problem was no longer capability.
The problem was the programming interface.
The browser needed an API in which the request and the response are plain values rather than the state of a single object.
That is how fetch() came to be.