
Declarative Web Push in Safari on macOS
Safari 18.5 on macOS now supports Declarative Web Push for both web apps running in Safari and web apps added to the Dock.
In Modern Web Weekly #51, I showed the payload that is needed for a notification to be handled declaratively on iOS (and now also macOS):
{
web_push: 8030,
notification: {
title: 'Declarative Web Push',
lang: "en-US",
dir: "ltr",
body: message,
navigate: "https://whatpwacando.today/declarative-web-push",
silent: false,
requireInteraction: true,
vibrate: [100, 50, 100],
app_badge: "1",
actions: [
{
action: "confirm",
title: "Confirm",
navigate: "https://whatpwacando.today/declarative-web-push"
},
{
action: "deny",
title: "Deny",
navigate: "https://whatpwacando.today"
}
],
data: {
name: "What PWA Can Do Today"
}
}
}
I was alerted by reader Alex Gustafsson to the fact that Safari 18.5 crashes as soon as the notification arrives when the app_badge
member is included in the payload. I tested and confirmed this was the case and removed app_badge
. A badge is still shown on the icon on iOS but cannot be specified through app_badge
for now.
How to make cross-document view transitions fast: part 2
In Modern Web Weekly #52, I explained how to implement cross-document view transitions and how to make them faster with the Speculation Rules API.
While this is a good option, this API is currently only supported in Chromium-based browsers. There is however another option that is supported in all browsers that involves the service worker.
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 bypasses 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 into 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, making cross-document view transitions really fast.
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.
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. I added a basic fading view transition for each page so you can see how fast this is.
This is how good browsers are at rendering the DOM if we use the streaming HTML parser.
Conclusion
Using a Service Worker and correctly utilizing the browser’s streaming HTML parser can dramatically boost the performance of your web app and make cross-document view transitions just as fast as same-document transitions.
You are now working with the platform and not against it.
PWA Audit: on your way to a great PWA
Do you already have a PWA, but are you running into issues with performance, security, or functionality? Or are you not sure how to make your PWA better?
I can help you by running an audit of your PWA
I will evaluate it on more than 35 criteria and provide you with clear and actionable instructions on how to improve it. No generic stuff that you can get anywhere, but an in-depth quality checkup to get you on your way to a great PWA.
Some of the criteria I will evaluate it on are:
Installability
Cross-device and cross-platform compatibility
Offline support
Usability
Effective use of modern web APIs
Performance
Security
Your investment in the improvement of your PWA through the audit is €599 excluding VAT (where applicable).
If you want to request an audit or first would like to know more, you can
fill out the form or book a call with me.