How Single-Page Applications Work

Getting to know the browser behaviors and APIs responsible for powering more and more web pages

A single-page application (SPA) is a website that re-renders its content in response to navigation actions (e.g. clicking a link) without making a request to the server to fetch new HTML.

While single-page application implementations vary, most rely on the same browser behavior and native APIs to enable the core functionality. Understanding these is key to knowing how single-page applications work.

What Type of SPA?

Single-page applications can use state from an external source (i.e. the URL location) or track state internally. This article will be focusing on location-based single-page applications. Why?

Internal state SPAs are limited because there is only one “entry”. A single entry means that you always start at the root when you enter the application. When you navigate within an internal-state app, there is no external representation. If you want to share some content, the other person loading the app will start at the root, so you’ll have to explain how to get to desired content.

An internal state SPA can only load the app’s entry

With location-based SPAs, you can share a link and be confident that anyone opening that link will see the same thing as you because the location is always updating as you navigate (assuming they have the same authorization to view the content) .

A location-based SPA render can immediately render the desired content

Location Primer

While the URL in the address bar is what users see and interact with, SPAs use window.location. This allows you to interact with the different parts of the URL without having to parse it yourself.

The window.location properties map directly from the URL

Only three of the location object’s properties are important for an SPA: pathname, hash, and search (commonly called a query string). With single-page applications, navigation to any location outside of the application is performed regularly (e.g. click an anchor and let the browser handle it), so the hostname and protocol can be ignored.

The pathname is typically the most important of these three properties because it is the one used for determining what content to render. The search and hash are more useful for providing additional data. For example, in the URL /images?of=mountains, the /images pathname would specify that an images page should be rendered, while the ?of=mountains search specifies the type of images that should be rendered within the images page.

Route Matching

Single-page application generally rely on a router.

Routers are made up of routes, which describe the location that they should match. These can be static (/about) or dynamic (/album/:id, where the value of :id can be any number of possibilities) paths. The path-to-regexp package is a very popular solution for creating these paths.

const routes = [
{ path: '/' },
{ path: '/about' },
{ path: '/album/:id' }
];

Route matching is comparing the current location (usually just its pathname) against the router’s routes to find one that matches.

After matching a route, the router will trigger a re-render of the application. Actual implementations for this are largely up to the router. For example, a router might use the observer pattern, where you give the router a function that knows how to trigger a re-render and the router will call it after it matches a route.

In-App Navigation

Navigation is the more interesting problem. When you click an anchor, the browser has native behavior attached to the event to trigger navigation. However, you can also attach your own click handler and override the native behavior (using event.preventDefault() in modern browsers). Without the native behavior the location will not change, so it is up to the click handler to trigger the navigation.

Before we can get to how click handlers can trigger navigation, we need to know a bit about how browsers normally handle navigation.

How Browsers Handle Locations

Each browser tab has a “browsing context”. The browsing context maintains a “session history”, which is basically, an array of location entries. An entry stores information about a location: its URL, the associated Document, serialized state, and a few more properties. An entry’s index specifies its position in the session history array. The browsing context also keeps track of which entry is currently active.

The session history is made up of entries

Document?

When a browser navigates, a request is sent to a server and the browser uses the response to create a Document object. The Document describes the page (the DOM) and provides methods for interacting with it. window.document is a browser tab’s active Document.

A Document is created from a Response

Session History

As you’re clicking links and navigating through pages, the browser tab is building up a session history. Each navigation makes a request to a server and creates a new entry (including a new Document).

Each navigation appends a new entry to the session history

Note: Unique colors in the entry images imply different documents.

If you press the browser’s back button, the browser will use the current entry to determine the “new” current entry (current.index - 1). The “old” current entry’s Document will be unloaded and the “new” current entry’s Document will be loaded.

Clicking the back button switches entries (and Documents). The forward button has the same behavior.

When you click a link and there are entries after the current entry, they will be dropped.

Entries after the current entry are dropped when clicking a link

However, if you navigate to the exact same location as the current location (pathname + search + hash), then the current entry will be replaced without affecting succeeding entries.

Clicking a link with the exact same location as the current location will replace the current entry

That is how native navigation works, but the point of a single-page application is to avoid requests hitting the server. What do SPAs do to avoid this?

The History API

Early single-page applications relied on the fact that you could change a location’s hash and the browser would create a new location entry without sending a request to the server. This worked, but wasn’t ideal, so the History API was developed to add first-class support for single-page applications.

Instead of creating a new Document for every location, the History API re-uses the active Document, just updating it to reflect the new location.

The History API has three core functions: pushState(), replaceState(), and go(). These (and the rest of the API) are accessed via window.history.

Navigation with History API calls re-uses the active document

Note: Wondering about browser support? All major modern browsers support the History API. IE ≤9 does not support the it, but these browsers are no longer supported themselves. Opera Mini does not run JavaScript on the client, which is why it doesn’t support the History API.

pushState() and replaceState()

Both pushState() and replaceState() have the same function signature.

  1. The first argument is state. If you do not want to pass any state, just pass null. It may be tempting to keep application state here, but there are some caveats that will be discussed later.
  2. The second is a title string, but no browsers actually use this yet.
  3. The third argument is the path that you want to navigate to. This can be a full URL, an absolute path, or a relative path, but it must be within the same application (same protocol and hostname). If it is not, a DOMException error will be thrown.
