The single-page application must die
Brian Neville-O'Neill
Posted on April 3, 2020
Written by Paul Cowan✏️
Disclaimer The views here are very much my own and not the opinions of LogRocket.
A further disclaimer is that I have spent the last ten years working on pretty much nothing else but single-page applications in their many guises.
The journey to the SPA (single-page application)
A possible definition of a single page application is:
A single-page application is a web application that requires only a single page load in a web browser.
My definition of a single page application is any application that relies solely on client-side rendering (CSR).
The growing thirst for highly interactive user interfaces (UI) resulted in more and more JavaScript code pushed to the browser. Javascript MV* frameworks grew out of the sprawling, messy codebases to bring order out of chaos.
Backbone.js was the first JavaScript MV* framework that opened the flood gates of hell to severe amounts of JavaScript being both shipped to the browser and parsed by the browser. This lead to the JavaScript running in the browser rendering dynamic HTML from the JSON responses of REST API calls and not the server. The infamous loading spinner that is so prevalent now emerged from the primeval swamp to take its place on the historical timeline of web development.
Following along after Backbone.js came the new kids on the block EmberJS, AngularJS and the current hotness React. Today it is probably more common to be using a JavaScript MV* framework than not as we want our web applications to behave just like their desktop counterparts.
I am not going to list the usual list of complaints about the SPA (single page application) that include things like SEO, performance problems, and code complexity. I do believe there are viable solutions for these problems, such as serving different content for web crawlers and code splitting for performance issues.
Progressive enhancement is flat lining
Building the web that works for everyone
My main problem with single-page applications is that they generally do not start life using progressive enhancement.
Progressive enhancement used to be a du jour concept, but the rise of the SPA has stalled it in its tracks as developers would rather deal with the new and shiny world that only the modern browsers allow. What about users in developing countries on slow networks or users of certain assistive technologies? We’ve turned a blind eye to ensure our CVs stay relevant.
If you create a new SPA using the CLI tooling from React, Angular, or Ember or whatever is du jour, then you are starting with the assumption that you are dealing with a Utopian world. The code is expecting to be running on a modern browser operating on a fast network with all the bells and whistles.
A broad definition of progressive enhancement is:
Progressive enhancement is a strategy for web design that emphasises core web page content first. This strategy then progressively adds more nuanced and technically rigorous layers of presentation and features on top of the content as the end-users browser/internet connection allow. — Wikipedia
What this means is that we start with the lowest denominator and add in enhancements such as JavaScript and we don’t start with the premise that a service worker is going to act as a proxy and cache content for repeat visits.
If we want to target a broader net of browsers and devices, then we need to ensure that the first time we visit a site, then the first page request is server-rendered preferably from an isomorphic web application.
If we take this approach, then our websites can work with JavaScript disabled, which is the holy grail of progressive enhancement.
We should also be using technologies associated with progressive web applications (PWA), more on this later.
Server-side rendering (SSR) vs client-side rendering (CSR) in a React application
I am going to use React as the example framework to outline the differences between the two types of rendering.
The main difference is that for server-side rendering (SSR) your server’s response to the browser is the HTML of your page that is ready to be rendered, while for client-side rendering (CSR) the browser gets a pretty empty document with links to your JavaScript and CSS.
In both cases, React needs to be downloaded and go through the same process of building a virtual DOM and attaching events to make the page interactive — but for SSR, the user can start viewing the page while all of that is happening. For the CSR world, you need to wait for all of the above to happen and then have the virtual DOM moved to the browser DOM for the page to be viewable.
The performance benefits of server-side rendering have been exaggerated and spun into a misrepresentation of the truth like a politician would use when uncovered.
Single-page application and progressive web applications
A PWA is a web app that uses modern web capabilities to deliver an app-like experience to users. The previous definition is a very wishy-washy explanation, but I think for any application to be qualified as a PWA, then it must fulfill the following three criteria:
- Served using HTTPS (secure)
- Have a valid web manifest file with a minimal set of icons
- Register a service worker with a fetch event handler and minimal offline support
The app shell model
For some reason, many think progressive web applications (PWA) are single-page applications (SPA), as they often use the app shell model promoted by Google.
The app’s shell is in the context of the app shell model is the minimal HTML, CSS, and JavaScript that is required to power the user interface of a progressive web app and is one of the components that ensures reliably good performance.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#000000">
<link rel="shortcut icon" href="/favicon.ico">
<title>My PWA</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
The first load should be rapid and immediately cached. Cached means that the shell files are loaded once over the network and then saved to the local device. Every subsequent time that the user opens the app, the shell files are loaded from the local device’s cache, which results in blazing-fast startup times.
If you create a new application with create-react-app then the workbox npm package, which is a collection of libraries for progressive web applications, is also installed. The workbox generated index.html is a bare-bones HTML file which has JavaScript script tags and CSS link tags added by webpack at build time.
This approach relies on aggressively caching the shell (using a service worker to get the application running. Next, the dynamic content loads for each page using JavaScript. An app shell model results in blazing fast repeat visits and native-like interactions.
The code generated by create-react-app
is client rendered only. No server generates a full HTML request for the first load. We are expecting the code running on a modern browser with modern features. There is no thought for progressive enhancement in this world.
A hybrid approach adds progressive enhancement to a progressive web application
There are definite advantages to both approaches, so the optimal approach is to use the best of both worlds.
If you make proper use of server-side rendering, then the server should initially respond to any navigation requests that are received with a complete HTML document, with content specific to the requested URL and not a bare-bones app shell.
Browsers that don’t support service workers can continue to send navigation requests to the server, and the server can continue to respond to them with full HTML documents.
Below is a render function that I use to server render React components. I am using loadable-components ChunkExtractor
to load only enough JavaScript and CSS for that specific URL using code splitting.
export async function render({ req, res }: RendererOptions): Promise<void> {
const extractor = new ChunkExtractor({
entrypoints: ['client'],
statsFile,
});
const context: StaticRouterContext = {};
const html = renderToString(
extractor.collectChunks(
<StaticRouter location={req.url} context={context}>
<Routes />
</StaticRouter>,
),
);
res.status(HttpStatusCode.Ok).send(`
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${extractor.getStyleTags()}
</head>
<body>
<div id="root">${html}</div>
${extractor.getScriptTags()}
</body>
</html>
`);
}
On the first load, a full HTML document is rendered that will still work if JavaScript is disabled.
Once the first load finishes, the react-router’s browser router takes over control of the navigation and, effectively, triggers the client-side rendering.
import React from 'react';
import { Routes } from '../../routes';
import { BrowserRouter } from 'react-router-dom';
export const App: React.FC = () => (
<BrowserRouter>
<Routes />
</BrowserRouter>
);
What about the service worker?
The hybrid strategy used by this approach to load the content doesn’t depend on a service worker, so even browsers that don’t support service workers can benefit from the implementation.
For browsers that do support service workers, we can still take advantage of the app shell model. Whenever a user triggers navigation inside the application, the service worker intercepts the request on the fetch event and adds the response to the cache. The next time navigation to that same URL is triggered, the service worker can load the content from the cache and delivers it instantly, without going to the network.
The service worker returns the same app shell HTML document for all navigation requests.
Service worker implementation
To make the app shell work, we need to get the service worker to cache a generic app shell HTML file. We can configure a special path like /app-shell
on the server to return a skeleton HTML file, and let the service worker fetch it during the installation of the service worker.
I use webpack and the workbox-webpack-plugin to generate the service worker config file.
Below is a scaled-down version of a service worker template file.
self.__precacheManifest = [].concat(self.__precacheManifest || []);
// active new service worker as long as it's installed
workbox.clientsClaim();
workbox.skipWaiting();
// suppress warnings if revision is not provided
workbox.precaching.suppressWarnings();
// precahce and route asserts built by webpack
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
// return app shell for all navigation requests
workbox.routing.registerNavigationRoute('/app-shell');
In the above code, the self.__precacheManifest
variable stores all URLs that need to be pre-cached.
The call to workbox.precaching.precacheAndRoute()
tells the service worker to fetch and cache all these URLs in its install process and use the cached version to serve all future matched requests.
The workbox.routing.registerNavigationRoute('/app-shell');
instructs the service worker that whenever there’s a navigation request for a new URL, instead of returning the HTML for that URL, return a previously cached shell HTML file instead.
All we need is a route in our express application to return the app shell skeleton:
app.use('/app-shell', (req, res) => {
res.status(HttpStatusCode.Ok).send(`
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- css link tags -->
</head>
<body>
<div id="root"></div>
<!-- js script tags -->
</body>
</html>
`);
});
I am amazed that this pattern is not more widespread. I think it is groundbreaking.
Epilogue
The single-page application made progressive enhancement take a back seat. The JAMstack and other similar frameworks have turned a blind eye to progressive enhancement and this to me is a backward step. We treat older devices as backwards compatibility. The web is often touted as for everyone but not in this world.
Progressive web applications following the app-shell model are blazing fast, but only if you are on a browser that supports service workers. Using a hybrid of rendering a full HTML document from an isomorphic JavaScript application and then letting the service worker kick in is where we should be heading. We are not in Utopia just yet, but we can breathe some life into the ailing progressive enhancement movement.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post The single-page application must die appeared first on LogRocket Blog.
Posted on April 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.