Photo by Mathew Schwartz on Unsplash
This is the story of our homepage make over to reach a solid lighthouse score. If you want to read how we made the homepage in the first place then have a look at our blog post from 2019.
Many of the things I write about here can be seen in the Fast Pages with React article.
The Origins
Our homepage started off as a React SPA. Why? It was 2019 and the UI designer created the components all in React. Plus, it was a super smooth experience that was actually quite acceptable.
However, while some may argue that a SPA is not good for SEO this was not the reason why we wanted to have a pre-rendered page. SEO seemed actually fine, but some more simple crawlers did not find all the information as it was hidden behind some JavaScript.
So what can we do here? Well, we can just render everything at build-time using an static-site generation (SSG) approach.
Going into SSG
We did not want to change the overall content or engine behind the page. After all, the DX was great and we thought that reusability of the components is more important than migrating to the latest SSG framework.
After the build (back then using the Parcel v1 bundler) was done, we had a post-build process that took the generated index.html and went through all the detected pages. As our routing is declarative (we already generated the routes from the file system paths) finding the pages was easy.
For each page we ran simple script that does the following things:
- Teach Node.js how ESM works (
esm
module) - Allow using TypeScript via
ts-node
(our source is using TypeScript) - Add some globals like
document
orlocalStorage
- Register some additional extensions, e.g., for resolving images as modules in Node.js (these images should resolve to the already generated images from the bundler)
- Evaluate the page - using
renderToString
- Replace the app's content container with the pre-rendered page
- Save the HTML of the modified app in a new file that matches the path of the page
In code this worked as follows:
const { readFileSync, writeFileSync, mkdirSync } = require('fs');
const { basename, dirname, resolve } = require('path');
require = require('esm')(module);
require('ts-node').register({
compilerOptions: {
module: 'commonjs',
target: 'es6',
jsx: 'react',
importHelpers: true,
moduleResolution: 'node',
},
transpileOnly: true,
});
global.XMLHttpRequest = class {};
global.XDomainRequest = class {};
global.localStorage = {
getItem() {
return undefined;
},
setItem() {},
};
global.document = {
title: 'sample',
querySelector() {
return {
getAttribute() {
return '';
},
};
},
};
const React = require('react');
const { MemoryRouter } = require('react-router');
const { renderToString } = require('react-dom/server');
React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};
function setupExtensions(files) {
['.png', '.svg', '.jpg', '.jpeg', '.mp4', '.mp3', '.woff', '.tiff', '.tif', '.xml'].forEach(extension => {
require.extensions[extension] = (module, file) => {
const parts = basename(file).split('.');
const ext = parts.pop();
const front = parts.join('.');
const ref = files.filter(m => m.startsWith(front) && m.endsWith(ext)).pop() || '';
module.exports = '/' + ref;
};
});
require.extensions['.codegen'] = (module, file) => {
const content = readFileSync(file, 'utf8');
module._compile(content, file);
const code = module.exports();
module._compile(code, file);
};
}
function renderApp(source, target, dist) {
const sourceModule = require(source);
const route = (sourceModule.meta.route || target).substring(1);
const Page = sourceModule.default;
const Layout = require('../../scripts/layout').default;
const element = React.createElement(
MemoryRouter,
undefined,
React.createElement(Layout, undefined, React.createElement(Page)),
);
return {
content: renderToString(element),
outPath: resolve(dist, route, 'index.html'),
};
}
function makePage(outPath, html, content) {
const outDir = dirname(outPath);
const file = html.replace(/<div id="app">(.*)<\/div>/, `<div id="app">${content}</div>`);
mkdirSync(outDir, {
recursive: true,
});
writeFileSync(outPath, file, 'utf8');
}
process.on('message', msg => {
const { source, target, files, html, dist } = msg;
setupExtensions(files);
setTimeout(() => {
const { content, outPath } = renderApp(source, target, dist);
makePage(outPath, html, content);
process.send({
content,
});
}, 100);
});
The whole process
is used as the given module is called from a forked process. So each page is generated in an isolated process.
The following diagram illustrates this process.
What did we win so far? We have a pre-rendered page that works already and transforms to a SPA. Nice. But not enough for us.
Modernizing the Stack
I already described the process as "was". Parcel v1 has not been updated in quite a while and is - from today's point of view - quite slow. It also does not support some modern concepts and should be retired for good.
As a replacement we've chosen Vite. This is a suitable replacement as it comes with a super fast development server and a very well optimized release build.
Also, since we are using codegen for the route retrieval we can still keep using it for Vite.
After all, the whole configuration for Vite to allow the transition is shown below:
import codegen from 'vite-plugin-codegen';
import { resolve } from 'path';
export default {
build: {
assetsInlineLimit: 0,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
plugins: [codegen()],
};
To avoid Vite's original behavior of inlining smaller assets (which would lead to problems in our SSG behavior) we set the assetsInlineLimit
to 0.
One thing we also improved when making the transition from Parcel v1 to Vite is that we introduced path aliases. With the configuration above we can write import '@/foo/bar'
instead of going explicitly via the relative path to src/foo/bar
. This makes modules more flexible and easier to maintain.
Previously, we used a bit of tools (esm
, ts-node
, ...) to actually make the SSG happen. With the new setup it was time to reduce the amount of tools and replace them all with esbuild
.
The updated code of the SSG kernel module therefore changed a bit, too:
const { writeFile, readFile, mkdir } = require('fs/promises');
const { dirname, resolve, basename, relative } = require('path');
const { createContext, runInContext } = require('vm');
const { compile } = require('./compile');
const React = require('react');
const ReactRouter = require('react-router');
const ReactRouterDom = require('react-router-dom');
const { renderToString } = require('react-dom/server');
React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};
React.useEffect = () => {};
async function renderApp(source, target, language, files, dist) {
const result = await compile({
dist,
files,
target,
platform: 'node',
stdin: {
contents: `
import { MemoryRouter } from "react-router";
import Page, { meta } from "./${basename(source)}";
import Layout from "${relative(dirname(source), resolve(__dirname, '../../layouts/default'))}";
const page = (
<MemoryRouter>
<Layout language="${language}">
<Page />
</Layout>
</MemoryRouter>
);
export { page, meta };
`,
sourcefile: resolve(dirname(source), 'temp-page.jsx'),
resolveDir: dirname(source),
loader: 'jsx',
},
});
const module = {
exports: {},
};
const ctx = createContext({
exports: module.exports,
require(name) {
switch (name) {
case 'react':
return React;
case 'react-router':
return ReactRouter;
case 'react-router-dom':
return ReactRouterDom;
default:
console.error('Cannot require', name);
return undefined;
}
},
setTimeout() {},
setInterval() {},
React,
module,
XMLHttpRequest: class {},
XDomainRequest: class {},
localStorage: {
getItem() {
return undefined;
},
setItem() {},
},
document: {
title: 'sample',
querySelector() {
return {
getAttribute() {
return '';
},
};
},
},
});
const code = result.outputFiles.find((m) => m.path.endsWith('.js')).text;
runInContext(code, ctx);
const { page, meta } = module.exports;
return {
content: renderToString(page),
meta,
};
}
async function makeFile(outPath, content) {
const outDir = dirname(outPath);
await mkdir(outDir, {
recursive: true,
});
await writeFile(outPath, content, 'utf8');
}
function makePage(outPath, meta, html, content) {
return makeFile(outPath, html.replace(/<div id="app">.*?<\/div>/s, `<div id="app">${content}</div>`);
}
process.on('message', (msg) => {
const { source, target, files, language, html, dist } = msg;
setTimeout(async () => {
const { content, replacements, meta } = await renderApp(source, target, language, files, dist, html);
const route = (meta.route || target).substring(1);
const outPath = resolve(dist, route, 'index.html');
await makePage(outPath, meta, html, content);
process.send({
done: true,
outPath,
replacements,
});
}, 100);
});
While the overall flow has remained the same, we now convert the page with some imports (e.g., MemoryRouter
) to a plain JavaScript script using the CommonJS module system. Therefore, the evaluation with the Node.js vm
module does not require esm
or ts-node
any more. Everything has been taken care of by esbuild.
In the code above the actual esbuild usage is hidden in the compile
function, so let's see how we set it up:
const { build } = require('esbuild');
const { codegenPlugin } = require('esbuild-codegen-plugin');
const { resolve, basename } = require('path');
function compile(opts) {
const { dist, target, stdin, files, platform, entryPoints } = opts;
const isBrowser = platform === 'browser';
return build({
stdin,
entryPoints,
outdir: resolve(dist, target.substring(1)),
write: false,
bundle: true,
splitting: isBrowser,
minify: isBrowser,
format: !isBrowser ? 'cjs' : 'esm',
platform,
loader: {
'.jpg': 'file',
'.png': 'file',
'.svg': 'file',
'.avif': 'file',
'.webp': 'file',
},
alias: {
'@': resolve(__dirname, '../..'),
},
external: ['react', 'react-router', 'react-router-dom', 'react-dom', 'react-dom/client'],
plugins: [
codegenPlugin(),
{
name: 'dynamic-assets',
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
const name = basename(args.path);
const idx = name.lastIndexOf('.');
const front = name.substring(0, idx);
const ext = name.substring(idx);
const prefix = front + '-';
const file = files.find((m) => m.startsWith(prefix) && m.endsWith(ext));
if (file) {
return {
path: file,
namespace: 'dynamic-asset',
};
}
return undefined;
});
build.onLoad({ namespace: 'dynamic-asset', filter: /.*/ }, (args) => {
const path = `/assets/${args.path}`;
return {
contents: `export default ${JSON.stringify(path)};`,
loader: 'js',
};
});
},
},
],
});
}
exports.compile = compile;
Quite straight forward. The few things to note are:
- Again, the usage of
codegen
is superb is there is literally a plugin for each bundler. So we can just use the codegen plugin for esbuild and we are covered here. - The dynamic asset plugin that has been defined above looks within the files if the name and extension fits; if it does it will use the already generated one from Vite.
- We can reuse the
compile
function not only for Node.js (the SSG part), but also for compiling some JS functions that run in the browser.
Especially the last point can be crucial. Right now this setup really moves the dynamic part (a SPA) to a fully static one. But we still have some interactive parts in there... So fully hydrating this thing - as we did beforehand - may not be good enough.
But even with this more modern setup one thing was still from 2019: the overall performance.
Lighthouse Issues
In 2019 we still got a great Lighthouse score, but now it's mediocre at best. Lighthouse got a lot more pushy - especially regarding hydration scenarios as we have here.
The score summary is:
Category | Desktop | Mobile |
---|---|---|
Score | 70 | 50 |
Best Practices | 81 | 79 |
First Contentful Paint | 0.4 s | 1.7s |
Largest Contentful Paint | 1.6 s | 8.4s |
Total Blocking Time | 0 ms | 20ms |
Cumulative Layout Shift | 1.828 | 1.899 |
Speed Index | 0.7s | 2.1s |
There are some findings that seem rather optional, but nevertheless could be implemented rather quickly (or not at all):
- Easy: No CSP header (as this is just served as static content we could embed it easily in the HTML as
meta
tag) - Tedious: On mobile some images are served with a lower resolution (presumably going into the
picture
tag with differentsource
elements to return the "right" resolution would fit) - Impossible: Deprecated API used (
unload
handler; usepagehide
event instead - actually we don't use this: the problem is coming/injected from a Chrome extension)
Naturally, the question is why the score is so low - and what we can do about it. Notably, we have a CLS (Cumulative Layout Shift) and a quite large LCP (Largest Contentful Paint).
Improvements
The first thing to do is to lower the amount of work that the JS engine has to do. For this we needed to rethink our hydration strategy. The standard one from a React SPA is to hydrate everything. But this comes with a lot of overhead and a quite slow start.
A much better approach would be to include an island architecture style here. Instead of hydrating everything in the beginning, we only hydrate parts - and only hydrate these parts when we need them (e.g., when they become visible).
How can we do that? Time for magic.
Let's consider the following code:
const Testimonials: React.FC = () => (
<>
<div className="container">
<h1>Testimonials</h1>
</div>
<div className="quote-carousel">
<TestimonialsSlides />
</div>
</>
);
This is the testimonials part of the homepage. Everything should be static - except the TestimonialsSlides
. This is a Carousel with some content. Everything is defined in React.
What if we could just tell the system to hydrate it? Let's imagine we rewrite the code to look like this:
const Testimonials: React.FC = () => (
<>
<div className="container">
<h1>Testimonials</h1>
</div>
<div className="quote-carousel" data-hydrate={TestimonialsSlides}>
<TestimonialsSlides />
</div>
</>
);
The only thing we changed is that we added a data-hydrate
attribute. Granted, this should be a string - but (surprise) we won't use the attribute anyway. We will actually change the attribute value at build-time.
The architecture that we have in mind works as follows:
The build-time change in the SSG kernel module is an addition for the replacements:
const { createHash } = require('crypto');
async function getUniqueName(path) {
const fn = basename(path);
const name = fn.substring(0, fn.lastIndexOf('.'));
const content = await readFile(path);
const value = createHash('sha1').update(content);
const hash = value.digest('hex').substring(0, 6);
return `${name}.${hash}`;
}
const matcher = /"data-(hydrate|render|load)":\s+(\w+)[,\s]/g;
const replacements = [];
while (true) {
const match = matcher.exec(code);
if (!match) {
break;
}
const lookup = match[0];
const kind = match[1];
const componentName = match[2];
const pos = code.indexOf(`var ${componentName}`);
const idx = code.lastIndexOf('\n// ', pos) + 4;
const src = code.substring(idx, code.indexOf('\n', idx));
const entry = resolve(__dirname, '../../..', src);
const name = await getUniqueName(entry);
replacements.push({
lookup,
entry,
name,
value: `"data-${kind}": "/assets/${name}.js",`,
});
}
for (const { lookup, value } of replacements) {
code = code.replace(lookup, value);
}
Nice! With this change we have a replacement of the attributes during the build process. We also identify the replacements to use them later on in one joint build process (such that they produce common chunks):
const { writeFile } = require('fs/promises');
const { basename, resolve } = require('path');
const { compile } = require('./compile');
process.on('message', (msg) => {
const { replacements, dist, files, target } = msg;
setTimeout(async () => {
const result = await compile({
dist,
files,
target,
platform: 'browser',
entryPoints: replacements.map((m) => ({ in: m.entry, out: m.name })),
});
for (const file of result.outputFiles) {
const name = basename(file.path);
const content = file.text;
await writeFile(resolve(dist, target.substring(1), name), content, 'utf8');
}
process.send({
done: true,
});
}, 100);
});
The only thing missing is a replacement for the SPA script. The replacement should be able to work with the data-hydrate
attribute, which tells the website to hydrate a container with a specific file:
function integrate() {
function getProps(element: Element) {
try {
return JSON.parse(element.getAttribute('data-props'));
} catch {
return {};
}
}
function load(fn: string) {
const react = import('react');
const reactDom = import('react-dom/client');
const mod = import(fn);
return Promise.all([react, reactDom, mod]);
}
document.querySelectorAll('*[data-hydrate]').forEach((element) => {
const fn = element.getAttribute('data-hydrate');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
observer.disconnect();
load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
requestAnimationFrame(() => hydrateRoot(element, createElement(m.default, getProps(element))));
});
}
});
});
observer.observe(element);
});
document.querySelectorAll('*[data-load]').forEach((element) => {
const fn = element.getAttribute('data-load');
load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
requestIdleCallback(() => hydrateRoot(element, createElement(m.default, getProps(element))));
});
});
document.querySelectorAll('*[data-render]').forEach((element) => {
const fn = element.getAttribute('data-render');
load(fn).then(([{ createElement }, { createRoot }, m]) => {
createRoot(element).render(createElement(m.default, getProps(element)));
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', integrate);
} else {
integrate();
}
That is pretty much it! The great thing is that this script is so small that it can be inlined. Since we also know when / if we need replacements we'd only inline the script when we would need it (i.e., when we might hydrate).
Finally, we externalized react
(and others), but we did not specify a replacement. As esbuild produces esm files those externals are placed as standard imports such as:
import * as React from 'react';
We would run into problems if such a thing is evaluated in the browser. The solution is to introduce an importmap in our page:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react-dom": "https://esm.sh/react-dom@18.3.1",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
}
}
</script>
Note that the importmap is lazyly loaded anyway. So it will perform optimally out of the box.
With everything in place we can look again at the lighthouse score.
The score summary is:
Category | Desktop | Mobile |
---|---|---|
Score | 78 | 65 |
Best Practices | 81 | 79 |
First Contentful Paint | 0.6 s | 4.3s |
Largest Contentful Paint | 1.3 s | 9.4s |
Total Blocking Time | 0 ms | 10ms |
Cumulative Layout Shift | 0 | 0 |
Speed Index | 0.6s | 4.3s |
While the score is already much better the LCP and overall speed index actually got worse. The reason the score is better is most notably that the CLS is now 0, which is great.
So where did we go wrong?
Final Touches
While the SPA transition hurt our score, it did not hurt as much as badly hydrated components. One of these components is the main carousel, which is shown immediately.
It turns out the carousel component was only made for SPAs. It dynamically computes the width and position of its slides based on the given content. The following diagram shows its working:
In short we have a container that gets the dimensions of the component. Inside the container we use a content element that has the width of the container multiplied by the number of slides + 2. We add two as we also introduce duplicates for the first and last slide. This way, we can have the experience of a real carousel. From the last slide you can keep on swiping right to get to the first slide and vice versa. Essentially, this allows you to keep scrolling in one direction.
What can we do to improve the code here?
Instead of relying on the runtime to set style
properties we return a pre-computed style
object. This way, we only change dynamic properties when a scrol operation starts.
Originally, the code had a function like:
const updateOffset = () => {
const c = container.current?.parentElement;
if (c) {
const o = offset.current;
let transform = 'translateX(0)';
let transition = 'none';
if (state.desired !== state.active) {
const dist = Math.abs(state.active - state.desired);
const pref = Math.sign(o || 0);
const dir = (dist > length / 2 ? 1 : -1) * Math.sign(state.desired - state.active);
const shift = (100 * (pref || dir)) / (length + 2);
transition = smooth;
transform = `translateX(${shift}%)`;
} else if (!isNaN(o)) {
if (o !== 0) {
transform = `translateX(${o}px)`;
} else {
transition = elastic;
}
}
c.style.transform = transform;
c.style.transition = transition;
c.style.left = `-${(state.active + 1) * 100}%`;
}
};
Now we move to:
const updateOffset = () => {
const c = container.current;
if (c) {
const o = offset.current;
let transform = 'translateX(0)';
let transition = 'none';
if (state.desired !== state.active) {
const shift = getShift(o, state.active, state.desired, length + 2);
transition = smooth;
transform = `translateX(${shift}%)`;
} else if (!isNaN(o)) {
if (o !== 0) {
transform = `translateX(${o}px)`;
} else {
transition = elastic;
}
}
c.style.transform = transform;
c.style.transition = transition;
}
};
The new getShift
function is also much improved:
function getShift(o: number, active: number, desired: number, total: number) {
if (!o) {
const end = total - 3;
if (!desired && active === end) {
o = -1;
} else if (desired === end && !active) {
o = 1;
}
}
if (o) {
const pref = Math.sign(o);
return (100 * pref) / total;
} else {
const diff = active - desired;
return (100 * diff) / total;
}
}
This results in a better UX while also improving the story for SSG / SSR.
Besides the work on the carousel component we also optimized the images and spend some time on content cleanup. Nothing dramatic - just little wins here and there.
The score summary is:
Category | Desktop | Mobile |
---|---|---|
Score | 99 | 87 |
Best Practices | 78 | 75 |
First Contentful Paint | 0.6 s | 2.6s |
Largest Contentful Paint | 0.8 s | 3.4s |
Total Blocking Time | 0 ms | 10ms |
Cumulative Layout Shift | 0.009 | 0 |
Speed Index | 0.6s | 2.6s |
This is it! Wonderful that such a "little" change in a single component can have such a drastic outcome. But in the end it's all about the details.
While every metric now looks good the best practices got a bit worse. Why? Because we added a small video in form of a YouTube embed (iframe
) into the homepage. As YouTube brings quite some cookies and other things Lighthouse is not really happy with the third-party content. So we can actually ignore this.
As wanted to be in the 90s rating this is certainly a success and as desired.
Conclusion
Overall, we kept the codebase stable and just gave the whole source code a bit of a makeover. This was sufficient to reach the next level of DX and performance.
As next step we'll modernize some of our content and re-introduce the SPA transition by hijacking internal links; loading an HTML fragment during a page transition. We'll potentially also replace the importmap with an integrated bundle, using Preact in compat mode as a replacement.
Top comments (12)
Very minor nit: You probably want to wrap
hydrateRoot
instartTransition
so the hydration becomes concurrent (improves INP, see slide 27)Yeah this is great - I almost forgot about it. I guess the main reason is that we potentially (see conclusion) replace React anyway at runtime with Preact (which has
startTransition
, but it's only a stub invoking the callback immediately).I just tried it and the INP goes from ~50ms to ~5ms (in most cases it's just flat 0). So yeah, definitely worth doing!
Great slides!
Thank you! Really appreciate it.
Great article! Thanks!!!
Literally anything to make react faster I'm sure will be welcomed by the world.
But if this article interests you, read some about using nearly any other framework. 😜
Hehe one thing I missed in the article is that we looked into a migration to Astro, which we use for a couple of other websites. It would fit very well here. However, as the underlying component lib uses React the migration would need to be done here first - which is out of scope.
So I guess you miss the point of the article. If the article interests you, you might not look into React for doing web dev, but you rather have a page using React and want to speed it up (and a migration might be out of scope, too).
Web development is doomed!
Why does it have to be overcomplicated to deliver simple sites?
After almost 6 years in React, I plan to start over with much simpler tools than this. Stockholm syndrome forces us to do crazy gymnastics...
The best solution for a high-performance site is not to use React! 🤣
Abandoning React is one option to consider. This is the choice made by the Microsoft Edge development team, for example, with an HTML-First approach.
The font used in this article makes it very difficult to read on my phone. 😥
Are you sure that's not a problem with your configuration? The article (like any other on dev.to) cannot change the font of dev.to.
I'm using Safari and now I know why...