history.pushState(null, '', '/next-location');
history.replaceState(null, '', '/replace-location');
// attaching state to an entry
history.pushState({ msg: 'Hi!' }, '', '/greeting');
// while on medium.com
history.pushState(null, '', 'https://www.google.com');
// throws a DOMException

history.pushState() adds an entry to the session history after the current entry. If there are entries after the current entry, those are lost when you push a new location. This is the normal behavior for when you click an anchor.

history.replaceState() replaces the current entry in the session history. Any entries after the current entry are unaffected. This is similar to the behavior for clicking an anchor whose href is exactly the same as the current URL, except replaceState() can replace the current entry with any location.

go()

go() is the programmatic way of performing the same task as the browser’s forward and back buttons.

go() can be useful when you know there is a previous page, like closing a location-aware modal, but I don’t find many uses for it.

go() is useful when you know that you can jump back to a previous location. If you aren’t positive there is a location to go back to, you might accidentally leave your site!

go() takes a single argument, a number, which is the number of entries away from the current one you want to navigate. Positive numbers go forward, negative numbers go backwards, and zero (or undefined) reloads the page.

go(-1); // go back one entry
go(1); // go forward one entry
go(-10); // go way back
go(0); // reload
go(); // reload

There are also history.back() and history.forward() methods, which are the same as history.go(-1) and history.go(1).

State

When entries were first mentioned, one of their mentioned properties was state. The pushState() and replaceState() methods also mention state directly in their name and we breezed over their first argument, which is state. So what is state?

State is just data that is attached to any entry. It persists navigation, so if you add state to an entry, navigate away, and then go back to the entry, the state will still be there. State is attached to an entry with pushState() and replaceState(). You can access the current entry’s state using history.state.

There are some limitations to what state can be. First, it has to be serializable, which means that the data can be turned into a string. Second, there are size limitations (640k characters in Firefox), which you probably don’t have to worry about, but do exist. Finally, when you navigate directly to a location its state is null, so if a page relies on state to render it will have issues with direct navigation. A good rule of thumb is that state should be used for non-rendered data, like a key to uniquely identify the entry or the URL to redirect to after a user logs in.

Navigating in SPAs using the History API

How do single-page applications take advantage of the History API? As mentioned above, we can add a click handler to anchors that overrides the native behavior using event.preventDefault(). The handler can call history.pushState()/history.replaceState() to perform the navigation without triggering a server request. However, the History API is only updating the session history, so the handler will also need to interact with the router to let it know the new location. Many routers use a History API wrapper to merge these steps.

There are various ways to add the handler to anchors. If you are using a rendering framework with components like React or Vue, you could write a special link component and attach the click handler directly to the component.

// React example
const Link = ({ children, href }) => (
<a
href={href}
onClick={event => {
// override native behavior
event.preventDefault();
      // navigate using the History API
// (ignoring replaceState() for brevity)
history.pushState(null, '', href);
      // finally, let the router know navigation happened!
}
>
{children}
</a>
);
<Link href="/somewhere">Somewhere</Link>
// renders
<a href="/somewhere">Somewhere</a>
// but clicking on it will trigger a history.pushState() call

You can see a more fleshed out example the the Curi router’s <Link> implementation, which uses a History API wrapper.

For more vanilla implementations, you could add a global event listener for clicks that detects in-app navigations, overrides the default behavior, and replaces it with a History API call. You can see an example implementation of this in the roadtrip router.

Actual implementations are slightly more complicated because not all clicks should be overridden. If the user does a modified click (clicks while holding the ctrl, alt, shift, or meta keys), we want the browser to handle the navigation. The same goes for an anchor with a target attribute.

The History API combined with overriding native click behavior makes in-app navigation easy. However, we have another type of navigation to worry about: a user pressing the browser’s forward and back buttons.

Detecting back/forward button navigation

When the back and forward buttons are clicked (as well as when history.go() is called), the browser emits a popstate event. In order to detect these, we just need to add an event listener to the window object.

window.addEventListener('popstate', event => {
// let the router know navigation happened!
}, false);

The session history will already be updated by the time the event listener is called, so all we need to do is let the router know that the location has changed.

Manually navigating with the address bar

If a user updates the location manually using the address bar, that navigation will create a new Document. The History API only prevents reloads with entries that share the same Document. This means that calling history.go() and clicking the forward/back buttons to navigate between them will cause full page reloads!

Review

Single-page applications control navigation so that we re-use the active Document instead of sending a request to a server.

Routers are typically used to power route matching within a single-page application. Route matching is done when the location changes, determines which route matches the new location, and then triggers a re-render of the application.

In-app navigation is performed using the History API. The default click behavior for anchors is overridden to use pushState() and replaceState() to navigate. A popstate event listener is used to detect navigation with the browser’s forward/back buttons. The click handler and event listener should both inform the router about the navigation to trigger the route matching cycle.

What about the server?

While most of a single-page application runs on the client, the files do have to come from somewhere. Single-Page Applications and the Server covers the various ways that you can serve a single-page application.

Resources

Interested in learning more?

Mozilla’s web docs has a good overview of the History and Location APIs.

If you really want to get your hands dirty, you can read through the WHATWG History spec. This can be very informative, but you may also find yourself clicking through a web of links to discern what all of the terminology means.

There are a number of History API wrappers that you can take a look at to see how they work. These also generally have some more functionality than this article covers. There is the aptly named history package, which is used by React Router and other projects. Vue’s vue-router has its own history implementation. Finally, there is hickory, my own wrapper, which is used by the Curi router.

If you have any questions/comments, please feel free to reach out to me on Twitter @pshrmn