Methodology

All profiling done uses the same settings:

“Fast 3G” throttled network speed.

Desktop resolution.

Disabled HTTP cache.

Logged-in, to an account with 16 tracked TV shows.

Baseline

We need a baseline that we can compare results against!

The page we’ll be testing is the main logged-in Summary view. it’s the most data-heavy page, and so it offers the most room for optimization.

The Summary view contains a set of cards, like this:

Each show gets its own card, and each episode gets its own square. Blue squares = seen episodes.

Here’s our baseline profile, on 3G speed. It’s… not great.

Yikes!

First Meaningful Paint: ~5000ms

First image loaded: ~6500ms

All requests finished: >15,000ms

Oof. The page doesn’t display anything useful until ~5 seconds in. The first image only finishes loading around 6.5 seconds, and it takes well over 15 seconds for all network requests to complete.

This timeline view offers a bunch of insights. Let’s walk through it and examine what it’s doing:

The initial HTML page is fetched. This is nice and quick, because the app is not server-rendered. The monolithic JS bundle has to be downloaded. This takes forever. 🚩 React traverses the component tree, computes the initial mount state, and pushes it to the DOM. It’s a header, a footer, and a lot of black space. 🚩 After mount, the application realizes that it needs some data, so it makes a GET request to /me, an API endpoint that returns the user’s data, as well as an array of shows they care about, and which episodes they’ve seen. Once we have that crucial list of shows, the application can fetch:

- an image for each show,

- an array of episodes for each show*.



This data comes from the wonderful TV Maze API.

* You may be wondering why I don’t just store episode info in my database, so I can skip all those calls to TV Maze. Ultimately, TV Maze is the source of truth; it has all the info about new episodes. I could make these requests on the server during Step 4, but that would greatly increase the amount of time that step takes, while a user stares into a sea of empty black space. Plus, I like having a lean server layer.



One potential workaround could be to set up a cron job that does daily syncs with TV Maze, and only request episodes directly if I don’t already have recent data. I kinda like that the data is realtime, though… this avenue will be left unexplored, at least for now.

The Obvious Improvement

The biggest bottleneck right now is that initial JS bundle’s size; it takes too long to download!

The bundle size is 526kb, and it’s not currently being compressed at all. Gzipping to the rescue!

With a Node/Express backend, this is easy; we just need to install the compression module, and use it as an Express middleware.

With that incredibly simple fix, let’s see what effect it has on our timeline:

First meaningful paint: 5000ms -> 3100ms

First image loaded: 6500ms -> 4600ms

All data loaded: 6500ms -> 4750ms

All images loaded: ~15,000ms -> ~13,000ms

The bundle went from 526kb over-the-wire to just 156kb, and it make a huge difference on page-load speed.

Caching with LocalStorage

With the obvious first step taken, I looked at the timeline. The first paint is at 2400ms, but it isn’t meaningful. It gets better at 3100ms, but all that episode data isn’t received until almost 5000ms.

I started thinking about server-rendering, but that wouldn’t actually fix the problem; the server would still have to make a call to the DB, and then a call to the TV Maze API. Worse, the user would be staring at a white screen while the server did its work.

Why not use local-storage? We can persist all state changes to the browser, and rehydrate from that state when the user returns. The data will be stale, but that’s OK! The real data won’t be far behind, and this will make the initial load feel so much faster.

Because this app uses Redux, persisting/hydrating state is pretty straightforward. First, I needed a way to update localStorage whenever the Redux state changed:

Next, we need to subscribe our Redux store to this method, as well as initialize it with any data from previous sessions:

There were a few kinks to work out, but for the most part, this was a really simple change, thanks to how Redux is architected.

Let’s take a gander at the new timeline:

Cool! It’s hard to tell from how small the captured screenshots are, but our very first paint is meaningful now; it contains a full list of the shows and episodes from our previous session: at 2600ms

First meaningful paint: 3100ms -> 2600ms

Episode data available: 4750ms -> 2600ms (!)

While this hasn’t actually affected the loading time (we still do make those API requests and they still take a while), the user has data immediately, and so the perceived speed improvement is very noticeable.

