Single-page applications (SPAs) are web apps that render different content based on the current location. SPAs are driven by routers, which have two primary jobs:

  1. Navigating to other locations in the application without causing a full page reload.
  2. Matching locations to routes and telling the application to re-render the matched route.

Navigation to other locations can be performed synchronously or asynchronously.

Synchronous Navigation

Today, most single-page application routers use synchronous navigation, which means that the location change always happens prior to route matching.

  1. The user clicks a link.
  2. The location changes (using the browser’s History API).
  3. The router matches the new location to a route and triggers a re-render.

Clicking a link changes the location and re-renders the application

What happens if we want to load some data for the new location’s route? With a synchronous SPA, a common solution is to render a loading indicator while waiting for data to load.

Navigating to a route that needs to load data may mean rendering a loading indicator

The app is essentially in a non-responsive state until the data has loaded. What if the user decides that they want to navigate somewhere else while the data is loading? They’ll have to use the browser’s back button to navigate back to the previous page, then click the link to the location that they’d rather go to.

If the user wants to change pages while data is loading, they have to go back to the previous page

Content that persists across routes, like a navigation menu, would let the user re-navigate without using the back button. However, that would also leave an extra location in their history (meaning they have to click the back button twice to get to the “previous” page).

A menu lets the user re-navigate, but they end up with extra locations in their session history

The simple “fix” for this is to detect if the user is already navigating and replace the “current” (actually the next) location instead of pushing a new location. This approach also immediately wipes out any “future” history, so the user cannot click the forward button once they have clicked a link. Maybe this isn’t a big deal, but it deviates from how navigation with multi-page applications works.

Is there a better solution?

Asynchronous Navigation

With an asynchronous single-page application, changing locations is a side effect of navigation; the location isn’t updated until any async actions (like loading data) complete.

  1. The user clicks a link.
  2. The router determines which route matches the future location.
  3. If the matched route has any async actions, those are run and the next step does not happen until they complete. Routes with no async actions skip this step.
  4. The location is changed and a re-render is triggered.

Note: Since the application won’t re-render until async actions for the next location have completed, it is helpful to show a loading indicator so that the user knows that the application is doing something.

An async SPA doesn’t change locations until data has finished loading. A loading indicator lets the user know that something is happening.

If the user clicks a different link before any async actions for the initial navigation have completed, the initial navigation will be cancelled. Since the initial navigation never actually changed the location, the history won’t have an extra entry in it.

An async SPA doesn’t change locations until data has finished loading. A loading indicator lets the user know that something is happening.

Pop Events

While navigating by clicking links works by delaying changing the location, clicking the browser’s back/forward buttons triggers a “pop” event, which immediately changes the location.

If a pop navigation’s async actions are running and the navigation gets cancelled by a new navigation, the router needs to know how to return to the previous location prior to applying the new navigation.

An async SPA doesn’t change locations until data has finished loading. A loading indicator lets the user know that something is happening.

That said, using the back/forward buttons means that the user is navigating to a page that they have already visited. The application should be caching the results of asynchronous actions, so re-visiting a page should effectively be instantaneous.

Writing Your Own Single-Page Application with Async Navigation

How can you make your single-page application work asynchronously?

Curi is a single-page application router that makes working with asynchronous actions, like data loading and code splitting, easy.

The illustrations above are nice, but how about a Curi demo where you can actually play around with the ideas discussed above.

With Curi, asynchronous actions can be attached to a route. When an async route matches, the navigation will not “finish” (location updated & app re-rendered) until the route’s async actions have completed.

const routes = [
    name: "User",
    path: "u/:userID",
    // async functions to call before navigation is finished
    resolve: {
      user: ({ params }) => UserAPI.get(params.userID)
    // this is called once resolve.user() has completed
    respond({ resolved }) {
      return { data: resolved.user, ... };

Curi works with React DOM and React Native, and has beta integrations for Vue and Svelte. As the author of Curi, I am obviously biased toward it, but I believe that Curi is the best SPA router for React (even if you aren’t doing anything asynchronous). Maybe one day this will be true for Vue, Svelte, and other UI frameworks as well.

Downsides to Asynchronous Navigation

I would be remiss to imply that asynchronous navigation is issue free.

What happens if a user clicks a link to navigate, and while the data for the next location is loading, they decide that they would prefer to stay on the current page?

With a multi-page application, after a user clicks a link, they can click the browser’s “stop” button to stop the navigation. If they click the browser’s “back” button instead, they would be taken to the previous location.

This is the downside to asynchronous navigation. A single-page application cannot activate the “stop” button, so there is no native way to cancel navigation.

Navigation with a synchronous SPA, which changes the location immediately, can be “cancelled” by clicking the back button, which returns the application to the “previous” location. This makes it easy to cancel the navigation, but also works differently than multi-page applications.

Ideally the browser would give us the ability to activate the “stop” button (e.g. via an async pushState() function) and let the router cancel navigation when it is pressed.

Currently, the best way to cancel a navigation with an async SPA is to refresh, which causes a full reload, or click a link to the current location, which will replaces the active navigation.

Curi is experimenting with an API to let you know when the router is navigating and cancel the navigation. While this is not as ideal as a native implementation, I think that this is the best solution in lieu of browser support.

You can see a simple demo of this experiment in this tweet.