The Simple CSR-MPA pattern


This is part 3 in a series on multi-page behaviours in modern web apps

In this post I’ll describe the first of two design patterns that is entirely Client-Side Rendered (CSR) and supports all the multi-page behaviours. Let’s break that down:

  • It’s entirely Client-Side Rendered—that means it runs entirely in the browser, and nothing but static content is served from a web server or CDN.
  • It supports all the multi-page behaviours—that means it’s shareable, deep linkable, refreshable, history traversable, link operable, and multi-tabbable.

This pattern can support an unlimited number of distinct URL paths, yet requires only a handful of static content files hosted on your web server or CDN. As far as I can tell the pattern doesn’t have an existing name, so for the purposes of this blog series I’ll refer to it as the Simple CSR-MPA pattern.1

What’s interesting about this design pattern is that it implements the multi-page behaviours in the simplest possible way for a CSR application. But there’s a price you pay for this simplicity: every time the user clicks a link, it reloads the page. In the next post I’ll describe how to overcome that limitation—but first, let’s examine this pattern in more detail.

Despite its limitations, this pattern is extremely useful in certain situations, and it can perform surprisingly well. Even if you don’t use this pattern in isolation, it’s a foundational building block used in more advanced patterns, so it’s worth understanding how it works.

I’ll start with an overview of the pattern, then I’ll walk through its steps one by one, and finally discuss why this design pattern is more useful than you might think.

How does the Simple CSR-MPA pattern work?

The following diagram illustrates how the Simple CSR-MPA pattern works.

Simple CSR-MPA pattern

The Simple CSR-MPA pattern

When you open the application for the first time, it cycles through four steps:

  1. Load outer HTML
  2. Load application
  3. Router
  4. Render page

At the end of this cycle, you are looking at a brand new web page with a new URL.

If you click a link within the application, it triggers a new page reload and repeats the same four steps, all over again. The same steps are followed if you right-click a link and open it in a new browser tab, click Back or otherwise revisit your history, reload the current page, or open a deep link or bookmark from outside the application. They’re all handled in exactly the same way.

To show it in use, I’ve modified the demo application from the previous post to implement the Simple CSR-MPA pattern. Here is a screenshot which links to the modified demo application.

Simple CSR-MPA demo application

Simple CSR-MPA pattern — demo application

If you try the new demo application, you’ll notice two major differences compared to the previous version:

  • All the Not found errors have now been fixed: links can be opened in new browser tabs when you right-click them, previously-shared deep links and bookmarks open as expected, locations can be retrieved from the browser’s full History menu, and pages refresh when you click Reload. You can even hack the URL directly in the address bar if you want (as long as you enter a valid URL path).
  • Every page-related action now causes a full page reload—even primary-clicking a link or traversing the browser history. You can tell the page is reloading because the three link colours randomize when you perform the action.

Walkthrough of the pattern in use

Now let’s walk through the four steps to understand how this pattern works in more detail.

Step 1: Load outer HTML

When the user opens any URL belonging to the application or clicks an internal link, the browser sends a request to the web server asking for the URL’s path (as well as its query string and fragment, if present). For example, if you open https://egalo.com/smpl, the browser sends a GET request to the server (‘egalo.com’) asking for URL path ‘/smpl’.

The full URL then appears in the browser’s address bar. This might be the application’s home page—but it could be any other valid page for that application. Such URLs are sometimes called outer URLs, to distinguish them from URLs for subresources such as embedded style sheets and JavaScript files.

To support the Simple CSR-MPA pattern, the web server must be configured to rewrite any valid outer URL to a single lightweight piece of markup. This markup is exactly the same for all valid outer URL paths. It’s sometimes referred to as the outer HTML.

What is in the outer HTML? As little as possible. The outer HTML for the demo application is just 336 bytes in size and contains the following markup:

<!DOCTYPE html>

<html lang="en">
    <head>
        <title>CSR-MPA demo application</title>
        <script src="/app/csr-mpa/static/app.js"></script>
        <script src="/app/csr-mpa/static/ntc.js"></script>
        <link rel="stylesheet" href="/app/csr-mpa/static/app.css">
    </head>
    <body onload="main()">
    </body>
