In recent years the idea of the PWA (progressive web apps) has grown massively. Several of the major JavaScript boilerplate libraries have made changes to be more compliant (create-react-app for example).
The idea is that the user should have a first-class experience anywhere, and that includes on a mobile device or a bad internet connection. Two things which often have noticeable performance impacts for websites which rely on the client-side for all JS downloading/processing and therefore are render blocking.
My company have a sizeable chunk of traffic from mobile/tablet (around the 40% mark) and many of which have bad experiences of our website. So we set about using this Christmas period to change our 100% client-side app to 100% server-side and progressively enhance on the client-side.
Our web application is using react/redux-saga so that is what I will focus on here. Note that his was a collaborative team effort and this is the output of our efforts.
We moved all component action dispatches into componentWillMount
lifecycle method. It is called on both the server and the client. It is triggered immediately before mounting and render occurs.
For us this was a matter of moving out of our custom component initialiser.
function init(x) {return dispatch(someAction(x));}Component.init = init;
For:
componentWillMount() {this.props.someAction(this.props.x);}
In addition to above we also made use of the componentWillReceiveProps
lifecycle method for client-side navigating (it will trigger the same action). So further page loads handled by the browser fetch the correct data.
Using react-saga’s real-world example we can tell our app to:
/server.js
store.runSaga(rootSaga).done.then(() => {res.status(200).send(layout(renderToString(rootComp),JSON.stringify(store.getState())))});renderToString(rootComp);store.close()
/store/configureStore.prod.js
store.runSaga = sagaMiddleware.run;store.close = () => store.dispatch(END);
As the END channel only watches for those actions fired initially, if you have an async request which depends on another (e.g. to get a users cart you must get the users ID first) it will not work on the SSR so far.
You can use a channel factory to achieve this. The main bulk of it is something like:
/something-saga.js
function* fetchSomethingSaga() {const ourChannel = yield call(channel);// create channel factory to queue incoming requests
yield fork(somethingElseHandler, ourChannel);// create a worker thread for forked saga and supply channel
try {while(true) { // loop so watch works more than onceyield take('FETCH_SOMETHING'); // watching for actionconst items = yield call(fetchItems); // 1st async call
yield put(ourChannel, fetchSomethingElseAction(items));
// 2nd async call via action and send payload into channel
}
} finally { // END triggeredourChannel.close(); // close + unsubscribe from channel}}
/something-else-saga.js
export function* somethingElseHandler(channel) {while(true) {const action = yield take(channel)// observe the handed channel factory
yield call(fetchSomethingElse, action)
// make async request with the channel factory
}}
For further details find the example and comments on the Saga documentation and expanded on in the Github issue.
Very useful for multiple dependent async requests.
Imagine the following page:
Main page content
Extra content relating to main content
Imagine both of the above are SSR only (via below aka not dispatching action if data is already in the store, no point client fetching if server has done it already).
/main-component.js
componentWillMount() {if (!this.props.someValueFromStore) { // via mapStateToPropsthis.props.loadValue(this.props.x); // via mapDispatchToProps}}
Yet you are aware the “extra” content loads below-the-fold therefore would be better for the user if it was lazy-loaded. We must decouple the requests from each other. See its saga:
/main-content-saga.js
function * fetchMainContent({payload}) {const { itemId } = payload;
yield put(loadMainContent(itemId));const mainData = yield call(fetchContent(itemId));yield put(loadMainContentSuccess(itemId));
// load extra contentyield put(loadExtraContent(mainData.extraId));const extraData = yield call(fetchExtraValue(mainData.extraId));yield put(loadExtraContentSuccess(mainData.extraId));}
I thought it best to change the above so instead of following a flow of events to determine which extra data to fetch, it uses data available in the store.
Just a small change below and moving \\ load extra content
block into its own action and saga. Lastly a small change to our component below:
/main-component.js
componentWillMount() {if (!this.props.someValueFromStore) {this.props.loadValue(this.props.x);} else {this.props.loadExtraValue(this.props.someValueFromStore);}}
Now we have a logic branch split between having main data in the store and not, effectively acting like server vs client but in a more concise manner.
As we will defer the bundle execution it will mean the page will render and only then the loadExtraValue
will dispatch.
We will need both actions added to componentWillReceiveProps
so all client-side navigations trigger get both.
Now that all your components are rendered on the server you may encounter some difficulties with regards to SASS/styling/3rd parties etc.
For example we were using react-media (https://www.npmjs.com/package/react-media), a CSS media query component. However its documentation mention:
If you render a
<Media>
component on the server, it always matches.
This poses a problem for our rendering which is now on the server.
The solution used was to conditionally render on the browser only (example below).
{(process.browser) &&<Media query={ X }>...</Media>}
You could use the defer
tag or async
tab as both download the script in parallel to the HTML parser.
However async
will block HTML parsing to execute, whereas defer
wont.
For us it was a matter of updating:
<script key={ X } src={ X } />
to (adding the defer)
<script defer={ 'defer' } key={ X } src={ X } />
Now the browser will fully render the page before executing our javascript. This will produce a much faster “first meaningful paint”.
I used Google Chrome to manually profile the results, first I had to consider the metrics to use and the method of obtaining them.
I decided on using the metrics:
Then using Chromes Performance dev-tool tab and the industry standard Android hardware (as suggested by Addy Osmani):
I applied the following performance timing API calculations:
TTI = performance.timing.domInteractive - performance.timing.navigationStart
OnLoad = performance.timing.loadEventEnd - performance.timing.navigationStart
The results proved a 500% increase in TTI and 200% increase in OnLoad. For a large portion of users this would be noticeable.
The https://www.webpagetest.org/ site is fantastic for all performance testing. It supports FMP (first meaningful paint) and TTI (time to interaction). There is even basic auth support if you have areas secured which you would like to profile.
The optimal setup is to repeat each test 3 times. I ran the following test scenarios:
The results table is very easy to read (see below)
The results are kept indefinitely under unique ids, so its very easy to refer to them in the future to compare improvements.
Chrome dev-tools now offers its own tool to audit your website (under Audits tab on dev-tools). Chrome runs the exact same “average Android hardware” conditions used on my manual profile.
You are given an overall score out of 100 which encapsulates your applications current performance and the opportunities to improve it.
The most useful metric I found here was the FMP which is very accurate and comes with a nice timeline so you can see exactly how it has worked it out.
Very good for breaking down what the metric means visually (example below):
For us our FMP had improved by ~600% (or by a factor of 6) and our Chrome overall score doubled.
Needless to say it was a success (min ~200% improvement) and we plan to push the changes to production.
Next we are addressing our hefty bundle size by using code-splitting, tree-shaking and Service Workers for caching, but SSR was a good place to start.
I hope this article has been useful in regards to helping someone else look at implementing SSR in a redux-saga/react application.
If there is anything I have missed or you think I could have improved on please let me know, would really appreciate any comments/feedback ❤️