Your Single Page App May Soon Be A Polyfill
Modern Web Weekly #22
Hi and welcome to a new edition of Modern Web Weekly! đ
In this edition I will explain you why you may not need a single-page app, but first: may I ask you a favour?
Are you affected by the recent disabling of PWAs in iOS 17.4?
Last week I wrote about Apple disabling the installation of PWAs in iOS 17.4 beta 1 and 2. This week, iOS 17.4 beta 3 was released and unfortunately, PWAs are still disabled which means this change will almost certainly make it to the final 17.4 release.
If your business relies on a PWA that means it will now be opened as a regular webpage on iOS 17.4. Among other things, this means it can no longer receive push notifications.
This will bring significant trouble to many businesses.
We don't know if or when PWAs will be fixed in future iOS releases as Apple is completely quiet about it. It may be a temporary situation as Apple may not have found a way for third-party browsers to install PWAs or they may just try to kill of PWAs completely.
We just don't know. Apple chose not to respond to any enquiries and that's a shame and signals utter contempt for developers and businesses that rely on PWAs to function.
The Open Web Advocacy (OWA) is a group of software engineers from all over that world who provide regulators, legislators and policy makers the technical details to understand anti-competitive issues like Apple not allowing real third-party browsers on iOS so web apps cannot compete with native apps.
OWA is currently collecting feedback from developers who serve EU customers on how Apple's upcoming changes, specifically the removal of homescreen Web Apps/PWAs will affect developers.
If your business is affected by these recent changes, please fill out the survey here: https://docs.google.com/forms/d/e/1FAIpQLSfNgzepH4lwmWf2kaKC4EpKPdfi69jUHFM8kf4-TBsAyWU1BA/viewform
The only way we can fight this is by letting legislators know how this hurts existing businesses and our industry as a whole.
Thank you! đ
In Modern Web Weekly #20, I wrote about View Transitions and in the paragraph that starts with "Usually, you will apply separate animations to the old and new view" I wrote that the pseudo-element
::view-transition-image-pair(root)
is the parent of ::view-transition-image-old(root)
and ::view-transition-new(root)
.This is of course not correct, this should have been:
::view-transition-image-pair(root)
is the parent of ::view-transition-old(root)
and ::view-transition-new(root)
.Thanks to George Raptis for reporting this!
Are single-page apps now polyfills?
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'}
});
};
nside 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.
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:
- This multi-page app will render just as fast as a single-page app or even faster when page size increases because we use the browserâs streaming HTML parser. Single-page apps bypass the streaming parser and fail to take advantage of it.
- Just like in a single-page app, only the content that changes is fetched from the network. But because the Service Worker caches all assets and serves them locally, network traffic is limited to the absolute bare minimum.
- The complexity of your app is dramatically reduced. Client-side routing is no longer needed and you donât need a framework to render the pages. The Service Worker takes care of all rendering and it operates in its own thread, separate from the main UI thread.
- Server-side rendering comes for free, just stitch the separate templates together and serve them like any other HTML page. These will be served on first render when the Service Worker is not yet controlling the page. On subsequent renders, the Service Worker will seamlessly serve the cached page since there is no client-side routing to take care of.
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.