React initial rendering performance
Luca Colonnello
Full Stack Engineer / Tech lead
@LucaColonnello
🇮🇹🇬🇧
Raise awareness about the
impact of modern front-end development techniques on speed and UX
Give some tips on how to analyse and solve some of these perf issues, helping you improve the quality of what you're building
Sit back and relax
we're about to embark on a journey...
@LucaColonnello
Close your eyes
You're now a Performance Engineer
working for a company called justPay!
You are assigned to a new project...
Sit back and relax
we're about to embark on a journey...
@LucaColonnello
Close your eyes
You're now a Performance Engineer
working for a company called justPay!
You are assigned to a new project...
justMakeItFaster!
The CEO wants to open the business to new markets, so we need to make our app faster in low end devices.
justPay!
Home page
@LucaColonnello
@LucaColonnello
Improve low end devices rendering and interactivity time
The goal
@LucaColonnello
In March 2019
Of this 76.5%, 20.8% were high end devices, up from 15% in March 2018
76.5% of active smartphones worldwide were Android
🇺🇸 48.3%
🇮🇳 95.9%
@LucaColonnello
a small framework to handle performance
@LucaColonnello
@LucaColonnello
@LucaColonnello
@LucaColonnello
What happens when I hit https://www.justpay.com/ for the first time?
@LucaColonnello
@LucaColonnello
FONT
IMG
JS
CSS
HTML
Layout
Paint
JS Evaluation
Render of content 👇
@LucaColonnello
@LucaColonnello
@LucaColonnello
JS runs in tasks
Task
call stack
document.body.appendChild(...)
Main thread blocking
@LucaColonnello
Layout and Paint don't run while JS is running
(simplified, there are edge cases for layout)
Task
Every task in the browser blocks others until is finished, the main thread is like a scheduled queue
@LucaColonnello
An example of this: rendering many charts
showLoadingSpinner();
const data = calculateDataForAllCharts();
renderAllCharts(data);
// 👆above 2 lines take 1 second
hideLoadingSpinner();
Task
Expectation vs Reality
🕓
📊
📊
@LucaColonnello
Although your JS might try to render using DOM APIs, Paint happens after the current JS task is finished
Therefore, the performance of your React app affects WHEN the user is going to see something in screen.
@LucaColonnello
This is a user waiting for your JS...
@LucaColonnello
@LucaColonnello
Server Side Rendering
@LucaColonnello
FONT
IMG
JS
CSS
HTML
Layout
Paint
JS Evaluation
Render of content 👇
@LucaColonnello
Improve
@LucaColonnello
Improve
Our users can now see something in the screen way earlier and benefit from the content.
The page feels faster as it almost instantly renders straight after downloading the HTML.
@LucaColonnello
@LucaColonnello
How does our JS affect users trying to interact with the elements on the page after rendering?
@LucaColonnello
@LucaColonnello
Header
CategoriesMenu
UserQuickLinks
TopDeals
NavModal
@LucaColonnello
BasketSummary
BasketModal
@LucaColonnello
SearchBar
SearchResults
SearchModal
@LucaColonnello
TopGadgets
TopGadgetPopup
@LucaColonnello
TwitterReviews
PeopleFavouritesList
@LucaColonnello
TopCategoryList
@LucaColonnello
Footer
@LucaColonnello
FONT
IMG
JS
CSS
HTML
Layout
Paint
JS Evaluation
Hydrating...
Render of content 👇
👆
👇
Interaction
👇
Handler
😡 Delay
@LucaColonnello
@LucaColonnello
> 300ms delay is perceived by users as slow, janky
Task
High Input Delay
👇 user interaction
> 300ms
👇 handler
@LucaColonnello
@LucaColonnello
Our hydration is made of 2 main elements
Task
webpack_require
ReactDOM.hydrate
@LucaColonnello
webpack_require
import React from 'react';
import { hydrate } from 'react-dom';
import App from './App';
hydrate(<App />, document.getElementById('#main'));ReactDOM.hydrate
webpck_require is used to run every module (and its modules) statically imported in the bundle.
This operation is blocking.
When running ReactDOM.hydrate, React needs to run every component our App is mounting.
This operation is blocking.
@LucaColonnello
The amount of modules we import in our bundle has a cost in evaluation, not just in size.
Hydrating our React App has a linear cost, the more components we have, the more it will cost.
By improving the Hydration time, we are freeing the main thread earlier, meaning our users can interact with the element in our page earlier.
@LucaColonnello
@LucaColonnello
Removing unnecessary work at first render
@LucaColonnello
App
Footer
TwitterReviews
TopDeals
TopDealsItem
TopCategoryList
PeopleFavouritesList
TopGadgets
Header
SearchModal
BasketModal
NavModal
CategoriesMenu
UserQuickLinks
Good
Too High
Self render time (not including children)
@LucaColonnello
Only 1 component seem to be heavy.
The rest is quite fast individually.
It's the amount of components which makes it slower.
When hydrating or rendering for the first time, React has to go through all your components.
@LucaColonnello
@LucaColonnello
Task
Task
Task
Task
Task
< 300ms
user interaction 👇
Hydrated rest subsequently, when visible
Hydrate most important things first
👇 event handler
by breaking it down into smaller tasks
we release the CPU earlier, hence less input delay
@LucaColonnello
Hydrate as you go, when needed
When elements are in viewport
Hydrate what's needed, do not hydrate static content
@LucaColonnello
@LucaColonnello
const App = () => (
<Header />
<TopDeals />
<TopGadgets />
<TwitterReviews />
<PeopleFavouritesList />
<TopCategoryList />
<Footer />
);const App = () => (
<Header />
<TopDeals />
<LazyHydrate whenVisible>
<TopGadgets />
</LazyHydrate>
<LazyHydrate ssrOnly>
<TwitterReviews />
<PeopleFavouritesList />
<TopCategoryList />
</LazyHydrate>
<LazyHydrate whenVisible>
<Footer />
</LazyHydrate>
);From
To
@LucaColonnello
App
TopDeals
TopDealsItem
Header
SearchModal
BasketModal
NavModal
CategoriesMenu
UserQuickLinks
Good
Too High
Self render time (not including children)
@LucaColonnello
~40% less spent on hydration
@LucaColonnello
@LucaColonnello
NavModal
CategoriesMenu
Time taken to render just the component,
not including its children
@LucaColonnello
const CategoriesMenu = ({ allMenuItems, categories }) => {
// expensive denormalisation of data
const categoriesMenuData = buildCategoriesMenuData(
allMenuItems,
categories
);
return (<AccordionMenu data={categoriesMenuData} />);
};// creating category -> menu items out of a flat list
/*
{
category: String,
links: [
{
label: String,
href: String,
}
]
}
*/
const buildCategoriesMenuData = (allMenuItems, categories) =>
categories.map(
category => ({
category: category.name,
links: allMenuItems
.filter(menuItem => menuItem.category === category.id)
.map(({ label, href }) => ({ label, href }))
})
);
⌄ allMenuItems[{...}]
⌄ 0:
category (Int)
label (String)
href (String)
⌄ categories[{...}]
⌄ 0:
id (Int)
name (String)
@LucaColonnello
const CategoriesMenu = ({ categoriesMenuData }) => (
<AccordionMenu data={categoriesMenuData} />
);Hoist to the server, you could have a presentational API (i.e. GraphQL)
@LucaColonnello
Calculate during SSR, skip during hydration
const CategoriesMenu = ({ allMenuItems, categories }) => {
const categoriesMenuData = useSSRMemo(
'categoriesMenuData',
() => buildCategoriesMenuData(
allMenuItems,
categories,
),
[allMenuItems, categories]
);
return (<AccordionMenu data={categoriesMenuData} />);
};useSSRMemo could use Context to collect the values in the server.
The server could then print the JSON data to be used by the client to skip the calculation.
@LucaColonnello
App
TopDeals
TopDealsItem
Header
SearchModal
BasketModal
NavModal
CategoriesMenu
UserQuickLinks
Good
Too High
Self render time (not including children)
@LucaColonnello
another ~10% less spent on hydration
@LucaColonnello
Improve
@LucaColonnello
@LucaColonnello
Use SSR to deliver content to users earlier
Don't hydrate more than necessary
Hoist as much as possible to the server
@LucaColonnello
React will possibly make some of this easier with Progressive and Selective Hydration
You can only optimise so much, so give your app a budget for hydration and have discussions with product about the impact of new features
@LucaColonnello