April 1, 2020
Beyond single-page apps: alternative architectures for your PWA (Google I/O ’18)

Beyond single-page apps: alternative architectures for your PWA (Google I/O ’18)

hello, everybody. AUDIENCE: Hello. JEFF POSNICK: Hey. My name is Jeff Posnick. And I’m a member of Google’s
Developer Relations Team. So, I’m here to talk about
an important but potentially misunderstood topic– the architecture that
you use for your web app, and specifically, how your
architectural decisions come into play when you’re
building a progressive web app. So what do I mean
by web architecture? Well, one way to think about it
is ask yourself the following questions– when a user visits a page on
my site, what HTML is loaded? And then, what’s loaded when
they visit another page? The answer to those questions
are not always straightforward. And once you start thinking
about progressive web apps, they can get even
more complicated. So my goal during
this session is to walk you through one
possible architecture that I found effective. Throughout the talk,
I’ll label the decisions that I made as being “my
approach” for building a progressive web app. You’re free to use my approach
when building your own PWA, but at the same time,
there are always other valid
alternatives, as well. My hope is that by seeing how
all the pieces fit together will inspire you
and that you’ll feel empowered to go out and
customize everything to suit your own particular needs. So, what I ended up
building to accompany this talk is a
Stack Overflow PWA. So I personally spend
a lot of time reading and also contributing
to Stack Overflow. And I wanted to
build a web app that would make it easy to
browse through frequently asked questions
for a given topic. It’s built on top of the
public stock exchange API. And you can try it out live
and view the source code by using the URLs on that slide. So, before we get into
the specifics of how I built that web app,
let’s define some terms and explain pieces of
underlying technology. First, we’re going to be talking
about what I like to call Multi-page Apps or MPAs. You can think of
MPAs as a fancy name for the traditional architecture
that’s been used really since the beginning of the web. Each time a user
navigates to a new URL, the browser progressively
renders the HTML specific to that page. There’s no attempt to
preserve the page’s states or the content in
between navigations. Each time you visit a new
page, you’re starting fresh. This is in contrast to
the single-page app model for building web apps, in which
the browser runs JavaScript code to update the
existing page when the user visits a new section. Now both the SPAs and MPAs
are equally valid models for you to use. But for this talk, I wanted
to explore PWA concepts within the context of a
multi-page app, something that we haven’t really talked
about too much in the past. So next up, you’ve heard me
and certainly countless others use the phrase, Progressive
Web App, or PWA. But what do I mean
by that, exactly? So you can think
of a PWA as a web app that provides a
first-class user experience and that truly earns a place
on a user’s home screen. The acronym FIRE, standing for
Fast, Integrated, Reliable, and Engaging, sums
up all the attributes to think about when
you’re building a PWA. For the purposes of this
talk, however, we’re going to focus on a subset
of those attributes, fast and reliable. While fast means a lot of
things in different contexts, we’re going to focus on the
speed benefits of ballooning as little as possible
from the network. But raw speed isn’t enough. In order to feel like
a PWA, your web app should be reliable. It needs to be resilient enough
to always load something, even if it’s just a customized
error page, regardless of the state of the network. And finally, we’re going to
rephrase the PWA definition slightly, and look at it what it
means to build something that’s reliably fast. Now, it’s not good enough
to be fast and reliable only when you’re on a
low-latency network. Being reliably fast means
that your web app speed is consistent, regardless of the
underlying network conditions. So PWA has introduce a high
bar for speed and resilience. Fortunately the web platform
offers some building blocks to make that type of
performance a reality. I’m talking, of course, about
service workers and the Cache Storage API. So a service worker sits in
between your web application and the network, acting
as a proxy that could intercept incoming requests. And what that service worker
does is completely up to you. In this basic illustration,
the service worker takes incoming requests
and forwards them on to the network. It then just returns a network
response to the page, as is. Now this trivial service
worker doesn’t actually add any value, versus the
default member behavior. But once we add Cache
Storage API into the mix, the power of service
workers shine through. So we can build a
service worker that listens for incoming requests
like before, passing some on to the network. But instead of just returning
the network response to the page, we could
write a service worker that stores a copy of the
response for future use via the Cache Storage API. Next time our web app
makes the same request, our service worker
can check its caches and just return the
previously cached response. So avoiding the network
whenever possible. Is a crucial part of offering
reliably fast performance. One more concept
that I want to cover is what’s sometimes
referred to as isomorphic or
universal JavaScript. That’s not a great name for it. Simply put, it’s the idea
that the same JavaScript code can be shared between
different runtime environments. So when I built my
PWA, I wanted to share JavaScript code between my
backend server and the service worker. There are lots of valid
approaches to sharing code in this way, but the
approach that worked for me was to use ES modules as
a definitive source code. I then transpiled and bundled
those modules for the server and for the service worker
using a combination of Babel and Rollup. So in my slides, when you
see an .mjs file extension, we’re talking about code
that lives in an ES module. So keeping those concepts
and terminology in mind, let’s dive into how I actually
built my Stack Overflow PWA. We’re going to start by
covering our backend server, and explain how that fits
into the overall architecture. So I was looking for a
combination of dynamic backends along with static hosting. And my personal approach was
to use the Firebase platform. Firebase Cloud Functions
will automatically spin off a node-based
environment when there’s an incoming request. It integrates with the popular
Express HTTP framework, which I was already familiar with. It also offers out-of-the-box
hosting for all of my site’s static assets. And let’s take a look at
how that server handles incoming requests. So when a browser
makes a navigation request against
our server, it goes through the following flow. The server routes the
request based on the URL, and it uses templating logic
to create a complete HTML document. We use a combination of data
from the Stack Exchange API, as well as partial
HTML fragments that the server stores locally. So once we know how to respond,
we start streaming HTML back to our web app. So there are two
pieces of this picture worth exploring in more
detail, routing and templating. When it comes to
routing, my approach was to use the Express
framework’s native routing syntax. It’s flexible enough to match
simple URL prefixes, as well as URLs that include
parameters, such as the question ID as part of the path. As mentioned earlier,
we’re using ES modules as a source of truth for my
isomorphic JavaScript code. Here we create a mapping
between route names and the underlying express
pattern to match against. I can then reference
this mapping directly from the server’s code. When there’s a match for
a given Express pattern, the appropriate handler with
templating logic specific to the matching route is
given a chance to respond. And what does that
templating logic look like? Well, I went with
an approach that pieced together partial HTML
fragments in sequence, one after another. So this model lends
itself really well to streaming response
back to the browser. The server sends back some
initial HTML boilerplate immediately, and the
browser is able to render that partial page right away. As the server pieces together
the rest of the data sources, it streams them to
the browser as well, until the document is complete. So to illustrate what I
mean, let’s take a look at the express code
from one of our routes. By using the
response’s write method and referencing locally-stored
partial templates, we’re able to start the
response stream immediately without blocking on any sort
of external data source. So the browser takes
this initial HTML and renders a meaningful
interface and loading message right away. So that loading please wait
message gives important context to our users, but it’s
not the sort of thing that you might expect to
see in a multi-page app. Rather than rely
on JavaScript, I’m using the empty sudo
class in the page’s CSS to display the message. And then it will automatically
disappear once the real content starts streaming in. So going back to
our route handler. The next portion of
our page uses data from the stack exchange API. Getting that data means
that our server needs to make a network request. We can’t render anything else
until we get a response back and process it. But at least our user isn’t
staring at a blank screen while they wait. And once we’ve
received the response from the stack
exchange API, we call a custom templating function to
translate the data from the API into its corresponding HTML. Now templating can be a
surprisingly contentious topic, and what I’m presenting here is
just one approach among many. You’ll definitely want to
substitute in your own solution here, especially if you have
legacy ties to an existing templating framework. But what made sense
for my use case was to just rely on
JavaScript’s template literals with some logic broken
out into helper functions. So one of the nice things
about building in MPA is that we don’t have to
keep track of state updates and re-render our
HTML as things change. So a basic approach that
produced static HTML worked fine for me. All right, so here’s
an example of how I’m templating the dynamic HTML
portion of our web app’s index. As with our routes,
the templating logic is stored in an
ES module that can be imported into both the
server and the service worker. And just a reminder,
whenever you’re taking user-provided input
and converting into the HTML, it’s crucial that you take care
to properly escape potentially dangerous character sequences. If you’re using an existing
templating solution rather than rolling your own,
that might already be taken care of for you. OK, so these template
functions are pure JavaScript, and it’s useful to break out
the logic into smaller helper functions when appropriate. Here I pass each of the items
returned in the API response into one such function,
which creates a standard HTML element with all the
appropriate attributes set. Of particular note
is a data attribute that we add to each link, set
to the Stack Exchange API URL that we need in order to display
the corresponding question. Keep that in mind, and
we’ll revisit it later on. OK so jumping back
to our route handler. Once templating is complete,
we stream the final portion of our page’s HTML to the
browser, and we end the stream. This is the key to the browser
that the progressive rendering is complete. OK, so that’s a brief
tour of our server setup. And users who visit our
website app for the first time will always get a
response from the server. But when a visitor
returns to our web app, our service worker will get
a chance to start responding. And let’s dive in there. Now this diagram should
looks somewhat familiar. Many of the same pieces
we’ve previously talked about are here, in slightly
different arrangement. Let’s walk through the request
flow for the service worker, taking all of its
logic into account. So our service worker handles
an incoming navigation request for a given URL. And just like our
server did, it has to use a combination of
routing and templating logic to figure out how to respond. The approach is
the same as before, but with different
low-level primitives, like Fetch, and the
Cache Storage API. We use those data sources to
construct our HTML response, which the service worker
passes back to our web page. So rather than
starting from scratch with those low-level
primitives, we’re going to build
our Service Worker on top of a set of high-level
libraries called Workbox. So I’m a member of the
Workbox engineering team, and this is not
exactly unbiased, but I think using Workbox
provides a solid foundation for any Service Worker’s
caching, routing, and response generation logic. Throughout the
next set of slides we’ll see how Workbox
is put to use. First let’s cover
service worker routing. Just as with our
server-side code, our service worker
needs to know how to match up an incoming request
with the appropriate response logic. My approach was to
translate each express route into a corresponding
regular expression making use of a helpful library
called RegX prem. Once that translation
is performed, we can take advantage of
Workbox’s built-in support for regular expression routing. So after importing
the module that has our regular
expressions, we register each one with Workbox’s router. Inside each route, we’re able to
provide custom templating logic used to generate a response. Templating in the service
worker is a bit more involved than it was in our
backend server, but Workbox helps with a
lot of the heavy lifting. So first we need to make
sure that our partial HTML templates are locally available
in the Cache Storage API, and are always kept up to
date whenever we deploy changes to our web app. Cache maintenance can be
error-prone when done by hand, so we turn to Workbox to handle
pre-caching as part of our build process. We tell Workbox which URLs to
pre-cache using a configuration file pointing to the
directory that contains all of our local assets
along with a set of patterns to match. This file is automatically
read by the Workbox CLI, which is run each time we rebuild
our site as part of my build process. So Workbox takes a snapshot
of each file’s contents and automatically injects that
list of URLs and revisions into our final
service worker file. Workbox now has
everything it needs to make sure our pre-cache
files are always available, and always kept up-to-date. For folks who use a more
complex build process, Workbox has both a
Webpack as well as a generic node module
interface in addition to its command line. All right, so next, we want
our service worker to stream that pre-cache partial
HTML back to the web app immediately
without any delays. This is a really crucial
part of being reliably fast. We always get something
meaningful on the screen right away. Fortunately, using the streams
API within our service worker makes that possible. Now you might have heard
about the streams API before. My colleague Jake Archibald
has been singing its praises for years now. He made this bold
prediction back in 2016. And the streams API is
just as awesome today as it was two years ago, but
with a crucial difference. While only Chrome supported
the streams back then, the streams API is much
more widely supported now. All right, so now,
careful observers might note that asterisk. So here are the
caveats for today. Firefox’s support for
streams is behind a flag, and it’s not yet
enabled by default, but you can try it
out if you go in and manually make that change. And there’s also
a known bug that can lead to issues with Edge’s
current streams implementation. But the overall
story is positive, and with appropriate
fallback code, there’s nothing stopping
you from using streams in your service worker today. Well, there might be
one thing stopping you, and that’s wrapping
your head around how the streams
API actually works. it exposes is a very
powerful set of primitives, and developers who are
comfortable using it can create really complex data flows. But understanding the
full implications of code similar to what’s on
the screen right now might not be for everyone. Rather than parse
through this logic, let’s talk about my approach to
service worker streaming. So I’m using a brand
new, high-level wrapper provided by Workbox. I can pass it in a mix
of streaming sources, both from caches and
runtime data, that might come from the network. Workbox will take
care of coordinating the individual sources and
stitching them together into a single
streaming response. Moreover, Workbox automatically
detects whether the streams API is supported, and
when it’s not, it creates an equivalent– though
non-streaming –response. This means that you
don’t have to worry about writing fallbacks
as streams inch closer to 100% browser support. All right. Let’s turn our attention
to how our service worker deals with runtime data
from the stack exchange API. So we’re making use
of work boxes built in support for a
stale-while-revalidate caching strategy, along with
expiration to ensure that our storage
doesn’t grow unbounded. Let’s take a look at how those
concepts translate into code. Here we set up two
strategies in Workbox to handle the different
sources that will make up the final streaming response. In a few function calls
and some configuration, Workbox lets us do what would
otherwise take hundreds or even thousands of lines
of handwritten code. So the first
strategy will be used to read data that’s
been pre-cached, like our partial HTML templates. The other strategy implements
that stale-while-revalidate caching logic along with the
least recently used cache expiration once we
reach 50 entries. Now that we have the strategies
in place, all that’s left is to tell Workbox how
to use them to construct a complete streaming response. We pass in an array of
sources as functions, and each of those functions
will be executed immediately. Workbox takes the
result from each source and streams it to the
web app in sequence, only delaying if the next
function in the array hasn’t completed yet. So the first two sources are
pre-cached partial templates that read directly from
the cache storage API, and so they’ll always be
available immediately. This ensures that our
service worker implementation will be reliably fast in
responding to requests, just like our
server-side code was. Our next source function fetches
data from the stack exchange API and processes the
response into HTML that our web app expects. The stale-while-revalidate
strategy means that if we have a
previously cached response for this API call, we’ll be
able to stream it to the page immediately while
the cache entry in the background for the
next time that it’s requested. Finally, we stream in a
cached copy of our footer, and close our finally HTML
tags to complete the response. So now we’ve run through
the service worker code, certain bits hopefully
look familiar. Partial HTML and tempating
logic used by our service worker is identical to what our
server-side handler uses. This code-sharing
ensures that users get a consistent
experience, whether they’re visiting our web app
for the first time or if they’re returning
to a page that’s been rendered by the service worker. That’s the beauty of using
isomorphic JavaScript. OK, so we’ve walked through
both the server and the service worker for our PWA, but there’s
one last bit of logic to cover. There’s a small
amount of JavaScript that runs on each of
our pages after they’re fully streamed in. This code progressively
enhances the user experience, but it isn’t crucial. The web app will still
work if it’s not run. So one thing we’re using
client-side JavaScript for is to update a page’s metadata
based on the API response. Because we end up using the
same initial bit of cached HTML for each page, we end
up with generic tags in our document’s head. But through coordination
between our templating and our client-side code, we
could update the window’s title using page-specific metadata. So as part of the
templating code we include a script
tag containing the properly-escaped string. Then once our page is loaded,
we read in that string, and we update the
document title. Now if there are other pieces
of page-specific metadata you want to update
in your own web app, you can follow
the same approach. So the other progressive
enhancement we’ve added is used to bring attention
to our offline capabilities. And we’ve built a reliable
PWA, and we want our users to know that when
they’re offline they can still load
previously-visited pages. We do this in two stages. First, we use the cache storage
API from within the context of our client-side JavaScript. It gives us a list of all the
previously-cached API requests, and we translate that
into a list of URLs. Remember that special
data attribute we talked about containing the
URL for the API request needed to display a question? So we could cross-reference
those data attributes against the list of
cached URLs, and create an array of all the question
links that don’t match. And this gives us
all the data we need to create handlers
that respond to the browser going online or offline. So when a browser
enters an offline state, we’ll loop through the
list of uncached links and dim out the ones
that won’t work. So keep in mind
that this is just a visual hint to the
user about what they should expect from those pages. We’re not actually
disabling the links or preventing the user
from navigating there. And when the browser
comes back online, we restore those links to
their original opacity. All right. We’ve now gone through
a tour of my approach to building a multi-page PWA. There’s a lot of
factors that you’ll have to consider when coming
up with your own approach, and you may end up making
different choices than I did, and that flexibility is one of
the great things about building for the web. But there are a
few common pitfalls that you may
encounter when making your own architectural
decisions, and I wanted to call them out
in advance to hopefully save you some pain. So first, I’d really recommend
against storing complete HTML documents in your cache. For one thing it’s
a waste of space. If your web app uses
the same basic HTML structure for each
of its pages, you’ll end up storing copies of the
same markup again and again. More importantly
though, let’s say you deploy a change to your
site’s shared HTML structure. Each one of those
previously-cached pages is still stuck with
your old layouts. You can imagine the frustration
when return visitors come back and see a mix of
old and new pages depending upon whether they’ve
previously visited a given UR. So the other pitfall
to avoid involves your server and your service
worker getting out of sync. My approach was to use
isomorphic JavaScript so that the same code was
always run in both places. But depending on your
existing server architecture, that’s not always possible. Whatever architectural
decisions you end up making, you need to have some strategy
for running the equivalent routing and templating code in
your server and your service worker. So what happens when you
ignore those pitfalls? Well, all sorts of
failures are possible, but the worst-case scenario is
that a user returns your site and navigates around, eventually
revisiting a cached page with very stale layouts. So it’s jarring when you see it
happen during the presentation, and it’s just as distracting for
your web app’s repeat visitors, so do everything you
can to avoid this. Alternatively, they might
come across a URL that’s handled by your server but is no
longer handled by your service worker. An experience full of zombie
layouts and routing dead ends is the opposite of
shipping a reliable PWA. But you’re not in this alone. The following tips can help
you avoid those pitfalls. Try to use templating
and routing libraries that have
JavaScript implementations. Now I know that
not every developer has the luxury of migrating
off your current web server in templating language. But a number of popular
templating and routing frameworks have implementations
in multiple languages, and if you can find one
that works with JavaScript as well as your current
server’s language, you’re one step closer
to keeping your service worker and your server in sync. Next, I recommend using a
series of sequential templates that can be streamed in
one right after another. It’s OK if later
portions of your page use more complicated templating
logic, as long as you could stream in the initial
parts of your HTML as quickly as possible. For best performance, you should
pre-cache all of your site’s critical static resources. You should also set up
a runtime caching route to handle dynamic content
like API requests. Using Workbox means
that you can build on top of well-tested
production-ready strategies instead of implementing
it all from scratch. And related to that, you should
only block on the network when it’s not possible to stream
a response from the cache. Displaying a cached API
response immediately can often lead to
better user experience than waiting for
that fresh data. So if you follow the
general guidelines that I’ve covered today,
you’re on your way to building a first-class
PWA experience, an experience that’s fast, loading as little
as possible from the network. In the case of my
stack overflow PWA, almost all the bytes were
loading are for actual content, with almost no overhead. In building an experience
that’s reliable by displaying something meaningful,
even when you’re offline, our PWA uses visual
cues to reinforce that we’re providing
a reliable experience, and an experience
that’s reliably fast by streaming
in our initial HTML immediately, followed
by page-specific content regardless of the
network conditions. So that about
wraps it up for me. We’re interested in your
feedback on this presentation. Please go to our website, or
use the Android or iOS apps to let us know what you think. And if you want to learn more
about the topics covered, these links will help. So thanks, everybody,
for watching. [APPLAUSE] Thank you. [MUSIC PLAYING]

10 thoughts on “Beyond single-page apps: alternative architectures for your PWA (Google I/O ’18)

  1. So good to see a PWA which isn't a JS SPA.😘

    Source code here I think: https://github.com/jeffposnick/application-shell

  2. You are aware however, that escape() and unescape() are actual JavaScript functions which are deprecated? https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/escape https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/unescape

  3. 11:33 how to handle caching headers? 19:20 why do you bother keeping the staic part in html files and precaching them, when you could have just written them as JS template literal, like you did with the dynamic parts?

  4. 31:45 cross-language templating libraries are common but.. routing? If anyone has got examples of cross-language routing, I'm interested

  5. Hey, I am integrating workbox to my web project with the goal of making it PWA.
    In most of the tutorials, people mention how to precachHTMLml and dynamically cache JSON responses.

    My web project is server-side rendered, I use twirl templating and serve compiled template from the server on each route change. So there is no way that I can precache HTML. I have to cache HTML dynamically. I don't see anyone talking about this. So can you please let me know what I am missing here.

Leave a Reply

Your email address will not be published. Required fields are marked *