Progressive Web Apps with React.js: Part 2 — Page Load Performance

Part 2 of a new series walking through tips for shipping mobile web apps optimized using Lighthouse. This issue, we’ll be looking at page load performance.

Addy Osmani
Oct 4, 2016 · 17 min read

Ensure Page load performance is fast

Mobile web speeds matter. On average, faster experiences lead to 70% longer sessions and 2 x more mobile ad revenue. Investments in web perf saw the React-based, Flipkart Lite triple time-on-site, GQ get an 80% increase in traffic, Trainline make an additional 11M in yearly revenue and Instagram increase impressions by 33%.

  • Speed Index (visual completeness)
  • Estimated Input Latency (when is the main thread available to immediately handle user input)
  • and Time To Interactive (how soon is the app usable & engagable)
  • Under representative network (3G) & hardware conditions
  • Be interactive in < 5s on first visit & < 2s on repeat visits once a Service Worker is active.
  • First load (network-bound), Speed Index of 3,000 or less
  • Second load (disk-bound because SW): Speed Index of 1,000 or less.

Focus on Time to interactive (TTI)

Optimizing for interactivity means making the app usable for users as soon as possible (i.e enabling them to click around and have the app react). This is critical for modern web experiences trying to provide first-class user experiences on mobile.

Housing.com took advantage of Webpack route-chunking to defer some of the bootup cost of entry pages (loading only what is needed for a route to render). For more detail see Sam Saccone’s excellent Housing.com perf audit.

Improving perf with route-based chunking

Webpack

If you’re new to module bundling tools like Webpack, JS module bundlers (video) might be a useful watch.

Code-splitting by routes in practice

Webpack supports code-splitting your app into chunks wherever it notices a require.ensure() being used (or in Webpack 2, a System.import). These are called “split-points” and Webpack generates a separate bundle for each of them, resolving dependencies as needed.

// Defines a "split-point"
require.ensure([], function () {
const details = require('./Details');
// Everything needed by require() goes into a separate bundle
// require(deps, cb) is asynchronous. It will async load and evaluate
// modules, calling cb with the exports of your deps.
});
import App from '../containers/App';function errorLoading(err) {
console.error('Lazy-loading failed', err);
}
function loadRoute(cb) {
return (module) => cb(null, module.default);
}
export default {
component: App,
childRoutes: [
// ...
{
path: 'booktour',
getComponent(location, cb) {
System.import('../pages/BookTour')
.then(loadRoute(cb))
.catch(errorLoading);
}
}
]
};

Bonus: Preload those routes!

Before we continue, one optional addition to your setup is <link rel=”preload”> from Resource Hints. This gives us a way to declaratively fetch resources without executing them. Preload can be leveraged for preloading Webpack chunks for routes users are likely to navigate to so the cache is already primed with them and they’re instantly available for instantiation.

Asynchronously loading routes

Back to code-splitting — in an app using React and React Router, we can use require.ensure() to asynchronously load a component as soon as ensure gets called. Btw, this needs to be shimmed in Node using the node-ensure package for anyone exploring server-rendering. Pete Hunt covers async loading in Webpack How-to.

const rootRoute = {
component: Layout,
path: '/',
indexRoute: {
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./Landing'))
})
}
},
childRoutes: [
{
path: 'book',
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./BookTour'))
})
}
},
{
path: 'details/:id',
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./Details'))
})
}
}
]
}

Easy declarative route chunking with async getComponent + require.ensure()

Here’s a tip for getting code-splitting setup even faster. In React Router, a declarative route for mapping a route “/” to a component `App` looks like <Route path=”/” component={App}>.

<Route 
path="stories/:storyId"
getComponent={(nextState, cb) => {
// async work to find components
cb(null, Stories)
}} />
var IndexRoute = require('react-router/lib/IndexRoute')
var App = require('./App')
var Item = require('./Item')
var PermalinkedComment = require('./PermalinkedComment') <--
var UserProfile = require('./UserProfile')
var NotFound = require('./NotFound')
var Top = stories('news', 'topstories', 500)
// ....
module.exports = <Route path="/" component={App}>
<IndexRoute component={Top}/>
<Route path="news" component={Top}/>
<Route path="item/:id" component={Item}/>
<Route path="job/:id" component={Item}/>
<Route path="poll/:id" component={Item}/>
<Route path="comment/:id" component={PermalinkedComment}/> <---
<Route path="newcomments" component={Comments}/>
<Route path="user/:id" component={UserProfile}/>
<Route path="*" component={NotFound}/>
</Route>
var PermalinkedComment = require(‘./PermalinkedComment’)
<Route path=”comment/:id” component={PermalinkedComment}/>
<Route
path="comment/:id"
getComponent={(location, callback) => {
require.ensure([], require => {
callback(null, require('./PermalinkedComment'))
}, 'PermalinkedComment')
}}
/>