</html>

The main purpose of the outer HTML is to give the browser references to all the subresources which contain the actual application. In this example there are three subresources: two JavaScript files and one CSS file.

In this case the body element is empty. That’s because we’re going to build the entire DOM structure dynamically using Client-Side Rendering.

With the rewrite rule in place, the web server treats every valid outer URL path as equivalent. For example, the following are all valid outer URLs for the demo application, so they all return the same outer HTML:

You can verify this by opening any valid outer URL and choosing ViewDeveloperView Source. In each case you’ll see an identical piece of markup. That’s the outer HTML.

By convention, the outer HTML is often stored in a file called index.html. That’s what I’ve done in the demo application. To show that in context, here’s the complete set of files deployed to the web root directory for the demo application:

CSR-MPA demo app files

In this example you can see index.html, which contains the outer HTML, along with the three subresources in a subfolder called static. Not bad, considering the application has 16,777,217 distinct web pages!

Note: In a variation of this approach, statically-generated markup is placed on the server instead of minimal outer HTML—and the static markup can vary by URL path. This technique is effectively a hybrid of SSG and CSR, and it’s often used to improve performance or SEO characteristics of CSR applications, among other reasons.

However, the deployment model remains the same, since only static files are served by the web server or CDN, and dynamic rendering only happens in the browser. Furthermore, the necessary steps to support multi-page behaviours are the same. Therefore I’d consider this to be a variation of the Simple CSR-MPA pattern.

For the rest of this post, I’ll assume that we’re talking about the “pure CSR” variation of this pattern, in which the outer HTML has an empty ‘body’ element and is identical for every outer URL.

Writing a rewrite rule

How do you write a rewrite rule? In a pure CSR approach, the rewrite rule needs to map every valid outer URL path to the physical path on the web server of the file containing the outer HTML. So for the demo application, the rewrite rule maps the outer URL path for the home page (‘/smpl’) and every possible colour-specific page (‘/smpl/<hex>’) to the static file path ‘/app/csr-mpa/index.html’.

Note that URL rewriting is completely internal to the web server, and is invisible to the browser. This is not a client-side redirect. The browser is still fetching distinct URLs—it’s just that they happen to all return the same content. For example, if the browser requests ‘/smpl/4b813e’ then the web server immediately maps that to the outer HTML and returns its content in the response.

The steps to configure your web server or CDN to perform URL rewriting are specific to the particular web server or CDN you’re using, so I won’t describe them here. To find out how to do it, search your web server or CDN’s documentation for “URL rewriting”. In most web servers it takes only a few lines of configuration to get the job done.2

Returning outer HTML directly from a CDN

If you’re using a CDN, and your application has a large number of outer URL paths, you might want to consider turbo-charging Step 1 by returning the outer HTML directly from your CDN, without going back to the origin server.

Why would you do this, given that a CDN will automatically cache responses in any case? Bear in mind that the CDN must cache responses separately for each outer URL path. In the case of the demo application, there are 16,777,217 valid outer URL paths. So the chance of any given outer URL path having its response cached by the CDN is low. By returning the outer HTML directly from the CDN, it will be nearly as fast as if it had the response cached for every valid outer URL path all the time. Consult your CDN’s documentation for details on how to do this and on any costs involved.

Why it’s important to optimize this step

Why are we spending effort optimizing for a minuscule piece of static content? The reason is that the outer HTML is the one piece of content we cannot assume will be cached by the browser (again, assuming there are many outer URLs).

All the subresources will be cached by the browser, since they have URL paths that remain fixed while the user navigates—as we shall see in Step 2. But the URL path for the outer HTML keeps changing as the user navigates. The browser has no way of knowing that different outer URLs will return the same content. So every time it sees a new outer URL path it will make a fresh request to the server.

This means that after the first page has loaded, the only network traffic the application generates while the user navigates will be requests for the outer HTML (as well as any API calls the application makes in Step 4, described later). Until it’s received the outer HTML, the browser cannot retrieve the subresources from its local cache (Step 2). Hence fetching the outer HTML is on the critical path—nothing else can happen until it’s loaded. Anything you can do to optimize Step 1 will therefore help improve overall page load times.

