Optimizing Core Web Vitals on a Next.js app

A case study optimizing for performance


Next.js by Vercel is a React meta-framework that enhances the React development experience. It enables the creation of production-ready apps and supports static site generation and server-side rendering, easy configuration, fast refresh, image optimization, file-system routing, and optimized code-splitting and bundling.

To evaluate how to optimize a React + Next.js application using third-party dependencies, we created the Next.js Movies app. This is a non-trivial movie browsing application and a fully-featured client of TMDB. It incorporates a rich set of features that allow you to search and browse through a comprehensive and categorized movie listing, view details and manage personal favorites through membership and authentication.

Subsequently, the Next.js Movies app was the benchmark that we used to implement a series of performance tweaks and identify the ones that were beneficial from an overall user experience perspective. Today, I want to talk about the performance improvement achieved on the whole and dig into each of the tweaks that we tried with their outcomes.

Cumulative Improvements #

We were able to achieve a significant aggregate performance improvement with all optimizations in place. This automatically translated to a better user experience. The metrics depicted here were captured before and after the implementation of all the code changes meant to optimize the performance.

To understand what the overall improvement implies from a user experience perspective, take a look at the following comparison for the actual page load experience

  • Before optimization
  • With a few changes and
  • With all optimizations in place.

In addition to the overall performance improvement, metrics were captured using Lighthouse and WPT after every code change for relevant pages. The tests were repeated multiple times to eliminate any lags due to sleeping servers or other conditions both before and after the change. The average calculated for each parameter thus gave us a reliable value to use for our analysis.

With that background, let's talk about every change implemented and how it contributed to the overall performance improvement we achieved.

Packages Switched #

Initially, a number of third-party React components helped to quickly implement the different features required for the Movies app. We decided to analyze the impact on metrics by trying other available alternatives for individual third-party components especially those that were heavy or blocking the main thread.

Most of these attempts were extremely fruitful in bringing down the values of different metrics

  • Using @svgr/webpack instead of Font-Awesome for SVG icons helped to boost Speed Index by 34%, LCP by 23%, and TBT by 51%
  • Using a custom-built component to replace react-burger-menu and removing the resize-observer-polyfill from react-sicky-box led to a reduction in bundle size by 34.28 kB (gZipped).
  • React Select Search was used instead of React Select which led to a 35% improvement in the LCP with a 100% improvement in CLS and 18% in TBT.
  • The use of React Glider instead of React Slick improved TBT by 77%.
  • Usage of React Scrolling instead of native smooth scrolling provided cross-browser compatibility for the scrolling feature.
  • React Stars component was used instead of React Rating which helped to boost TBT by 33%.

Let us find out what was changed and why.

SVG icon Library #

SVG icons were the obvious choice for all our icon needs across the Movies app. We initially chose Font-Awesome due to its popularity and ease of use as a scalable vector icon library with icons that are customizable using CSS.
However, there had been concerns that Font-Awesome may be slow to load on web pages due to the large transfer sizes when loading the library. This affects Lighthouse performance score.
We replaced Font-Awesome with @svgr/webpack as our SVG icon provider. Another change was to import individual icons on all our pages instead of the library itself even if the page uses multiple icons. For example:

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

// replaced by

import HeartIcon from 'public/assets/svgs/icons/heart.svg';
import PollIcon from 'public/assets/svgs/icons/poll.svg';
import CalendarIcon from 'public/assets/svgs/icons/calendar.svg';
import DotCircleIcon from 'public/assets/svgs/icons/dot-circle.svg';

This helped to improve the Lighthouse score across the board. Here is a snapshot of the score before and after the change. Also, note a difference of almost 200 KiB in request transfer size and the change in user timings before and after the change.

Before
After

Application Menu #

The initial version of the app used React-Burger-Menu as an off-canvas side-bar component to display the application menu by clicking the burger icon. The component comes with a collection of inbuilt CSS styles and animations that provide options to customize the menu.
An analysis of bundle sizes for React-Burger-Menu and the app revealed that we could do better.

We did not need all the features included in the react-burger-menu component and thought that a simple custom component would serve our needs just as well.
This helped to reduce the bundle size corresponding to the burger menu component considerably without affecting the required functionality. As seen in the treemap analysis of the chunks before and after the change, the gzipped size of the burger-menu chunk was 6.73 kB earlier but reduced to **879 B **after the change. The parsed size also went down from 32.74 kB to 2.14 kB. Thus, the change helped to reduce both the download time as well as the parse time for the chunk.

The Movies app allows you to sort movies belonging to a particular genre or starring a selected actor. You can sort by Popularity, Votes, Release Date, or Original Title. To allow users to select a sort option, we had previously used the react-select component. The component allows for multiple-select, search, animation, and access to styling API using emotion. The bundle size for the component is** 27.2 kB** minified and gzipped with 7 dependencies.

