GraphCommerce has a strict focus on getting great scores on Google's Core Web Vitals. This document outlines the steps we take to ensure that our frontend is performant.
To get a good LCP and CLS there are a few things we need to do.
Rendering of the page is done in three main phases.
Rule of thumb: Move as much work up the phases as possible.
The HTML document containing all the HTML and CSS is downloaded and rendered on the frontend. Optimizing for this phase is usually where to start.
The goal is that the page should look great in this phase on mobile and desktop.
Of course not everything can be achieved with only HTML/CSS/Images. Customer specific information is not present in this phase, the server only sends session-less information to the browser. So any session specific information will be missing here.
Solution: Disable JavaScript in the Chrome inspector and reload the page.
Rule of thumb: Most of the attention should be put on getting as much of the page rendered as possible in this phase.
Make sure the images that are required for this phase have the loading=eager
property.
Hydration is the process is to "attach" React to the HTML that was rendered by the server. React will attach to the HTML that exists inside the DOM and take over managing the DOM inside it. Next.js automatically does this for the root component.
Hydration errors are problematic for performance as this will cause React to rerender the entire page. This means that after hydration is complete, React will start to rerender the entire page. This will thus double the amount of work React has to do.
See Pitfall in in this chapter: https://react.dev/reference/react-dom/client/hydrateRoot#hydrating-server-rendered-html
Solution: Keep an eye on the console for hydration errors and fix them.
Keep the hydration phase as short as possible. Web Vitals does not measure the execution time directly but there is a case where this happens:
When React starts hydration it will start to listen to all event listeners. So even before hydration is complete the clicks of the user will be registered and will be replayed when hydration is complete. This also means that the whole hydration phase will count against the INP metric.
The longer the hydration phase, the more often it will be measured as the INP metric. The more negatively it will affect the Core Web Vitals and user experience.
Solution: Use the React Profiler to see where work is being done and optimize that.
To load session specific data, Apollo Client is used. After the first render is complete, the Apollo Client data becomes available.
Most frequently used queries are automatically persisted (cached) in the local
storage of the browser (key: apollo-cache-persist
). By default everything is
persisted, except for pruned data (see
persistenceMapper).
Since this phase is late, do not use the result of non-session specific queries for the initial render. This also causes rerenders of the components causing all vitals to be affected.
Solution: Move data fetching to the getStaticProps
or getServerSideProps
functions. That can be a bit unergonomic, in that case move the data fetching
requirements to the GraphQL Mesh layer, by creating additional resolvers.
For example, it has been complex to get attribute option value labels like the
brand of a product without doing an additional customAttributeMetadataV2
query. Fetching this information separately requires precise coordination to
make it work even in getStaticProps.
To solve this the query fetching the attribute option values has been moved to
the GraphQL Mesh layer with the
ProductInterface.custom_attributeV2
and it's
resolver
and
mesh configuration plugin.
To keep the Hydration phase as short as possible, it's a good idea to postpone
the hydration of (heavy) components that are outside of the viewport until they
are needed. By using <LazyHydrate>
, the component can be hydrated either
manually, by setting the hydrated
prop to true or automatically by the
IntersectionObserver inside <LazyHydrate>
(as soon as the component comes into
view). It is recommended to set an estimated height value on the height
prop.
This will reserver space for the component, when the page is loaded clientside.
This is to prevent multiple LazyHydrate
components below each other from
intersecting all at once (as they do not have any HTML, and thus any height,
when loaded clientside). This height does not need to be exact.
The IntersectionObserver method of hydrating should never be used for components
that are inside the viewport on pageload, as it requires JS and is asynchronous.
When items are rendered in a loop and the first few items are inside the
viewport, these should be set to hydrated={true}
based on the index of the
loop.
Example 1:
import { LazyHydrate } from '@graphcommerce/next-ui'
function MyLayout() {
return (
<MyComponentAboveTheFold>
Immediately hydrated
</MyComponentAboveTheFold>
<LazyHydrate hydrated={true}>
<MyComponentAboveTheFold>
Immediately hydrated
</MyComponentAboveTheFold>
</<LazyHydrate>
<LazyHydrate height={500}>
<MyExpensiveComponentBelowTheFold>
Only hydrated when scrolled into view
</MyExpensiveComponentBelowTheFold>
</LazyHydrate>
)
}
Example 2:
export function MyItems(props) {
const { items, loadingEager = 2 } = props
return (
<>
{items.map((item, index) => (
// The first two items are hydrated immediately. Any items after that are hydrated on intersection
<LazyHydrate
key={item.id}
hydrated={index < loadingEager ? true : undefined}
height={500}
>
<MyComponent {...item} />
</LazyHydrate>
))}
</>
)
}
TLDR: Use the <MediaQuery>
component instead of useMediaQuery or CSS
breakpoints.
To render UI conditionally for various breakpoints it is practically always faster to render them conditionally with CSS than it is to conditionallty render components with JS. However rendering conditionally with CSS will cause increased JS executing time, Total Blocking Time and possibly INP issues.
useMediaQuery: When you are now using useMediaQuery to conditionally render content for mobile or desktop.
This means that hooks like useMediaQuery should almost never be used. See docs and examples.
Server-side rendering and client-side media queries are fundamentally at odds. Be aware of the tradeoff.
Also see https://mui.com/material-ui/react-use-media-query/#server-side-rendering
When you are using CSS to show or hide content based on media queries. Causes TBT problems as both code paths need to be rendered. Bad TBT can cause INP problems.
function RenderConditionallyForCertainBreakpoints() {
return (
<>
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
hide on screens wider than md
</Box>
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
hide on screens smaller than md
</Box>
</>
)
}
To solve both of the above problems we can use the <MediaQuery>
component. It
will conditionally render/hydrate the component based on the media query and not
execute the JS if the media query does not match.
Example:
import { MediaQuery } from '@graphcommerce/next-ui'
function MyLayout() {
return (
<MediaQuery query={(theme) => theme.breakpoints.up('md')}>
<MyExpensiveDesktopComponent>
Only visisble on desktop
</MyExpensiveDesktopComponent>
</MediaQuery>
)
}
Contexts + useState should be used cautiously. When the state updates, it causes ALL components within the context provider to rerender, not just the components that use the context. When wrapping a lot of components inside a context, this can become very expensive.
Solution for state that involves query data: Use Apollo Client cache, by using
useQuery
for data that is already fetched on the server and cached by Apollo.
Solution for local state: use Apollo Reactive variables
When using the Image component, the sizes prop should be set correctly so no unnecessarily large images are served. The prop accepts either a string (vw, px, calc(), etc) or an object with breakpoints and a string value. See the prop type for all available options.
Example:
<Image
layout='fill'
alt={item.label}
src={item.url}
sizes={{
0: '100vw',
[theme.breakpoints.values.md]: '50vw',
}}
/>
When querying inside getStaticProps
, only use await
when the data is
required for something else inside getStaticProps
. Otherwise it should be
awaited in the return statement. When a query is awaited immediately, it halts
the code at that point until that query completes. Meaning any queries called
after that will be called in serial. Awaiting all queries in the return
statement allows the queries to be called in parallel.
Example:
// Bad, because each query needs to wait until the previous one is completed entirely before being fired
export const getStaticProps: GetPageStaticProps = async (context) => {
// Query takes 100ms - code is halted until it finishes
const {data: queryOne} = await staticClient.query({ query: queryOneDocument })
// Query takes 100ms - code is halted until it finishes
const {data: queryTwo} = await staticClient.query({ query: queryTwoDocument })
// Query takes 100ms - code is halted until it finishes
const {data: queryThree} = await staticClient.query({ query: queryThreeDocument })
return {
props: {
...queryOne
...queryTwo
...queryThree
// Total wait time 300ms
},
}
}
// Good, because each query is fired in parallel and only awaited when each query is already running in the background
export const getStaticProps: GetPageStaticProps = async (context) => {
// Query takes 100ms - but it runs in the background, code continues execution
const queryOne = staticClient.query({ query: queryOneDocument })
// Query takes 100ms - but it runs in the background, code continues execution
const queryTwo = staticClient.query({ query: queryTwoDocument })
// Query takes 100ms - but it runs in the background, code continues execution
const queryThree = staticClient.query({ query: queryThreeDocument })
return {
props: {
// Awaits query (100ms)
...(await queryOne).data
// Awaits query, already finished in the background (0ms)
...(await queryTwo).data
// Awaits query, already finished in the background (0ms)
...(await queryThree).data
// Total wait time 100ms
},
}
}