How to combine PWA and isomorphic rendering (SSR)?

I wish someone had told me when you try those both technologies together you can run into troubles, but the solution for this is not that hard.

Published: 2017-11-16

Adding to a project isomorphic rendering or PWA can be easily done with the guide of some tutorials. Problems can occur if you try true offline with PWA and cache application shell when server-side rendering is isomorphic. I found a simple trick how to combine together those both technologies. I regret that some PWA tutorials don't show me this before.

Plan for application

First enter
  1. Server-side rendering with data ready to rehydrate
  2. Bootstrap on the client side
  3. Register Service Worker
  4. Cache requests
Second enter
  1. Serve app shell from cache
  2. Fetch data on the client side
Final blog

The app we are going to build is not that much (only one route showed above), but it's perfectly enough to show core concepts were adding together PWA and isomorphic rendering can fall apart. According to PWA guidelines app should use app-shell model, so I highlighted what part of the application is static (can be cached) and frequently changing content (since this application isn't cached):

division of blog on dynamic content and app shell

All code used in this article you can find on GitHub. Each chapter has related to it branch: branch stage1 contains code after chapter 1, branch stage2 contains code after chapter 2, etc., you get the idea.

1. Initial project

But first I am obliged to introduce a bare-bones example. In this chapter, I will show how to build basic app setup without PWA and isomorphic rendering. So if you want you can jump to the next chapter where I add this features or to the last section where discuss how to solve the problem mentioned in the title.

Project structure
.
├── client
│   ├── blog.js
│   └── client.js
├── package.json
├── public
│   ├── logo.png
│   ├── script.js  <- build by webpack
│   └── style.css
├── server
│   └── getPosts.js
├── server.js
└── webpack.config.js
webpack.config.js
const path = require('path');

module.exports = {
    entry: path.resolve(__dirname, 'client', 'client.js'),
    output: {
        path: path.resolve(__dirname, 'public'),
        filename: 'script.js',
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['babel-preset-env'],
                    },
                },
            },
        ],
    },
};

Whole webpack here is used only to be able to use import syntax, this is not required at this moment but it will be in the future. So be prepared.

server.js
const express = require('express');
const app = express();
const getPosts = require('./server/getPosts');

app.use(express.static('public'));

const html = () => `
    <!DOCTYPE html>
    <head>
        <link rel="stylesheet" href="style.css">
    </head>
    <html>
        <body>
            <div id="app">
                <div class="loading">loading</div>
            </div>
            <script src="script.js"></script>
        </body>
    </html>
`;

app.get('/', (req, res) => {
    setTimeout(() => { // pretend heavy computations
        res.send(html());
    }, 1000);
});

app.get('/posts', (req, res) => {
    getPosts(posts => { // getPosts will respond after 1s
        res.send(posts);
    });
});

app.listen(3000, () => console.log('App is running http://localhost:3000'));

In server.js you can find that app has only one main route / which serve static HTML with Loading text and id="app" anchor for bootstrapping app on the client side. /posts route will respond with fake random generated data. You should notice that I delayed by 1s HTML response and getPost function - this will be helpful for displaying the progression of rendering app on a timeline and in some sense pretends delays in the real apps.

client.js
import blog from './blog';

const renderBlog = () => {
    document.getElementById('app').innerHTML = blog();
};

setTimeout(() => { // fake dalay pretending heavy client JS computations
    renderBlog();
}, 1000);
blog.js
const blog = () => 'here will be blog';

export default blog;

Currently, there is not much on the client side, just "Hello World" concept showing the most primitive way of rendering on the client side. This approach is perfectly fine for this tutorial, you will be not distracted by some sort of angular/react/vue/etc. jargon. Oh and I also add 1s delay pretending heavy JS computations on the client side.

At this point after running node server.js and visiting http://localhost:3000 app should render like this:

loading timeline of hello world blog version

2. Render blog on the client side

At this moment project setup is finished. We can finally add some HTML markup to render application on the client side. There will be nothing fancy here, one function which takes articles data and returns HTML markup regarding if data exists or not. How it looks like:

client.js
import blog from './blog';

const renderBlog = data => {
    document.getElementById('app').innerHTML = blog(data);
};

setTimeout(() => {
    renderBlog();

    fetch('/posts')
        .then(res => res.json())
        .then(res => renderBlog(res));
}, 1000);
blog.js
import skeletonPost from './skeletonPost';
import post from './post';