For the sort dropdown, we merely needed a simple single-select component without any styling features. As such, we decided to go with the react-select-search component. It is a lightweight component (**3.2 kB **minified and gzipped) with zero dependencies. While it supports multi-select and search features, styling features can be included by developers as required.
The following table highlights the changes in the UI itself due to the component change and corresponding improvement in Lighthouse performance.

BeforeAfter

We had used the React-Slick component on our movie pages that allowed users to horizontally "glide" through the movie cast. The react-slick component however is quite heavy when it comes to the bundle size. At 14.7 kB it comes with 5 dependencies.

We found a lighter option in react-glider which provided a similar carousel feature with a smaller bundle size and inline CSS.

A reduction in bundle size from 14.7 kB to 3.4 kB was quite a jump (78% improvement) with zero impact on functionality. This change was a welcome addition. In the future, we may rewrite this component to use CSS Scroll Snap.

The Scrolling Component #

The Movies App implements pagination on the movie listing pages to switch from one page to the other. Every time the previous or next page button is clicked, the view needs to scroll to the top of the new page. For the transition to be smooth, we had used the native smooth scroll function as follows.

window.scroll({
top: 0,
left: 0,
behavior: 'smooth'
});

// and

document.querySelector(`.${SCROLL_TO_ELEMENT}`)?.scrollIntoView({
behavior: 'smooth'
});

Native smooth scroll functions are however not supported across all browsers.

To allow us to animate the vertical scrolling, we decided to use a scrolling library called react-scroll (6.8 kB gzipped). This not only helped to recreate the same scroll effect with a small regression in performance as can be seen in the following comparison.

Scrolling behavior before the change

Scrolling behavior after the change

Performance MetricFCP (s)Speed Index (s)LCP (s)TTI (s)TBT (ms)CLSPerformance (%)
Before0.83.932.631.7316.66094.33
After0.923.782.92.2666092.8
% Change153.8110.2639.63296.150

The Rating Component #

React-rating, the rating component that we had originally used, allows you to customize the look by using different symbols for rating; eg., stars, circles, thumbs-up, etc. We had used the star symbol for rating earlier and did not need the other features that were part of the library. The cost of including the bundle for this component was **2.6 kB**.

The react-stars component served our purpose and we were able to show star ratings for movies on the movie listing screen using this component too. This component was only 2 kB minified and gzipped. We used this component and inlined the source for further optimization.

Although, the library sizes do not look very different, the react-rating component uses SVG icons for ratings while the react-stars component uses the symbol "★". As the component gets repeated 20 times on the movie listing page, the size of the icon/image also contributes to the overall savings due to the component change. This is apparent from the Lighthouse scores before and after the change.

