EXPEDIA GROUP TECHNOLOGY — SOFTWARE

12 Tips to Improve Client Side Page Performance

And how to ensure the improvements endure over time

Katie Garcia
Expedia Group Technology

--

Images by Pixabay on Pexels.com

Last year, I worked on an Expedia Group™ performance initiative tasked with improving client side performance across the Vrbo™ search results page (SERP) and property details page (PDP). Over the course of six months, the PDP Google Lighthouse score moved from #7 out of seven competitor sites to #1. We reduced the amount of content loaded on initial page load by 60%, from 4171 KB to 1653 KB, and made Vrbo and HomeAway the best in the industry. For comparison, the Airbnb property page downloads about 4932 KB or 3 times more content per page load.

Before and after metrics show improvement in Google Lighthouse score for Vrbo search and property page
Before and after metrics show improvement in Google Lighthouse score, time to interactive, and content size for Vrbo search and property pages.

This success was a multi-pronged effort across multiple teams and truly too many people to count. Some of these wins came from a simple version bump after another team refactored a shared component. Some required no version bump at all, like when a service just started returning responses faster.

But I don’t want to make page performance seem like an unruly beast that can only be tamed when armies of people come together under the mandate of an omniscient leader. I write this for every engineer, product manager, and technical manager with access to GitHub and Chrome dev tools. Rome was not built in a day, and the sum of our accomplishments did not result from any one magnificent piece of code. These practices range from minuscule to grudgingly manual. They range from “clean up your damn tests” to “sit with your product leadership and discuss the effect of every single network call on every piece of user experience during initial page load.” (Code? UX? Together?? Impossible! 🙀)

Before you embark on these improvements, you need to measure, capture, and understand your app’s performance at the starting gate and then again at milestones along the way. Ian White writes about several measurement techniques in Analyzing Client-side Performance of Web Applications. In addition to Chrome dev tools and Google lighthouse score, we use an internal tool to see changes to First Contentful Paint, Time to First Byte, First Input Delay, and Primary Action Rendered (PAR) in real time. The advent of this tool was game changing for us, which exemplifies the truism that you can’t improve what you can’t measure.

decorative separator

Level 1: Seasonal cleaning

1. Remove old code

It sounds simple. It seems obvious. And yet, it still falls through the cracks.

Across SERP and PDP, we cleaned upwards of 30 A/B tests that were long complete. For a typical test, we removed about 100 lines of code, but in one special case, that number was over 3,000.

2. Reduce DOM nodes

Google Lighthouse recommends a page contain fewer than 1,500 DOM elements. At the start of this initiative, PDP had more than 3,000. Today, according to Google Lighthouse, it has 950.

Google Lighthouse feedback “Avoid an excessive DOM size — 950 elements”
DOM nodes saw a drop from more than 3,000 to 950 elements on PDP at the time of page load.

DOM node reduction is an art and a science. Here are some techniques that can help:

  • Pay attention to elements that are repeated
  • Defer loading components that appear below the fold or behind a click
  • Replace DOM nodes with CSS pseudo elements using :before or :after instead
  • Avoid rendering device-specific content rather than hiding it with CSS
// good
{isMobile && <MyComponent/>}
// bad
<MyComponent className=”visible-xs” />
  • Replace inline SVGs with CSS background that renders the SVG as an image. Note that this limits your ability to customize size and color, so use this approach only where that tradeoff makes sense.