Setting cache control headers for the outer HTML

Should you set cache control headers to tell the browser to cache the outer HTML? You can do—but there are trade-offs. If you do, be aware that:

  • The outer HTML will be cached separately for each outer URL path.
  • Your web server’s access logs will no longer reliably track users as they navigate between pages.

If the application is protected using old-school server-side redirects to authenticate users, you might want specifically not to cache the outer HTML to ensure that unauthenticated page requests initiate the login sequence.

Step 2: Load application

OK, now step 1 is complete. The browser has the outer HTML. What happens next?

The browser parses the markup, finds references to the subresources that contain your application, and attempts to fetch them. For this to work well, several things need to be in place.

Use absolute paths for subresources

References to subresources in the outer HTML must use absolute paths such as /app/csr-mpa/static/app.js—or alternatively, absolute URLs if you decide to host them in a subdomain.

Don’t use relative paths (such as static/app.js), since the browser will convert those to absolute paths relative to the current outer URL path—and since the outer URL path keeps changing, requests for subresources will return HTTP 404 Not found errors and your application won’t work.

Ensure outer URL rewriting is not applied to subresources

The URL rewriting performed in Step 1 must not apply to the subresources downloaded in Step 2. If you configure URL rewriting to apply to all URL paths, then even requests for subresources will return the outer HTML—and your application will be screwed. To avoid this, requests for subresources must be allowed to return their file contents as normal.

In order to segregate subresource URL paths from outer URL paths, you can simply place the subresources in a folder whose path is not the same as any valid outer URL path. For the demo application, I placed the subresources under /app/csr-mpa/static/. That will never clash with outer URL paths which follow the pattern /smpl/<hex>.

It doesn’t really matter what subresource URLs look like, since you won’t see them in the address bar. If it makes things easier, you can place subresources in their own subdomain, such as static.example.com. In that case, the outer URLs will have an entire DNS domain all to themselves.

Tell the browser to cache subresources

To get decent performance, you must tell the browser to cache the subresources. This is important because in a typical CSR application, the subresources are orders of magnitude larger than the outer HTML—they are your application, after all. But they’re nonetheless identical for every page the user visits.

Caching the subresources will mean that when a user opens a new page, even if the browser has never seen that outer URL path before, it will load the subresources almost instantaneously from its local cache.

In order to enable browser caching, you must configure your web server or CDN to include an additional HTTP header in the response for each subresource. For example, the following header tells the browser to cache a response for 86,400 seconds (24 hours):

cache-control: max-age=86400

Again, the configuration steps needed to implement this are specific to web server or CDN you are using, so I won’t describe them here.

Putting it all together: steps 1 and 2

With all the above in place, Steps 1 and 2 should look something like this (this example is taken from Google Chrome.appViewDeveloperDeveloper ToolsNetwork):

CSR-MPA demo app steps one and two

In this example:

  • Step 1—fetching the outer HTML—took 81 ms.
  • Step 2—fetching the subresources—took 0 ms.

Let’s walk through exactly what happened to produce that network log.

  • To begin with, we’ll assume this was not the first page the user opened in this application, so the browser already had the subresources cached from a previous page.
  • The user opened a new page https://egalo.com/smpl/feddfe (“Pink Lace”).
  • The browser sent a GET request to the server asking for URL path ‘/smpl/feddfe’.
  • The web server’s rewrite rule recognized ‘/smpl/feddfe’ as a valid outer URL path, so the rewrite rule kicked in and generated a response containing the outer HTML.
  • The outer HTML was served very fast (81 ms), since it’s a very small piece of static content.
  • The browser received the outer HTML, parsed the markup, and found references to the subresources.
  • The browser realized that it had all three subresources already in its local cache—so instead of fetching them from the server again, it fetched them from its local cache almost instantaneously (0 ms).

The end result is that once the user has visited at least one page in the application, then for every subsequent page Steps 1 and 2 complete incredibly fast—even if it’s a page they’ve never visited before!

Step 3: Router