`BeforeAfter

Although the other parameters are more or less unchanged, we noticed a significant difference in TBT (33%). This was because the chunk that included the rating component ("react-rating" package) was excluded from the long main-thread tasks.

Other techniques used for Optimization #

Experimenting with alternate libraries was one part of the performance analysis and optimization project. We also tried other mechanisms that have been known to enhance performance. Let's talk about what was attempted and what worked or didn't work for us.

Code-Splitting #

We used code-splitting to lazy-load the Menu component - being collapsed by default on mobile, this was an opportunity to only do work when a user actually needed it. We had initially tried lazy loading with the Burger Menu sidebar component and observed some gain in performance. After we replaced this with a custom component for the sidebar menu, we lazy-loaded the custom component.

We used the LazyLoadingErrorBoundary component which acts as a wrapper for react lazy() and react suspense(). This ensures that the menu component is loaded after page load. While FCP and LCP remained about the same, there was a substantial reduction in TBT by **71% **as can be seen in the following comparison.

Performance MetricFCP (s)Speed Index (s)LCP (s)TTI (s)TBT (ms)CLSPerformance (%)
Before0.864.23.462.5370087.66
After0.833.633.31.7320090.33
% Change3.4813.574.6231.6271.420

Inline the Critical, Defer the Non-Critical #

Our Lighthouse audits were consistently generating this suggestion that we certainly needed to act upon.

CSS is a render-blocking resource, i.e., it must be loaded and processed before the page is rendered. Some of the CSS may be required to style the content that is visible on the initial page load. This is the critical CSS that needs to be inlined to optimize the page. There may be other CSS that is not required initially and can be deferred

As part of our optimizations, we in-lined_ the_ CSS required for dark/light modes transition which was identified as critical CSS.

As per Next.js documentation, we had initially imported all our node module CSS files in the /pages/_app.js file. We are using two components react-glider and react-modal-video that require CSS import from node modules. Importing this CSS through _app.js would be render-blocking for the app as these components are not required on all the pages.

The CSS required by these components was inlined in the files where the component was used. For example, after optimization, the code in our cast component includes the syntax to render the Glider along with the styles that it uses.

<div ref={ref} className='cast'>
<Glider hasArrows slidesToShow={slidesToShow} slidesToScroll={1} itemWidth={GLIDER_ITEM_WIDTH}>
{cast.map(person => (
<PersonLink key={person.id} person={person} baseUrl={baseUrl} />
))}
</Glider>
</div>
<style jsx>{`
/*CSS Classes required for Glider*/
`}
</style>

With this change, we were able to observe a slight change of 2% to 5% in FCP, LCP, and TTI. The performance improved from 79% to 81% for the page.

Aspect Ratio for Images #

The changes we discussed so far helped us to improve the FCP, LCP, TBT, and TTI on different pages. Let us now talk about improving the last parameter on the Lighthouse report, the Cumulative Layout Shift (CLS). For an in-depth understanding of CLS and its causes, refer to my article on optimizing CLS. The Lighthouse report for the movies page before optimization gave us a clear indication of what was causing the CLS.

Even though a CLS of 0.016 is well below the threshold, we did experience the shift when loading the page, especially on a mobile 3G connection. So we worked on the elements that were causing the layout shift as reported.
Instead of setting image dimensions, we used the aspect-ratio-boxes technique for setting the aspect ratio for images. This helps to reserve the required space for the image while the page is still loading so that there is no shift once the image is loaded. Using this technique we were able to bring the CLS for the page down to 0, the image suggestions for layout shifts were eliminated and there was a perceptible improvement in user experience.

Note: Browser support for CSS aspect ratio improved after we worked on the Movies application, but if we were building it today we would likely use that feature.

Preconnects #

Preconnects allow you to provide hints to the browser on what resources are going to be required by the page shortly. Adding "rel=preconnect" informs the browser that the page will need to establish a connection to another domain so that it can start the process sooner. The hints help to load resources quicker because the browser may have already set up the connection by the time the resources are required.

We added the following preconnects to our code as hints.

  <link rel='preconnect' href='https://image.tmdb.org' /> 
<link rel='preconnect' href='https://api.themoviedb.org' />
<link rel='preconnect' href='https://www.google-analytics.com' />
<link rel='preconnect' href='https://content-autofill.googleapis.com' />

There was a small but discernible difference in the values of performance parameters after this change as tabulated here.

Performance MetricFCP (s)Speed Index (s)LCP (s)TTI (s)TBT (ms)CLSPerformance (%)
Before0.93.93.432.9360088
After0.833.52.862.6353.33093.33
% Change7.7710.2516.6110.236.670

Optimize the API call sequence #

Being a TMDB client, the movies app makes several API calls to get the list of movies, genres, cast, and other details along with related images. The principle used to optimize the API call sequence should ensure that calls to fetch data to be used for rendering the main page area are not put off until the other API calls have finished. With this in mind, we changed our sequence of execution as follows.

BeforeAfter
Fetch the metadata like genres and configuration while the API call for movie posters was put off until they were finished.Fetch the metadata (used for populating the side menu) and simultaneously fetch the movie poster data.
Fetch the movie poster dataRender the home page with the fetched movie poster data.
Render the home page with the fetched movie poster data.

Preloading API response #

When a user visits the home page of the Movies app for the first time, we already know that we will be showing them page 1 of the ‘Popular' movies list. The actual list itself comes from the TMDB API, but the API call can be created based on these two values
Genre = "popular" and page = 1
With this knowledge, we were able to preload the data for the home page as follows.

<link rel='preload' as='fetch' href='https://api.themoviedb.org/3/movie/popular?api_key=844dba0bfd8f3a4f3799f6130ef9e335&page=1' crossOrigin='true' />

This was used only on the home page as we cannot predict what the users will click/pick on the other pages. If the preloaded data is not used, it will be a waste of resources resulting in a warning like this which can be seen in Chrome Dev Tools - "The resource https://api.themoviedb.org/3/movie/popular?api_key=844dba0bfd8f3a4f3799f6130ef9e335&page=1 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate as value and it is preloaded intentionally."
The LCP and TTI improved by 12.65% and 7.76% respectively after this change while the overall performance went up from 91% to 94% for the home page.

Preloading the logo and the TMDB trademark #

The logo and TMDB trademark are displayed on all pages and we found the performance after preloading these to be improved. These were preloaded using a media query.

<link rel='preload' href={LOGO_IMAGE_PATH} as='image' media='(min-width: 80em)' />
<link rel='preload' href={DARK_TMDB_IMAGE_PATH} as='image' media='(prefers-color-scheme: dark) and (min-width: 80em)' />
<link rel='preload' href={LIGHT_TMDB_IMAGE_PATH} as='image' media='(prefers-color-scheme: light) and (min-width: 80em)' />

This resulted in a 5-6% improvement in FCP and Speed Index.

Making the site Responsive #

. The movies app uses Next.js SSR to render the wrapper for the UI. Since the app can be accessed on both desktop and mobile devices, responsive design was essential. Combining responsive design with SSR has been a challenge because

  1. The server where the content is rendered does not recognize the client window element. Thus methods like window.matchmedia() cannot be used to determine client details. Additionally, client hints are not supported across all browsers.
  2. Using CSS media query would result in rendering all of the elements regardless of whether they are used either on desktop or mobile.

To address these challenges we used the @artsy/fresnel library. The approach used here is that the server would still render all elements in the DOM with CSS breakpoints. Only components that match the breakpoints would be mounted. We were thus able to avoid duplicate markup and unnecessary rendering
The following images compare the difference in markup rendered before and after the change for the same content.

BeforeAfter

Following is the change in Lighthouse performance observed after the change.

Performance ParameterFCP (s)Speed Index (s)LCP (s)TTI (s)TBT (ms)CLSPerformance (%)
Before0.933.732.62.63600.00194.33
After1.063.232.662.6663.33095
% Change13.9713.42.31.145.55100

While there is some regression in FCP, LCP, TTI, and TBT, the speed index and performance have improved. The chunk size has increased due to the contribution of the artsy/fresnel bundle. However, the reduction in markup may make this a good trade-off.

Enable Google Analytics #

Google analytics was included on the site so that we can get a better picture of how the app engages with its users. Some regression was expected after including Google Analytics. The change in performance was captured as per our process to track performance variations for the code changes. There was some regression as expected due to the inclusion of the analytics component,

Performance ParameterFCP (s)Speed Index (s)LCP (s)TTI (s)TBT (ms)CLSPerformance (%)
Before0.83.42.531.826.66095.66
After0.953.72.932.1335092.75
% Change18.758.8215.6118.0531.280

Ideas that did not Help #

Based on the Lighthouse report's feedback, there were some alternatives and ideas that we tried but gave up because there were no performance benefits. Here is a summary

  1. We are using the react-lazyload package for lazy loading images. This was listed in the long main thread tasks, along with the scrolling and rating components.

We tried replacing this with native image lazy-loading. Based on subsequent testing, we noticed that TBT increased from 10 ms to 117 ms for a negligible reduction in LCP. It is possible that native image lazy loading loads a few images that are near the viewport while react lazy-load only loads those that are within the viewport causing this difference in TBT.

Today, one could also use the Next.js Image Component to implement this functionality.

  1. Before setting the aspect ratio for images, we had tried to improve CLS by setting image dimensions. Even though it is one of the recommended approaches for reducing CLS, setting image dimensions did not work so well as the aspect ratio technique that we finally implemented.

  2. Tried out server-side rendering to reduce LCP but it brought about regression rather than improvement. This could be because the movie-related data and images required to render pages were fetched through TMDB API calls. This caused the server response to be slow because all API requests/responses were processed on the server.

Ideas that might help #

There are a few additional opportunities for performance improvement that we might try out in the future. These range from replacing individual components with lighter alternatives to implementing full-fledged SSR. Here's what we could explore to check if it contributes to the performance of the app.

  1. Implement responsive images with preloading as discussed here
  2. Introduce caching using service-workers.
  3. Currently, the _app.js file is slightly bloated as it includes redux-related logic eg., actions, reducers, etc. Individual pages do not need all of these files when landing. We could try eliminating redux or apply code-splitting for redux logic.
  4. Implement SSR without redux and try SSR caching.
  5. Replace react-modal-video with a lightweight alternative.
  6. Use keen-slider instead of react-slider.
  7. Use react-cool-inview instead of react-lazyload.
  8. Apply lazy-loading/code-splitting techniques to load third party libraries using different React loading patterns
  9. Image post-processing to preload the first few images like the hero-image.
  10. Replace the SVG loading spinner with something that uses CSS animation.
  11. Use lighter components that use HTML and CSS for rendering images instead of component that uses JavaScript internally.

Conclusion #

Performance optimization is an ongoing process. Over the last 6 months, we covered a lot of ground with these changes to not only incorporate but also test many recommended best practices. We could always do more. However, at some point, you have to decide whether the gain in performance is justified by time spent on testing different alternatives. The loop will of course be repeated as and when new features are added. We however wanted to capture our takeaways from this journey so that they serve as a manual for our future endeavors as well as yours.

With special thanks to Anton Karlovskiy and Leena Sohoni-Kasture for their contributions to this article.