const blog = data => `
    <header>
        <div class="container">
            Median - awesome blog
            <nav>
                <a href="#">here</a>
                <a href="#">should</a>
                <a href="#">be</a>
                <a href="#">navigation</a>
            </nav>
        </div>
    </header>
    <main class="container">
        <section>
            <h2>Newest articles</h2>
            ${
                data
                    ? data.map(postData => post(postData)).join('')
                    : [skeletonPost(), skeletonPost()].join('')
            }
        </section>
        <aside>
            <img width="200" height="200" src="/logo.png">
            <h1>Welcome to Median</h1>
            A place to read stories that matter most to you.
            Every day, thousands of lorem writers share
            important stories on Median.
        </aside>
    </main>
`;

export default blog;
skeletonPost.js
const skeletonPost = () => `
    <article>
        <div class="article__header">
            <span class="article__avatar article__avatar--skeleton"></span>
            <div class="article__top">
                <h3 class="article__title article__title--skeleton"></h3>
                <div class="article__date article__date--skeleton"></div>
            </div>
        </div>
        <div class="article__teaser article__teaser--skeleton">
            <span></span>
            <span></span>
            <span></span>
        </div>
    </article>
`;

export default skeletonPost;
post.js
const post = data => `
    <article>
        <div class="article__header">
            <img class="article__avatar" src="${data.avatar}">
            <div class="article__top">
                <h3 class="article__title">${data.title}</h3>
                <div class="article__date">${data.date}</div>
            </div>
        </div>
        <div class="article__teaser">${data.teaser}</div>
    </article>
`;

export default post;

First render is without data and it will show 2 skeleton posts. After one-second later blog will be fully rendered with 5 "articles". Now rendering timeline looks like:

loading timeline of SPA version blog

3. Cache with service workers (PWA)

After quite a long introduction we are moving to more interesting parts. We will add a simple cache for static assets and / route - they will be served with the cache first strategy after the first entry. Handling endpoint with /posts by service worker will not be covered here - it's not important for this tutorial. Most of this code comes from Service Workers: an Introduction tutorial. If you want to read more about advanced service worker use checkout Michal Załęcki article.

server.js
<body>
    <div id="app">
        <div class="loading">loading</div>
    </div>
    <script src="script.js"></script>
    <script> // --- new node ---
        if ('serviceWorker' in navigator) {
            window.addEventListener('load', () => {
                navigator.serviceWorker.register('/sw.js');
            });
        }
    </script>
</body>
sw.js
const CACHE_NAME = 'cache-token';
const urlsToCache = ['/', '/style.css', '/script.js', '/logo.png'];

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
    )
});

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request).then(response => response || fetch(event.request))
    );
});

In server.js we are adding service worker registration. In our sw.js we determinate 4 URLs to cache: 3 static resources /style.css, /script.js, /logo.png, and one route /. install event will fetch all urlsToCache and add responses to the cache. It is worth mention that in our case all 4 URLs will be invoked twice: one during normal rendering page and the second time from service worker. This is normal behavior and is a clue for solving some problem we will run into in the future. You can see this in a network timeline:

network record of traditional requests and service worker requests

On repeat visits, urlsToCache resources will be served with cached-first strategy. This is defined in fetch event. So we can save one second by omitting network fetch of / route and serve it from a cache.

loading timeline of PWA blog version

4. Isomorphic rendering

For sake of simplicity in this chapter I turned out service worker by simply commenting it out // navigator.serviceWorker.register('/sw.js'); (don't forget to unregister service workers in browser).

Since our blog function defined in blog.js simply return string with html markup we can reuse it on node backed side. Just in app.get('/') fetch posts and pass them to html function and replace <div class="loading">loading</div> with ${blog(data)}. Additionally pass data in window.__PRELOADED_STATE__ = ${JSON.stringify(data)} for future rehydration state on the client side.

server.js
const express = require('express');
const app = express();
const getPosts = require('./server/getPosts');

import blog from './client/blog';

app.use(express.static('public'));

const html = data => `
    <!DOCTYPE html>
    <head>
        <link rel="stylesheet" href="style.css">
    </head>
    <html>
        <body>
            <div id="app">${blog(data)}</div>
            <script>
                window.__PRELOADED_STATE__ = ${JSON.stringify(data)}
            </script>
            <script src="script.js"></script>
            <script>
                if ('serviceWorker' in navigator) {
                    window.addEventListener('load', () => {
//                      navigator.serviceWorker.register('/sw.js');
                    });
                }
            </script>
        </body>
    </html>
`;

app.get('/', (req, res) => {
    setTimeout(() => { // 1s delay
        getPosts(posts => { // 1s delay
            res.send(html(posts));
        });
    }, 1000);
});

app.get('/posts', (req, res) => {
    getPosts(posts => { // 1s delay
        res.send(posts);
    });
});