// inline svg
<svg focusable=”false” data-id=”SVG_CHECK__12" width=”12" height=”12" viewBox=”0 0 12 12" xmlns=”http://www.w3.org/2000/svg">
<path fill=”none” stroke-linecap=”round” stroke-linejoin=”round” d=”M2 7l3 3 6–8"/>
</svg>
// svg as css background image (es6)
`background-image: url(“data:image/svg+xml,${encodeURIComponent(mySvg);}”)`
// svg as css background image (css)
background-image: url(https://path/to/my.svg);

Case study

In a dramatic example of DOM node reduction, we noticed that the component that renders the star rating for reviews rendered each star as an SVG, composed of two DOM nodes for the greyscale version and two DOM nodes for the color version. Then, to apply classes, we wrapped the SVGs in two divs each. On a typical search results page with 50 results, the math looked like…

(4 svg elements x 5 stars + 4 wrapper divs) x 50 search results = 1200 DOM nodes

We refactored this component to use one SVG for all five grey stars and one SVG for all five color stars. Then, we applied the SVGs using a CSS background applied to :before and :after pseudo elements. As a result, a single instance of the star rating component dropped from 24 DOM nodes to one DOM node, and the total DOM nodes on SERP dropped from 1200 to 50. The resulting math looks like…

1 div x 50 search results = 50 DOM nodes
By reducing the number of DOM nodes for each star rating component, the search results page reduced the number of related DOM nodes from 1200 to 50.

3. Reduce React re-renders

You can try reducing re-renders with the following strategies:

  1. Use why-did-you-update in dev mode to identify the cause of unnecessary re-renders.
  2. Create a connected (aka container) component around components that share the same data; all child components should be stateless (aka dumb) components.
  3. Use Redux selectors strategically to limit component re-renders
  4. For class components, use shouldComponentUpdate to control re-renders by listening for changes on specific props
  5. Alternately, replace React.Component with React.PureComponent which automatically runs a shallow comparison between current props and next props and updates only when there are changes. (Note that shallow comparison can return a false positive, for example when comparing two objects.)
  6. Alternately again, replace class based components with functional components, which reduces overall code. Then, wrap them in React.memo, which adds shallow prop comparison in the same way that React.PureComponent adds prop comparison to React.Component.
  7. Pick the right state management strategy. There is no one-size-fits-all strategy for state management, but React apps should pick among: local state, immutable state using Flux/Redux, higher order components, React context, and React hooks to reduce unnecessary re-rendering and re-calculating of values.

Case study

One tricky area on PDP is the inquiry form because each keystroke represents a change to state. Through a series of changes, we learned it was more important to individually scrutinize each piece of state than to make sweeping changes and expect it to dramatically improve the experience. Originally, we stored the form state globally in our Redux store. Noticing that it caused unnecessary re-renders, we then moved it to component state and debounced the inputs. We then noticed that this introduced lag on Edge, and realized the component now re-rendered 334 times for each character typed. Eventually, we reverted back to using the Redux store but made two key updates to the original code, which reduced re-rendering significantly:

  • We made the telephone input field update only on blur
  • We added two new selectors to listen for prop changes and only update the component on changes we actually cared about.
decorative separator

Level 2: Next level renovations

4. Move scripts from <head> to the bottom of <body> and make sure scripts are non blocking

Look at your document <head>, and evaluate each script being called. Can it be moved to the bottom of the document? Is there an option to load it asynchronously?

5. Reduce image sizes

Where possible, replace images with SVGs or WebP. Where not possible, make sure to load images on demand and make sure to optimize their size and color settings.

Case study

PDP was using a 70 KB image sprite containing 170 country flags. Of these 170 flags, most users only ever see one. If they open the locale switcher, they might see an additional 18 or so flags representing the other supported locales for that site. To improve this experience, we replaced the image sprite with a collection of SVGs and applied each as a CSS background URL. There were several benefits:

  1. The SVGs scale better on retina screens
  2. Each SVG weighs less than 1 KB, compared to 70 KB for the image sprite
  3. We only need to load one SVG initially. We can load additional SVGs on demand as needed.
Left: Locale switcher showing grid of country flags and names | Right: Sprite of all country flags
On the left, the locale switcher users see after clicking on the flag in the header. On the right, the 70 KB image sprite that was used previously.

6. Choose your node modules wisely

We found multiple cases where we were using a highly rated, popular node module from npm, only to find a better, smaller, more performant alternative that did the job just as well. Here are some of our preferences. Your mileage may vary.

  1. We prefer date-fns over moment.js
  2. We prefer @u-wave/react-youtube over tjallingt’s react-youtube
  3. We built our own google map module instead of using Tom Chen’s react-google-maps

7. Look at lodash

In an unexpected turn of events, we replaced lodash.size with lodash and saved 200 KB in our unminified bundles. What? How?

The answer is a little more complicated. By inspecting our Webpack bundles, we noticed that we were importing both lodash and multiple lodash submodules, like lodash.get, even though the full version of lodash appeared nowhere in our package.json. Upon closer inspection, we discovered lodash was required as a peer or child dependency-of-a-dependency. Even though we thought we were being judicious by only directly importing lodash submodules, in fact we were bundling both the full version of lodash and the individual submodules. This created duplicate entries in package-lock.json and, by extension, in our Webpack bundles.

In response, we did two things:

  1. Replaced individual lodash.submodule definitions with full version of lodash in package.json. This was the most reliable way to prevent this type of duplication on an ongoing basis.
  2. Added aliases from lodash.submodule to lodash/submodule in our Webpack config, following guidelines from this article. These config changes handle the mapping of the peer and child dependencies-of-dependencies to the corresponding directory within the lodash parent module.

After making those two changes, multiple lodash and lodash.submodule entries still appear in our package-lock.json, but only one lodash package gets included in our Webpack bundles.

8. Review your production bundles

Check if you are including non-essential code in your production bundles. A common opportunity in React apps is to use babel-plugin-transform-react-remove-prop-types to remove propTypes definitions when you build for production. This shaved about 20 KB from our production bundles. While not monumental by itself, those small savings add up.

9. Audit when and how assets are fetched

The right time to make a network call depends on your unique situation. SERP and PDP each have different needs, so our optimizations varied for each. PDP calls many different services each for a discrete set of values: photos, price, location, recommendations, house rules, and so on. On the other hand, SERP consists almost entirely of the contents of a single call to search results service.

For each service call, we considered its needs and then chose to make that call on either the server or the client. In general, server side requests will be faster but the client must wait for all server side calls to complete before it can render the first pixel on the client.

  1. Server side
    Does this piece of content provide SEO value? Is it above the fold? Is this service response time shorter than the longest other call being made concurrently? If we answered yes to any of those questions, then we’d likely choose to render that asset on the server.
  2. Client side
    Is this content part of a test? Is it low on the page, relevant to only a segment of traffic, or otherwise slow and flaky? Can it fail gracefully? Does it affect page cacheability? If we answered yes to any of those questions, then we’d likely choose to call that service on the client.
  3. Prefetching with Redis
    As we considered the unique needs of the search results page, we had the idea of prefetching data and saving it to an in-memory cache until the moment at which a user needs it. With prefetching, SERP would kick off the request to search results service at the same time it requests the rest of the app shell. However, instead of waiting and returning both pieces of content at once, it immediately returns the app shell while continuing to wait on the search results to complete. As the search results are returned on the server, we stash them in an in-memory cache using Redis. Once the app shell finishes rendering and search results request has resolved, search hits are populated from the Redis store to the client. After a few false starts, prefetching on SERP proved to be one of the biggest successes of the season with a roughly 20% improvement to PAR.

10. Optimize Webpack bundles

In analyzing our network activity in the Chrome Performance tab, we noticed that our Webpack bundles were unevenly divided and created a period of almost 2000 ms during which only one or two assets were being transferred. Chrome opens six http connections in parallel, so by using only two slots, we left unused network capacity on the table. In earlier Webpack optimizations, we had perhaps over optimized by trying to create desktop, mobile, and secondary bundles without always paying attention to where there was overlap in the code. This resulted in a few very large bundles and a few much smaller ones.

Webpack 4 includes dynamic chunk splitting strategies out of the box. We decided to work with the built-in optimization.splitChunks settings to test out three scenarios:

  1. More smaller chunks
  2. Fewer larger chunks
  3. A middle number of medium sized chunks.

The middle-of-the-road approach outperformed in almost every metric we measured. By letting Webpack generally decide how to bundle our javascript, we get more even bundles that are better scoped to prevent the long pole problem. It also simplifies our code, makes our bundles easier to audit, and unlocks our ability to lazy load individual components.

Before and after waterfall diagram of network performance shows the second wave of downloads is able to start sooner.
On the left, our Webpack chunks varied wildly in size, meaning PAR couldn’t fire until the largest chunk finished downloading. After optimization, our medium sized chunks mean PAR can fire sooner.

11. Lazy load UI components

Once we had clean bundles, we were ready to surgically select branches of discrete code to be chunked and deferred until some later point. We used two strategies which I’ll illustrate with two types of components: footer and modal.

In the case of the footer, we created a Webpack chunk called SiteFooter which includes the React code required to render the footer. Because not all users scroll to the bottom of the page, we decided to only load the SiteFooter Webpack chunk when a user scrolls to some threshold x pixels above the top of the footer. To do that, we created a reusable component that uses a waypoint to trigger an asynchronous import of SiteFooter when a user scrolls to it. That looks something like…

<LazyLoadOnScroll
importComponent={SiteFooter}
importFunc={() => import(
/* webpackChunkName: “SiteFooter” */ ‘./SiteFooter
)}
threshold={800}
>
<SiteFooter />
</LazyLoadOnScroll>

Similarly, we wanted to defer importing our modal component, so we made another Webpack chunk called Modal. In this case, we used a click trigger instead of a scroll trigger. We considered calling import on click, but this introduced lag between the user clicking and the modal opening. Instead, we decided to import Modal in the background after its parent component mounted.

It’s worth noting that you can accomplish the same things with React lazy. At the time we were working on this, React lazy was brand new, but it applies the same principles and I expect it will become the standard going forward.

decorative separator

Level 3: Ongoing maintenance

12. Add performance monitoring on every build

There’s nothing more frustrating than deep cleaning your house only to turn around to find a sink full of dishes. While dishes are inevitable, an ever increasing bundle size is not. Automated checks and visible feedback can nudge us toward better behaviors.

PDP uses two methods: bundle size logging provides feedback during the development cycle and Google Lighthouse budgets create a hard cutoff if the bundle size creeps past a certain point.

  • Bundle size logging
    We added build time bundle size comparison as a step in our Jenkins build. This compares the bundle size of the branch build against the bundle size on master and reports it back to GitHub as a comment on the pull request. This creates an opportunity for the code author and reviewer to discuss any changes to the bundle size and verify the added size is intentional and worthwhile. On multiple occasions, this has allowed us to catch and resolve duplicate or unnecessary code before it was merged.
Comments in Github posted by Jenkins compare the bundle size of each PR build with the bundle size of the master build
Jenkins compares the before and after bundle size on each commit and reports the result to GitHub as a comment in the PR.
  • Google Lighthouse budget
    We added Google Lighthouse budgets as part of our Jenkins pipeline for the SERP and PDP apps. This check measures the transfer size of multiple asset types including images, CSS, third party and internal scripts. It then fails the build if any one resource type size exceeds the allocated budget.
Google Lighthouse output shows the bundle sizes for each different type of asset, including css, images, and javascript.
Google Lighthouse measures the bundle sizes for each different type of asset.
decorative separator

The results above show that the effort we put in paid off. By being specific about what we measure and how we measure it, we achieved our goals. I hope this post inspires you to apply these techniques to your pages. With a little effort, you can tackle low hanging fruit; with a little more effort, you can make a step change. And remember, any work you do will be long lasting if you add monitoring and logging to prevent regressions.

--

--