< Back
calendar

Oct 6, 2020

Your Single-Page App Is Now A Polyfill

You simply can’t beat the platform

Header for posting Your Single-Page App Is Now A Polyfill
Photo by C M on Unsplash

Why do we build single-page apps? Two main reasons really.

We want our web apps to feel “instant”, without any ugly blank screens in between pages that remind us our app is not really like an app.

Blank screens make for a bad user-experience. Users don’t want to wait for content to arrive from a server when they click a link or a button. They expect websites to be fast like native apps.

So we build single-page apps, where only the content that changes in the page is replaced, avoiding a full page reload, so navigating to another page feels instant.

An added benefit of this is that now we only need to fetch the content that changes from the server, instead of a whole new page. This reduces the amount of data we need to fetch from the network, making our app faster. This is the second main reason we build single-page apps.

But single-page apps bring added complexity.

We now bypass the browser’s routing and instead handle this ourselves on the client. Most times, a frontend framework is added as well to handle the rendering of these pages, increasing complexity further.

Now of course frameworks can do a lot more, but it all started with the desire to eliminate blank screens in between pages and reduce payload sizes.

What if I told you can have a blazing fast multi-page app as well, without any blank screens in between pages?

A multi-page app that doesn’t require any client-side routing, where every new page is a full page reload but that only fetches the content that changed from the server.

Streaming HTML

The trick to making multi-page apps blazing fast is actually quite simple: we utilize the browser’s streaming HTML parser.

The thing is that the browser renders HTML while it downloads. It doesn’t need to wait for the whole response to arrive but it can start rendering content as soon as it becomes available.

The Response object that is returned by fetch exposes a ReadableStream of the response contents in its body property, so we can access that and start streaming the response:

fetch('/some/url')
.then(response => response.body)
.then(body => {
const reader = body.getReader(); // we can now read the stream! }

A typical single-page app uses an app shell, which is actually the single page that the content is injected into. It usually consists of a header, footer and a content area in between where the content for each page is placed.

The problem is that any content that is added to the HTML page after it has loaded is bypassing the streaming HTML parser and is therefore slower to render.

We can however benefit from browser streaming by having a Service Worker fetch all the content we need and have it stream everything to the browser.

Server-side rendering on the client

To accomplish this, we need to split all pages into a header and a footer, cache these templates and then fetch the body content from the network, if needed.

The Service Worker will intercept any outgoing request, fetch the header and footer and then determine which body content it needs to fetch. This can be just a simple HTML template or a combination of a template and some data fetched from the network.

The Service Worker will then combine these parts to a full HTML page and return it to the browser. It’s like server-side rendering, but it’s all done on the client-side in a streaming manner, using a ReadableStream.

This means it can start rendering the header of the page while the content and footer are still downloading, giving a huge performance benefit.

Let’s have a look at the code, in particular the fetch event handler that is invoked whenever an outgoing request is intercepted by the Service Worker:

const fetchHandler = async e => {
const {request} = e;
const {url, method} = request;
const {pathname} = new URL(url);
const routeMatch = routes.find(({url}) => url === pathname);

if(routeMatch) {
e.respondWith(getStreamedHtmlResponse(url, routeMatch));
}
else {
e.respondWith(
caches.match(request)
.then(response => response ? response : fetch(request))
);
}
}; self.addEventListener('fetch', fetchHandler);

The fetchHandler function examines the incoming request and tries to find a matching route in the routes array by the url of the request:

const routes = [
{
url: '/',
template: '/src/templates/home.html',
script: '/src/templates/home.js.html'
} ...
];

For the home route (‘/‘) it will find the home.html template and the accompanying JavaScript inside a script tag in home.js.html.

The Service Worker will then fetch the templates header.html and footer.html, combine them with home.html and home.js.html to a full HTML page and stream it back to the browser.

In the previous example, this is handled inside the getStreamedHtmlResponse function. Let’s have a look at it:

const getStreamedHtmlResponse = (url, routeMatch) => {
const stream = new ReadableStream({
async start(controller) {
const pushToStream = stream => {
const reader = stream.getReader();

return reader.read().then(function process(result) {
if(result.done) {
return;
}
controller.enqueue(result.value);
return reader.read().then(process);
});
};

const [header, footer, content, script] = await Promise.all(
[
caches.match('/src/templates/header.html'),
caches.match('/src/templates/footer.html'),
caches.match(routeMatch.template),
caches.match(routeMatch.script)
]
);

await pushToStream(header.body);
await pushToStream(content.body);
await pushToStream(footer.body);
await pushToStream(script.body);

controller.close();
}
}); // here we return the response whose body is the stream
return new Response(stream, {
headers: {'Content-Type': 'text/html; charset=utf-8'}
});

};

Inside getStreamedHtmlResponse we construct a new ReadableStream that is passed an underlyingSource object, containing the start method which is called immediately after the stream is constructed.

start is passed a controller argument which is a ReadableStreamDefaultController that allows control of the internal state and queue of the ReadableStream.

Inside the start method, we fetch the templates for the HTML page and push the contents of the templates as individual streams into the main stream using the pushToStream function.

This function reads the individual streams from the templates chunk by chunk and enqueues them using controller.enqueue().

Since the start function is asynchronous, a new Response is immediately returned with the ReadableStream as the body of the response.

The browser can now stream the response and the page appears on the screen nearly instantly.

Just let that sink in for a moment: we are now able to serve responses instantly, just like a single-page app, but without any of the complexity that a single-page app brings.

No client-side routing, no framework and no complicated server-side rendering.

All rendering is handled by the Service Worker that serves blazing fast, streaming responses.

Single-page apps are polyfills

This basically reduces single-page apps to a polyfill, which is a pretty bold statement, but here’s why:

Does it really work?

Now you might wonder if a multi-page app like this can really beat a single-page app when it comes to speed and performance.

I created a demo so you can see for yourself how fast a multi-page app using streaming HTML can really be. You can find the source code here on Github.

If you click around you will notice that the header of the page stays firmly in place, even though every page requires a full page reload and some pages are pretty heavy.

This is how good browsers are at rendering the DOM if we use the streaming HTML parser.

Browsers are not slow. The DOM is not slow. The way we try to shoehorn the single-page app model into a medium that is inherently multi-page, by throwing frameworks and a slew of libraries at it, makes it slow.

Conclusion

Using a Service Worker and correctly utilizing the browser’s streaming HTML parser can dramatically boost the performance of your web app and usually defeats the purpose of having a single-page app altogether.

You are now working with the platform and not against it.

Instead of throwing a framework and a dozen libraries at your app, keep it simple and use the platform.

You will probably find that it’s all you need.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.