app.listen(3000, () => console.log('App is running http://localhost:3000'));

On the client side now we can determinate if it was rendered with fully HTML markup and only rehydrate state or window.__PRELOADED_STATE__ is missing so render on the client side (like in the previous chapters). This step can seem a little bit redundant but in next chapter will be required.

client.js
import blog from './blog';

const renderBlog = data => {
    document.getElementById('app').innerHTML = blog(data);
};

setTimeout(() => {
    if (window.__PRELOADED_STATE__) { // only rehydrate
        renderBlog(window.__PRELOADED_STATE__);
    } else { // only client side render
        renderBlog();

        fetch('/posts')
            .then(res => res.json())
            .then(res => renderBlog(res));
    }
}, 1000);

It seems that isomorphic capabilities of app is finished. But there is problem with line import blog from './client/blog'; in server.js - currently node doesn't support ES modules imports using ES modules requires .mjs extension. We can convert our code to use "Michael Jackson Script" but in real project when in isomorphic part we will try to use for example JSX or importing style this .mjs will not help. So we must pass our server.js through webpack build process:

webpack.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

const commonModule = {
    rules: [
        {
            test: /\.js$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ['babel-preset-env'],
                },
            },
        },
    ],
};

module.exports = [
    {
        // client
        entry: path.resolve(__dirname, 'client', 'client.js'),
        output: {
            path: path.resolve(__dirname, 'public'),
            filename: 'script.js',
        },
        module: commonModule,
    },
    {
        // server
        entry: path.resolve(__dirname, 'server.js'),
        output: {
            path: __dirname,
            filename: 'server.build.js',
        },
        module: commonModule,
        target: 'node',
        externals: [nodeExternals()],
    },
];

Now this code should work fine. Remember that from now to starting server you have to run command node server.build.js. Timeline for this chapter is quite boring but I feel obligated to show it.

loading timeline of SSR blog version

5. Service worker + isomorphic rendering

Now if we turn on back again navigator.serviceWorker.register('/sw.js'); in server.js we should have both service worker cache (PWA) and isomorphic rendering in an application! But what will then happen instead, let's see :

broken loading timeline of PWA + isomorphic rendering

The problem lies in '/' in urlsToCache in sw.js, in chapter 3 it was caching HTML with "Loading" title, but when we add isomorphic rendering service worker is caching whole rendered blog. So on repeat visits, the user will always see old articles cached during install event.

This problem was my motivation for this article. And as you should guess in next paragraphs I will show a solution for this.

We should prepare our server to send shell version of the app, it can be simply done by adding a parameter to request URL /?shell . This can be also done in request header but parameter approach is easier to show.

server.js
app.get('/', (req, res) => {
    setTimeout(() => {
        const shell = typeof req.query.shell !== 'undefined';
        if (shell) {
            res.send(html()); // send shell verion
        } else {
            getPosts(posts => {
                res.send(html(posts)); // send fully rendered version
            });
        }
    }, 1000);
});

Service worker should also be modified. But first I will show it final version and later explain in detail.

sw.js
const CACHE_NAME = 'cache-token';
const staticUrlsToCache = ['/style.css', '/script.js', '/logo.png'];
const shellUrlsToCache = ['/'];
const urlsToCache = [
    ...staticUrlsToCache,
    shellUrlsToCache.map(url => `${url}?shell`),
];

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
    );
});

self.addEventListener('fetch', event => {
    let request = event.request;

    const shellUrl = shellUrlsToCache.find(url => request.url.endsWith(url));
    if (shellUrl) {
        request = new Request(`${shellUrl}?shell`);
    }

    event.respondWith(
        caches.match(request).then(response => response || fetch(request))
    );
});

Original urlsToCache is now distributed on two arrays one with static assets and one with urls which have shell version. shellUrlsToCache are ended with ?shell suffix, so in install event service worker will cache resources: /style.css, /script.js, /logo.png and /?shell. So far so good, now we are not caching / route!

Now on repeat visits, we must serve somehow shell version. This is the tricky one - I didn't realize that service worker can be so powerful. In fetch event, we are checking event URL, and if it matches one of our shellUrlsToCache we will make a new Request with appended ?shell parameter to it. So when the user enters http://localhost:3000 on repeat visit service worker will send him http://localhost:3000/?shell from a cache. In browser bar user will still see http://localhost:3000, how brilliant is that - and this is not redirect.

working loading timeline of PWA + isomorphic rendering

Our journey ends here. We have got the isomorphic app ready for engine crawlers and social sharing previews. Also, the app is ready for extending it with more service worker goodness like more advanced cache strategies for /posts resources - but this is a story for another article.