Steps 1 and 2 are now complete, and the browser now has all the content it needs. It’s time for the application to spring into life.

If you’re using a web framework, then your framework will take over at this point. But let’s take a brief look at what needs to happen, framework or not.

The outer HTML must tell the browser to run the application. In the demo application, the onload property of the body element is set to run JavaScript function main().3

<body onload="main()">

When the application starts running in the browser, its main job is to render the page. But what page should it render? That depends on the current outer URL, which is displayed in the address bar. The application must therefore ask the browser to tell it what the current URL is.

In pure JavaScript, this takes a single line of code:

const url = window.location;

The client-side code now has a Location object, which has properties for all the useful elements of the current URL. For example, url.pathname returns the URL path.

Using this information, the application can now invoke the appropriate client-side code to render the page. If required, different URL paths (and query strings, if present) can result in different page renderings based on logic contained entirely within the client-side code.

If you’re used to traditional Server-Side Rendered (SSR) applications, this might seem back-to-front. Normally it’s the web server that parses the URL path and decides what to render. But in this case, it’s the browser!

Web developers use the term router to describe a component that parses the URL and hands off control to the appropriate rendering code. In traditional SSR applications, the router resides on the web server. But in a CSR application supporting multi-page behaviours, the router resides on the client. The application still has distinct URLs—but the decision about what page to render is deferred until the application is running in the browser.

So Step 3 is basically the application’s router. Furthermore, this is the first time the application has done anything URL-specific. In steps 1 and 2 we basically ignored the outer URL (apart from checking its validity), and treated all outer URLs as interchangeable. Step 3 is the first time we’re saying “do something different, depending on the current URL”—and we’re doing that in the browser.

Step 4: Render page

In Step 4, the client-side code invoked by the router now renders the page. It effectively builds what would have gone in the outer HTML’s body element, if this had been a traditional SSR application. Instead, we’re doing Client-Side Rendering, so we need to drive the DOM programmatically.

In the demo application, Step 4 is trivially simple—it just sets the background colour of the page to match the hex value it found in the URL path.

In a real application, this step would obviously do a whole lot more. The application might call server-side APIs to fetch additional data relevant to the current page. For example, if the outer URL path is ‘/resource/271’, it might call server-side APIs to fetch all kinds of rich information about resource 271 and display it.4

Such API calls are described as asynchronous if they run in the background after the page has loaded. This is what allows the Simple CSR-MPA pattern to display rich information on each page, even though it reloads pages whenever the user navigates.

A page rendered in Step 4 will probably include links to other pages within the same application. In the Simple CSR-MPA pattern, links are perfectly ordinary, regular hyperlinks—there is nothing special about them at all. No event listeners are attached. The History API is not used.

When the user clicks on a link, the application goes back to Step 1 and repeats everything above, all over again.

When the Simple CSR-MPA pattern is useful

As mentioned earlier, the Simple CSR-MPA pattern is a foundational building block used in more advanced design patterns—including the one described in the next post.

However used even on its own, this pattern can work well in certain situations. What types of applications might work well with this design pattern?

Types of application that work well with this design pattern

A web application that’s a good candidate for the Simple CSR-MPA pattern will probably have attributes similar to these:

  • Utilitarian. The application exists to serve a purpose. You’re not trying to win awards for aesthetics.
  • Lightweight. The application’s pages are relatively simple and they open quickly following a page reload. You’re probably using a reasonably lightweight web framework.
  • Resource-centric. Different URL paths correspond to different resources, and there’s little shared state between them.
  • The users will benefit from multi-page behaviours. The application’s users will be exchanging deep links, working in multiple tabs, and so on—so they’ll appreciate multi-page behaviours for their efficiency and usability.
  • For architectural reasons, you want the application to run in the browser. Maybe you prefer the simpler deployment model of static site hosting, or you don’t want the operational overhead of maintaining an application server or writing server-side code for SSR. In most cases, either:
    • SEO is not important, or
    • the application uses a hybrid of SSG and CSR to achieve SEO.

If your application fits this profile, the Simple CSR-MPA pattern might just be good enough. And if not, the pattern described in the next post might be a better fit.