CommonsChunkPlugin

const CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
entry: {
p1: "./route-1",
p2: "./route-2",
p3: "./route-3"
},
output: {
filename: "[name].entry.chunk.js"
},
plugins: [
new CommonsChunkPlugin("commons.chunk.js")
]
}
  • Tree-shaking in Webpack2 will help remove unused exports. This can help keep your bundle sizes smaller.
  • Also, be careful to avoid require.ensure() calls in common/shared bundles. You might find this creates entry point references which have assumptions about the dependencies that have already been loaded.
  • In Webpack 2, System.import does not currently work with server-rendering but I’ve shared some notes about how to work around this on StackOverflow.
  • If optimising for build speed, look at the Dll plugin, parallel-webpack and targeted builds
  • If you need to async or defer scripts with Webpack, see script-ext-html-webpack-plugin

Beyond code-splitting: PRPL Pattern

Polymer discovered an interesting web performance pattern for granularly serving apps called PRPL (see Kevin’s I/O talk). This pattern tries to optimise for interactivity and stands for:

  • (R)ender initial route and get it interactive as soon as possible
  • (P)re-cache the remaining routes using Service Worker
  • (L)azy-load and lazily instantiate parts of the app as the user moves through the application

Implementing PRPL

tl;dr: Webpack’s require.ensure() with an async ‘getComponent’ and React Router are the lowest friction paths to a PRPL-style performance pattern

module.exports = {
entry: "./example",
output: {
path: path.join(__dirname, "js"),
filename: "[chunkhash].js",
chunkFilename: "[chunkhash].js"
},
plugins: [
new webpack.optimize.AggressiveSplittingPlugin({
minSize: 30000,
maxSize: 50000
}),
// ...
  • Pre-caching remaining routes. For caching, we rely on Service Worker. sw-precache is great for generating a Service Worker for static asset precaching and for Webpack we can use SWPrecacheWebpackPlugin.
  • Lazy-load and create remaining routes on demand — require.ensure() and System.import() are your friend in Webpack land.

Cache-busting & long-term caching with Webpack

Why care about static asset versioning?

filename: ‘[name].[chunkhash].js’,
chunkFilename: ‘[name].[chunkhash].js’
const path = require('path');
const webpack = require('webpack');
// Use webpack-manifest-plugin to generate asset manifests with a mapping from source files to their corresponding outputs. Webpack uses IDs instead of module names to keep generated files small in size. IDs get generated and mapped to chunk filenames before being put in the chunk manifest (which goes into our entry chunk). Unfortunately, any changes to our code update the entry chunk including the new manifest, invalidating our caching.
const ManifestPlugin = require('webpack-manifest-plugin');
// We fix this with chunk-manifest-webpack-plugin, which puts the manifest in a completely separate JSON file of its own.
const ChunkManifestPlugin = require('chunk-manifest-webpack-plugin');
module.exports = {
entry: {
vendor: './src/vendor.js',
main: './src/index.js'
},
output: {
path: path.join(__dirname, 'build'),
filename: '[name].[chunkhash].js',
chunkFilename: '[name].[chunkhash].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "vendor",
minChunks: Infinity,
}),
new ManifestPlugin(),
new ChunkManifestPlugin({
filename: "chunk-manifest.json",
manifestVariable: "webpackManifest"
}),
// Work around non-deterministic ordering for modules. Covered more in the long-term caching of static assets with Webpack post.
new webpack.optimize.OccurenceOrderPlugin()
]
};

Further reading

Advanced Module Bundling optimization reads

Thanks to kylemathews and Max Stoiber.

Addy Osmani

Written by

Eng. Manager at Google working on Chrome • Passionate about making the web fast.