Gone is the staggered, things-keep-changing second where content appears as it’s available. While this is often a popular technique for getting stuff on the page sooner, it can be overwhelming when the page keeps updating as new content is available. I much prefer being able to render the “final” UI immediately.

As an extra bonus, this winds up being pretty useful in non-perf ways too. For example, users have the ability to change the sorting of shows, but before this change, that preference would be forgotten when the session ends. Now, that preference is restored when they come back!

There is a downside to this, though: it’s no longer clear whether you’re still waiting for new data or not. I plan to add a spinner in the corner that shows whether additional requests are still being waited on or not. Also, you may be thinking “This is great for returning users, but does nothing for new users!”. You’re right, but actually, this isn’t applicable for new users. New users have no tracked shows, and so their page load is super quick; just a call-to-action to start adding shows. So we’ve effectively killed the experience of “staring at a black screen forever” for all users, new and returning.

Images and Lazy Loading

Even with this latest improvement, images are still taking forever to load; this timeline doesn’t show it, but it still takes 12+ seconds for all images to be loaded, with 3G speeds.

The reason for this is simple: TV Maze returns large movie-poster-style photos, whereas I only need a narrow strip, used to help tell shows apart at-a-glance.

Left: what gets downloaded ················ Right: what gets used

To solve this problem, my initial thought was to use something like ImageMagick, a wonderful CLI tool I used while making ColourMatch.

When the user adds a new show, the server would request a copy of the image, use ImageMagick to crop out the middle of the image, send it over to S3, and use the S3 URL on the client, rather than using the TV Maze image link.

Rather than deal with this myself, though, I decided to outsource this concern to Imgix. Imgix is a service that sits in front of S3 (or other cloud storage providers) and allows you to dynamically create cropped, resized images. You just use a URL like this, and it creates and serves an appropriate image:

https://tello.imgix.net/some_file?w=395&h=96&crop=faces

A nice bonus is being able to crop based on interesting areas of the photos. You’ll notice in the left/right comparison photo above that it crops the 4 kids on the bikes, instead of just cropping the exact center of the image.

For Imgix to work, the image has to be available via S3 or similar. Here’s a snippet from my back-end code, which uploads an image when a new show is added:

By running every new show through this promise, we get images that are ready to be dynamically cropped.

On the client, I use image properties srcset and sizes make sure that images are being served based on the window size and display pixel ratio:

This helps ensure that mobile clients get the larger version of the image (since those cards wind up taking up the whole viewport’s width), whereas desktop clients get a slightly smaller version.

Lazy Loading

Each image is now way smaller, but we’re still loading an entire page worth of shows at once! On my large desktop window, only 6 shows are visible at once, but we fetch all 16 images at once, on page-load.

Happily, the awesome package react-lazyload offers really simple lazy loading. The code is as simple as:

Alright, it’s been a while since we looked at a timeline.

Our first-meaningful-paint numbers haven’t changed, but image download times are way better:

First image: 4600ms -> 3900ms

All visible images: ~9000ms -> 4100ms

Eagle-eyed readers might have noticed that this timeline only downloads episode data for 6 episodes, instead of all 16. This is because my initial attempt (and the only one I remembered to capture) lazy-loaded the episode card, not just the show’s image. Ultimately this introduced more problems than I was able to solve in this weekend-long-perf-tune, and so I simplified it. The impact on above-the-fold image load-times is unchanged, though.

Codesplitting

We’re definitely getting to a pretty good place, perf-wise.

One obvious issue is that we only have a single bundle. Let’s use codesplitting to reduce the amount of on-request code needed!

Because I’m using React Router 4, it’s a simple matter of following the docs to create a <Bundle /> component. I played around with a few different configurations, but ultimately, there wasn’t a lot of splitting that made sense.

In the end, I split out the mobile views from the desktop ones. The mobile version has its own views, which use a swiping library, custom assets, and a few extra components. This bundle wound up being surprisingly small — about 30kb before compression — but it nevertheless had a noticeable impact:

First meaningful paint: 2600ms -> 2300ms

First image loaded: 3900ms -> 3700ms