The Simple CSR-MPA pattern is a powerfully simple—and in my view, underrated design pattern. It’s particularly suited to scenarios where it’s acceptable to have the “feel” of a traditional SSR application, but for architectural reasons the application needs to run in the browser.

Your users probably won’t notice anything unusual. The page will reload whenever they click a link—but that’s not unusual for web applications. That’s how they’ve worked for years.

Before wrapping up, let’s take a closer look at two main areas of concern: performance and state management.

Performance

The baseline performance of the Simple CSR-MPA pattern can be extremely fast. By baseline performance, I mean the time taken to perform only Steps 1, 2 and 3, which have fixed complexity, before doing Step 4, which is application-specific. The performance comes thanks to the caching and other optimizations described earlier.

If the pages are lightweight and CSR is fast, an application using the Simple CSR-MPA pattern can load new pages at speeds close to this baseline performance. The demo application illustrates this in the extremal case where Step 4 is trivially simple. In a real-life application, page load times of a few hundred milliseconds can be achieved. Such an application will “feel” like a fast and snappy SSR application to users.5

In practice, web applications built using modern web frameworks can be anything but lightweight and simple, though. Depending on how heavyweight and complex your application is, the Simple CSR-MPA pattern might not be a good fit.

There are other factors to consider:

  • You only get the full benefit of suppressing page reloads using the History API if the DOM structure is substantially similar between pages. If the new page has a completely different DOM structure, everything will need to be re-rendered anyway.
  • If the web application spends a significant amount of time waiting for server-side APIs to return data, then this might dominate the overall page latency regardless whether there was a page reload or not.

State management

Is the Simple CSR-MPA pattern “stateless”? Not really. It can be as stateful as you want it to be for as long as the user stays on the same URL! During that time the application can exhibit all kinds of rich interactive behaviour, asynchronous updates, ability to edit data, and so on.

When the user transitions to a new “page”, the slate is wiped clean. Is the application stateless at that point? Again, not really. A small but crucial piece of state will be transferred to the new page via the URL path itself, such as ‘/resource/946’. The new page can derive a lot of other data from that URL, especially if it calls server-side APIs.

Again, this works best in utilitarian applications in which different URL paths represent distinct resources. If you need to run a media player in the corner of the screen while the user navigates between pages, this design pattern is not for you.

You can of course preserve state by other means, such as Web Storage. But you might find you don’t need to. The URL may be enough.

In some cases, the Simple CSR-MPA pattern might actually simplify state management compared to using the History API. When you use the History API, your application needs to figure out the delta to modify the current DOM and ensure that client-side state is consistent with any resources on the server-side, which can get very complex. Rebuilding all client state following a page transition might simplify things in some cases.

As always, caveats apply. Complex applications will have special needs and considerations not covered here. Storing security tokens while using this pattern is a separate topic which I’ll cover in a future post.

When the Simple CSR-MPA pattern is not enough

So far, we’ve looked at two design patterns for CSR web applications that run entirely in the browser.

  • The HTML5 History API used on its own, without any additional web server configuration, is able to suppress page reloads when the user navigates, but doesn’t provide support for all the multi-page behaviours.
  • The Simple CSR-MPA pattern supports all the multi-page behaviours, but it reloads the page every time the user navigates.

What if you want both? What if you want to suppress page reloads when the user navigates and support all the multi-page behaviours? Well, you merge those two design patterns together of course! And that’s what I’ll cover in post four.


  1. Because it’s simple, entirely CSR, and behaves like an MPA. 

  2. You might need to use regular expressions if you have many outer URL paths following the same patterns. 

  3. In JavaScript, main() does not have any special significance like it does in C. The initial function can have any name, but main() is often used by convention. 

  4. You will then see the latest information about the resource—even if you arrived at the page by clicking Back. This is normally the behaviour you want from the Back button. You’re travelling back to the resource, not travelling back in time. 

  5. In a previous life in 2008, I developed a web application using the Simple CSR-MPA pattern, and it ran successfully in production for many years. Not once did anyone complain about performance—in fact it was very fast. It was implemented using the now unfashionable Google Web Toolkit. The HTML5 History API didn’t exist back then.