TLDR: Turning Redux state into a JavaScript string you can parse with JSON.parse() instead of an object literal, or inert script tag, appears to be significantly faster than other approaches for sending Redux store state to the browser.

For my case making this one change shaved TTI (Time To Interactive) from an already pretty good 4.04s to 3.3s, a .74s or ~18% improvement. It increased the Lighthouse performance score an average of 8 points from 87.2 to 95.2!

These results were unexpected, when I shared them on Twitter both Addy Osmani and Mathias Bynens asked me write it up... so here we are.

Introduction

When doing Server-side Rendering (a.k.a SSR) it is common practice to render HTML and "rehydrate" it in the browser using Preact or React.

Often times, this also requires you transfer the current application state to the browser, somehow. That way, when your application spins up in the browser, it can rehydrate with all the state it had on the server.

Most of my experience with doing this as a consultant is with Redux. And, in my experience these Redux state dumps can easily get way too big... but I digress.

The best strategy is always to minimize their size to begin with. But, there are certain cases where that is difficult to do beyond a certain point, and plenty of other cases where things have already gotten out of hand and you're just trying to make thing better.

There are three predominant mechanisms (that I'm aware of) for doing this "state transfer."

They are:

Option #1: Turn your state in to a JS object literal

This means that on the server-side you do something like JSON.stringify(store.getState()) and you end up with HTML that includes a script tag like this.

<script> window.__REDUX__ = { some: "value", someOther: "value and so on" }; </script>

For this approach to work you also have to be careful about not including anything in your JSON that would make the browser think the script tag was terminating. So some extra escaping may be in order (learn more here). Additionally, using the result of JSON.stringify() directly in JS source code was a potential security issue until recently, since JSON strings could contain certain characters that were invalid in JS strings. More recently, this has been addressed with a spec-level proposal that made it into ES2019.

But, as you'll see you probably won't want to use this approach anyway since it's the slowest option.

When this executes in the browser what was JSON on the server-side is treated and parsed by the browser as a JS object literal. No different than if you have written code like this, without the quoted key names:

<script> window.__REDUX__ = { some: "value", someOther: "value and so on" }; </script>

But, it just has a few extra double quotes.

The important point in this case is that the data itself is parsed as if it were JavaScript.

Option #2: Turn your state into an "inert" script

Browsers only will parse the contents of a <script> tag if its type is "module", "text/javascript", or another recognized JS MIME type, or well... it doesn't have a type specified at all. So the point is anything else you specify as a type means its contents will be ignored.

People use this for all manner of trickery, but it can also be used to transfer Redux state with a bit less escaping, but again, you still need to escape for stuff like </script to protect against XSS issues.

Some people like to to give it a valid MIME type to make themselves feel better but... browser don't care.

For example:

<script id="myState" type="application/json"> {"your": "state", "goes": "here"} </script>

Then you can later parse it by grabbing the element out of the DOM and parsing its contents in some other script like so:

window.__REDUX__ = JSON.parse( querySelector("myState").innerHTML );

Unlike approach #1, in this case, the browser never interprets the data as JavaScript it merely parses a string as JSON, which is a much stricter data type, which as it turns out, is much faster.

However, this approach means that the browser first has to create a DOM element with a lot of text in it and then you have to read that text from the DOM. This, as it turns out, is not without performance overhead of its own (results below).

Option #3: Turn your state into JS String containing escaped JSON.

What you do end up with in your HTML is this:

<script> window.__REDUX__ = JSON.parse("{\"some\":\"state\",\"other\":\"value\"}"); </script>

As it turns out, this is way faster than the other options.

It may look a bit funny, but you still end up with a JS object. However, the key difference is that the contents of the data you're sending is never interpreted as if it were code. It's just a long JavaScript string literal, as far as the JavaScript parser is concerned. But, at runtime, it's parsed as JSON and becomes a JS object instance just like all the other approaches.

A quick note on how I generated the JSON string: It turns out, it's not so easy to do this correctly, especially if you're including data from an API that you may not have full control over. Avoiding XSS is important here.

Anyway, again thanks to Mathias, I ended up doing the following in node.js on the server-side to generate that string in a way that's safe to assume can be treated like a JavaScript string. I'm using his excellent: jsesc library as well as his advice to do roughly something like this:

const jsesc = require("jsesc"); // assume `data` here is the data we want to transfer module.exports = data => { const jsonString = jsesc(JSON.stringify(data), { json: true, isScriptContext: true }); return ` <!DOCTYPE html> <html> <head> ... stuff here </head> <body> <div id='js-app'>${allTheAppHTML}</div> <script>window.__REDUX_STATE__ = JSON.parse(${jsonString})</script> <script src='/my-app.js'></script> </body> </html> `; };

I'm a stooge, all the credit goes to other people here

The inimitable Addy Osmani wrote an awesome post for the V8 project's blog about The Cost of JavaScript in 2019.

And I happened to see, the equally inimitable, Mathias Bynens tweet where he screencapped part of it about the cost of parsing JSON and suggested the JSON string approach (and later helped review this post).

I'm currently working on a Preact PWA project for a big client where we're doing SSR with a redux state transfer of an object bigger than I'd like it to be, so... i figured what the heck, let's try it.

What I found blew me away.

The test setup

Browser: Chrome Stable 75.0.3770.100

Audit Mechanism: Lighthouse Perf Audit, incognito mode, Simulated Fast 3G, 4x CPU Slowdown setting, with no caching.

Number of tests: 5 runs of each approach

Size of Redux state in bytes: 163,980 with escape slashes: 176,017 added 7%

The size of the state object here is significant. It's much bigger than I'd like it to be but it comes from an API that I have little control over so anyway...

The results

Approach #1: JS Object Literal

Perf Score TTI 83 4.0s 87 4.1s 89 4.1s 88 4.1s 89 3.9s Avg: 87.2 4.04s

Approach #2: Inert Script Tag

Perf Score TTI 90 4.0s 90 4.0s 92 4.1s 92 4.0s 90 4.0s Avg: 90.8 4.02s

Approach #3: Escaped JSON String inside Script Tag

Perf Score TTI 95 3.3s 95 3.2s 96 3.3s 96 3.4s 94 3.3s Avg: 95.2 3.3s

Summary

Avg Perf Score Avg. TTI Approach #1: 87.2 4.04s Approach #2: 90.8 4.02s Approach #3: 95.2 3.3s

Conclusion: JSON String inside a real <script> tag wins by a shocking margin. An 18% TTI improvement on an already pretty fast app is incredible for such a simple change.

Your mileage may vary, but if you're doing a state transfer like this I'd encourage you to try it for yourself.

Please tell me what you learn, I'm @HenrikJoreteg on Twitter. Please, let me know what you find.

Some of my open questions that I don't really have time to dig into right this minute:

As Mathias pointed out to me in DM Chrome 76 includes even faster JSON.parse, how will this perform in Chrome 76+? Could this be even better?!?! Would .innerHTML versus .textContent make any difference in approach #2? How does this change impact performance in other browsers?

I'd love to hear your thoughts, but I wanted to share this while it was all still fresh in my mind. Hope this helps someone, thanks for reading!