Better Programming

Advice for programmers.

Follow publication

Low-Hanging Web Performance Fruits: A Cheat Sheet

Artem Perchyk
Better Programming
Published in
18 min readMar 19, 2023
Image credit

Here’s the situation you’ve probably been in already: you join a new project and soon notice the page load speed is… underwhelming. You might assume it’s due to your slow network, but after running a Lighthouse report, you find the score is 20 out of 100. Something is definitely wrong here!

When I see something like this, I usually have a real urge to fix it. Besides, sometimes it’s a nice way to get acquainted with the project as you inevitably go through a lot of code when doing it.

Now, what’s important is that performance optimizations are a real rabbit hole, and there’s almost always something else that can be optimized. The Pareto principle applies here perfectly: a set of things can be done relatively easily, but they can have a meaningful impact. The purpose of this article is to be a “cheat sheet” for such optimizations and will be as framework-agnostic as possible. And also, it’s not about fixing slow algorithms and stuff. That’s a whole different topic.

1. Optimize Your Assets

Okay, I know what you’re thinking. “What a great insight, dude! What’s next? You’re gonna tell us that 2 + 2 = 4?”

But seriously, people tend to forget to optimize their assets all the time. There is a huge probability that the repository you’re working on contains a bunch of excessively large images, too. And those images can be large, sometimes almost as large as your JavaScript bundle, which at least does something useful. Of course, if the first page you’re visiting doesn’t contain a lot of images, the impact on FCP and TTI will be less visible. However, even the slowly loading images on further navigation through the app can be irritating.

Luckily, there are a lot of free tools to compress images without losing quality. I personally prefer TinyPNG. It works just fine, and their panda mascot is cute.

Also, as IE11 has passed into history, we’re almost safe to use WebP without fallback images. WebP is more effective than JPG and PNG, so using it will reduce the image size by another ~20%. “Almost safe” because, according to Caniuse, Safari versions 14–15 only support WebP if OS Big Sur or later is installed.

Finally, the loading=”lazy” attribute is now available for img and iframe elements. It lazily loads non-critical elements (off the screen) and can significantly improve page load speed.

<img src="image.jpg" alt="..." loading="lazy" />
<iframe src="video-player.html" title="..." loading="lazy"></iframe>

Unfortunately, as of March 2023, Safari requires to enable an experimental feature for this to work for iframes, and Firefox doesn’t support this attribute on iframes at all. But it is safe to use anyway.

The next step is to check for any custom SVGs on the site. If there are, there’s a chance that they are unoptimized too. The icons usually aren’t that big, but if you can squeeze out another 10–20 KB on them, why not? I won’t go in-depth on this matter because there is this wonderful article by Raymond Schwarz. For the optimization itself, Jake Archibald’s SVGOMG is the default option.

Now, the fonts. In old grim times, we often needed different formats of the same font (.eot, .otf, .ttf, .woff, .woff2…) because the browser support was so weird. Nowadays, .woff2 is all that’s needed unless you require Opera Mini support. If it’s provided together with older font formats, your browser that supports it will only load .woff2 version anyway. But it might also be that some of the fonts you’re using only exist in older formats — in this case, make sure to convert them. There are plenty of free websites for this on the internet.

And if you’re using Google Fonts using their default suggested option (embed a link element that will load an external CSS that will request the fonts), just don’t do that. It’s always better to self-host a font unless you’re making a very quick POC. Here’s a nice article on this topic.

Another obvious thing that, I believe, still needs to be mentioned: make sure that the CSS and JS that you’re loading are minified. Some bundlers, such as Parcel v.2, do it by default. Webpack does it too, but most likely, if you use Webpack, you also use a config file, so make sure it contains something like this:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = (env, argv) => {
return {
// ...other parts of config...
optimization: {
minimizer:
argv.mode === 'production'
? [
new TerserPlugin({
terserOptions: {
format: {
comments: false,
beautify: false,
},
},
}),
new CssMinimizerPlugin(),
]
: [],
}
}
}

Finally, make sure that your assets are compressed. Again, even though this sounds like a default option, I’ve seen projects that weren’t enabled. For the past years, gzip was the default compression algorithm, but brotli has full browser support for a while, and it offers more effective compression than gzip. While many servers compress data on the fly, others require you to upload pre-compressed payloads ahead of time. If this is your case, use WebpackCompressionPlugin, @parcel/compressor-gzip, or rollup-plugin-gzip, depending on your bundler.

TL;DR

  • Compress your static images
  • Use loading="lazy" on them
  • Optimize custom SVGs
  • Don’t use legacy font formats
  • Don’t use CDNs for fonts unless you really know what you’re doing and why
  • Don’t forget to minify your CSS and JS for production
  • Make sure that the assets you’re receiving from the backend are compressed using gzip or Brotli

Now the next step would be…

2. Cache Your Assets

All of the above is important for the first user impression. However, for the subsequent visits, there’s a real cheat code: a service worker!

“Service workers essentially act as proxy servers that sit between web applications, the browser, and the network (when available). They are intended, among other things, to enable the creation of effective offline experiences, intercept network requests and take appropriate action based on whether the network is available, and update assets residing on the server. They will also allow access to push notifications and background sync APIs.” — MDN

Essentially, a service worker enables us to store JavaScript, CSS, images, and even page navigations for quick delivery from the cache instead of sending actual requests. Moreover, we can set up precaching to load these assets in the background before the user even requests them.

The service worker API, however, is a bit confusing when you first look into it. This is where an awesome set of libraries called Workbox comes for help. It provides a more human-readable API to set up caching. In addition to that, it comes with a set of recipes for the most common tasks.

To set up a service worker, you need to register it in the entry point file (for example, in React apps, this is the file where you call root.render()).

import { Workbox } from 'workbox-window';

// Having the service worker locally can lead to some very weird debugging situations
if (!window.location.host.includes('localhost')) {
// service-worker.js is in the root folder, entrypoint file is one level deeper.
const wb = new Workbox(`/service-worker.js`);
// This is kind of redundant now, but, well...
const isServiceWorkerSupported: () => boolean = () => 'serviceWorker' in navigator;

const registerServiceWorker = (): void => {
if (isServiceWorkerSupported()) {
wb.register()
.then(() => {
console.log('Service Worker registration completed');
})
.catch((err) => {
console.error('Service Worker registration failed:', err);
});
}
};

registerServiceWorker();
}

You will probably want to test the service worker locally. To do this, you need to temporarily place the service-worker.js file into your public folder alongside the assets, as this file, won’t be loaded from the root locally because it’s not a part of the bundle.

The contents of the service worker file depend on what and how you want to cache.

Here’s an example:

import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';

// clientsClaim() and self.skipWaiting()
// essentially force the service worker to install immediately

clientsClaim();

self.addEventListener('install', function (event) {
event.waitUntil(
caches
// In this example, my-cache will, obviously, be always empty. But we will fill it later!
.open('my-cache')
.then(function () {
return self.skipWaiting();
})
);
});

// Cache page navigations (html) with a Network First strategy
registerRoute(
// Check to see if the request is a navigation to a new page
({ request }) => request.mode === 'navigate',
// Use a Network First caching strategy
new NetworkFirst({
// Put all cached files in a cache named 'pages'
cacheName: 'pages',
plugins: [
// Ensure that only requests that result in a 200 status are cached
new CacheableResponsePlugin({
statuses: [200],
}),
],
})
);

// Cache CSS, JS, and Web Worker requests with a Stale While Revalidate strategy
registerRoute(
// Check to see if the request's destination is style for stylesheets, script for JavaScript, or worker for web worker
({ request }) =>
request.destination === 'style' || request.destination === 'script' || request.destination === 'worker',
// Use a Stale While Revalidate caching strategy
new StaleWhileRevalidate({
// Put all cached files in a cache named 'assets'
cacheName: 'assets',
plugins: [
// Ensure that only requests that result in a 200 status are cached
new CacheableResponsePlugin({
statuses: [200],
}),
],
})
);

// Cache images with a Cache First strategy
registerRoute(
// Check to see if the request's destination is style for an image
({ request }) => request.destination === 'image',
// Use a Cache First caching strategy
new CacheFirst({
// Put all cached files in a cache named 'images'
cacheName: 'images',
plugins: [
// Ensure that only requests that result in a 200 status are cached
new CacheableResponsePlugin({
statuses: [200],
}),
// Don't cache more than 50 items, and expire them after 30 days
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true,
}),
],
})
);

We use three different caching strategies in this example. What are they, though? I’ll allow myself to just quote the official Workbox docs:

Stale While Revalidate uses a cached response for a request if it’s available and updates the cache in the background with a response from the network. Therefore, if the asset isn’t cached, it will wait for the network response and use that. It’s a fairly safe strategy, as it regularly updates cache entries that rely on it. The downside is that it always requests an asset from the network in the background.

Network First tries to get a response from the network first. If a response is received, it passes that response to the browser and saves it to a cache. If the network request fails, the last cached response will be used, enabling offline access to the asset.

Cache First checks the cache for a response first and uses it if available. If the request isn’t in the cache, the network is used and any valid response is added to the cache before being passed to the browser.

Network Only forces the response to come from the network.

Cache Only forces the response to come from the cache.

You might want to customize this configuration to suit your specific requirements and experiment with different caching strategies. However, there’s an essential and powerful feature that’s missing — precaching. When the service worker is installed after a wb.register() call, it will proactively fetch and cache all the relevant assets in the background.

As a result, when a user requests a page or asset that’s already been cached, it will be delivered quickly from the cache without having to visit every page or load every piece of content beforehand. This provides a significant performance boost and a smoother user experience.

Workbox provides two ways to enable precaching: with generateSW and injectManifest. I prefer the second because it’s more flexible, more information on this can be found here.

The only bundler that’s officially supported by Workbox for precaching is Webpack. However, the plugin for Rollup seems to be actively maintained too. Alternatively, you can use a CLI tool or use workbox-build as a part of your CI pipeline.

Here is how you can enable precaching with Webpack:

// service-worker.js

import { precacheAndRoute } from 'workbox-precaching';

clientsClaim();
// __WB_MANIFEST is a placeholder for a manifest that's created by workbox-webpack-plugin
precacheAndRoute(self.__WB_MANIFEST);
// webpack.config.js 

const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = (env, argv) => {
return {
entry: './src/index.tsx',
plugins: [
// Some common plugins for both dev and prod modes here...
].concat(
argv.mode === 'production'
? [
new InjectManifest({
// Take the SW file that's in the same folder as the Webpack config
swSrc: './service-worker.js',
// Put the generated file with the precaching instructions
// into the root of `dist` folder so
// our entrypoint file index.tsx can read it
swDest: 'service-worker.js',
// Optional: set a size limit for precached files
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
// Optional: exclude some files based on name and/or type
// For example, if your images are responsive,
// with many variants for different screens,
// you might not want to precache them all
// because it would take too much time on app startup
exclude: [/\.png$/, /\.woff$/],
}),
]
: []
),
};
};

If everything is done correctly, you will see something like this in the console after running the production build:

The above will improve the user experience by a lot. However, we can do more! Remember the useless my-cache in service-worker.js above? We can fill it with the network responses so that once the response for a matching request is received, it stays in the cache, and when the request is performed again, the response will be returned immediately. And this cache will stay between the sessions. How cool is that?

However, you have to use this power with caution. This technique is best suited for the endpoints which rarely change — let’s say there is some configuration on the backend that is requested by the frontend on app startup, and it’s pretty much static. Don’t use it for anything subject to change often.

Also, in my opinion, stale-while-revalidate is the only strategy that makes sense when you’re pursuing performance improvements. This means that if the content for the cached endpoint is changed on the backend, the end user will only see the updated version on the second attempt, which may be confusing in some cases. If that’s not an option, you should rely on the backend cache instead.

And there is an unfortunate (but logical) limitation, it’s not possible to cache POST requests — so, for example, if you’re using GraphQL, you’re probably not going to make it work. At some point, I spent a lot of time trying to overcome this limitation by storing the cached responses in the IndexedDB, even created my first SO question, but didn’t make it work. Let me know in the comments if this is actually doable!

Anyway, if you have such network calls, you can cache them in the service worker with the following code:

// service-worker.js

self.addEventListener('fetch', (event) => {
const allowedEndpoints = [
'getVeryImportantStartupData'
];
const { request } = event;
const requestURL = new URL(request.url);

// Prevent Chrome Developer Tools error:
// Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': 'only-if-cached' can be set only with 'same-origin' mode
//
// See also https://stackoverflow.com/a/49719964/1217468
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return;
}

if (!allowedEndpoints.some((endpoint) => requestURL.pathname.includes(endpoint))) {
return;
}

const freshResource = fetch(event.request)
.then((response) => {
const clonedResponse = response.clone();
if (response.ok) {
caches.open('my-cache').then((cache) => {
cache.put(event.request, clonedResponse);
});
}
return response;
})
.catch((e) => {
console.error(`Couldn't fetch fresh resource ${e}`);
});
const cachedResource = caches
.open('my-cache')
.then(async (cache) => {
return cache.match(event.request).then((response) => {
return response || freshResource;
});
})
.catch(() => {
return freshResource;
});
event.respondWith(cachedResource);
});

I advise reading the awesome Offline Cookbook by Jake Archibald for more inspiration and ideas.

TL;DR

  • Use service workers to cache assets
  • Precaching is often a viable option, but use it wisely
  • You can even cache responses to GET requests, but choose the endpoints to cache carefully
  • Service workers aren’t that much of “low-hanging” fruits, deploying them can be challenging. However, I hope that the provided code snippets can help

3. Split Your Code

As your app grows, the amount of JavaScript code needed for startup also increases. If you rarely deploy and your user base is small or limited to existing customers, and you’ve implemented SW caching from the previous step, it may not matter too much. But if not, you’ll want to make your entrypoint chunk, which is the code required for the application to start up, as small as possible.

However, this is a framework-dependent issue, and bundlers can also add their own differences. In the scope of this article, it’s impossible to cover everything. So, I’ll provide a general overview of code splitting and leave it at that.

You would probably want to start with route-based code splitting using dynamic imports, which works in a pretty similar way in all the most popular frameworks.

Here’s what it means:

// replace
// import UserDetails from './views/UserDetails'
// with
const UserDetails = () => import('./views/UserDetails.tsx')

Then you can use this import in the same way as you would usually do when declaring your routes. This will tell your bundler to create a separate chunk for UserDetails and everything that’s imported in it, and the browser will only load it when the component should be rendered.
Webpack 4.6.0+ also supports preloading and prefetching of the modules — something that’s worth experimenting with.

Before we move forward, here are a couple of notes on route-based code splitting:

  • Route-based code splitting can be more challenging to implement in existing Angular applications compared to React and Vue. This is because of the modular architecture in Angular and the tendency for developers to import functions and components between different feature modules. Encapsulate the modules by moving reused code into a common module, which acts as a mediator. This may be a long journey, but it is worth it for proper lazy loading and to clean up the architecture of your app.
  • React has a built-in function called React.lazy for lazy loading, which is cool, but there is a caveat. If a user has your app in an opened tab while you make a deployment, you might run into a situation where the app requests a chunk that is no longer present because its hash has changed. Check this gist by Raphaël Léger. This is a utility function that will reload the application if a chunk was not found, forcing the user to load a new version of the app. I found it super useful.

After you’re done with route-based code splitting, you can go deeper. For example, in the Panalyt app, we have a data view for each dashboard that is using two libraries that are not used anywhere else in the app: react-virtualized and dnd-kit. I have changed the imports of this component from regular to dynamic, which eliminated these two libraries that are not needed for the app to start from the entrypoint chunk:

const ReportView = lazyWithRetry(() => import('../ReportView/ReportView'));

Finally, sometimes even this is not enough. An example: let’s say we have a page that is only accessible to a certain category of users. The page is fairly simple and does not have any inner components. But it’s using a heavy library which ends up in the entry point chunk because it’s used in a route-level component. To fix this, we can use top-level await and dynamically import the module that contains the library:

const HeavyLibrary = (
await import(
/* webpackChunkName: "heavyLibrary" */
/* webpackMode: "lazy" */
'heavylibrary' // the package name
)
).default

The only problem I’ve encountered with this so far is that if the imported module is a JSX component and you’re using TypeScript, the type definitions will be lost.

TL;DR

  • Use route-based code splitting to ensure you don’t load everything at once
  • If needed, go one level deeper and lazy load separate components if they contain heavy and unique dependencies
  • If needed, you can lazy load certain libraries to ensure they are out of the entrypoint chunk

All of the above is needed to…

4. Optimize Your Bundle

As with many things in this article, this sounds like a very basic and obvious thing… which is often forgotten in the projects.

Code splitting techniques described in the chapter above help a lot, but you must also manage and audit your dependencies. Ask things like: “do we really need X? We’re only using two functions. Maybe we can replace them somehow”?

Sites like You might not need exist for a reason.

See what’s in your bundle with a tool like Webpack Bundle Analyzer or Source Map Explorer and make decisions based on what you see.

Quite often, it makes sense to exclude some part of a library because you don’t use it, like with the infamous locales issue of moment.js. With Webpack, you can exclude things with IgnorePlugin, or if you don’t need a whole subpackage that has tree-shaking issues, you can resolve it to false (which is not the purest method, but it works):

    resolve: {
extensions: ['.tsx', '.ts', '.js', 'json'],
alias: {
'@sentry/replay': false,
},
},

Another thing I have noticed just recently is that Webpack Bundle Analyzer has a super useful setting that allows you to only visualize your entry point chunk, so you can see how much code your app actually needs for the startup.

That’s basically it. I just wanted to highlight the importance of bundle size. Trying to optimize it is a fun experience, but there isn’t much general advice because of the variety of dependencies that can bloat your bundle. Just don’t forget to audit it from time to time… and if the app is still using an old bundler like, let’s say, Webpack v.2 or Parcel v.1, upgrade it, and you might be pleasantly surprised.

5. Manage Third-Party Scripts

Premature optimization is the root of all evil? Well, maybe, but I feel that for the web, there is the second root which consists of unoptimized third-party scripts. They are like sneaky little gremlins that destroy your website performance.

A real-life story first. Years ago, I was lucky enough to get my first job as a software engineer, and the project was a huge e-commerce website (performance matters for these more than for anything else, I think). It was also a multi-page application, so every page change loaded everything from scratch.

And so it happened that I became the person who was responsible for web analytics and third-party scripts in general.

One day, we got a request from the marketing department to inject yet another third-party script to enable yet another analytics system. We didn’t really want to, but we had to. I have added the script and the performance report for the next day has shown that our average page load time has degraded from ~3s to ~4s, just because of one script. It wasn’t even that particularly big. It was just slow. After long debates with the marketing department and the third-party system support, we have delayed the downloading and execution of this script by three seconds after the window load event.

We lost some metrics, but we had them from other systems anyway. This was the first harsh lesson that those small scripts could hurt.

The second harsh lesson was when some consultant guy wrote three lines of code in this system’s GUI. The code was then shipped with the script that was downloaded. Yes, a lot of those systems allow making nice little stored XSS. The code was something like this:

if (dataLayer.customerProfile.customerId !== undefined) {
// do something with the customerId
}

You might have guessed what happened then. The sad cannot read property customerId of null moment has happened because customerProfile was either an object when the customer was logged in, or null when otherwise. Wouldn’t have been much of an issue if at least one of the following wasn’t true:

  • The third-party script had an implicit setInterval(f, 200) which attempted to call the function again in case of an error
  • We had our own error logging system, which sent a request in case of an error
  • We were self-hosting the app, and the logger was hosted on the same servers
  • There were a lot of anonymous customers
  • The consultant did it in the late evening and didn’t check the result

The result was a nice self-DDoS attack that overloaded the servers and caused significant downtime when the business hours started the next day.

The moral of the story is the following: don’t be the person who just adds another script when being asked. Be an owner of all that stuff instead.

Measure the performance impact. Set the rules regarding who and how can change anything, especially when it results in additional code that is silently loaded on the front end.

When auditing the existing third-party scripts, you can do the following:

  • Check with those who use the analytics data if there are any unused scripts. There is a good chance that something was added for a trial period but was not deleted after it ended.
  • If there are many scripts, the best way to load them would often be to use a tag manager (Google Tag Manager, Segment, Tealium, Adobe Launch… pick your poison). This centralizes the management of the 3rd party systems, allows to make the changes without deployments, and allows to have an impact on the site performance by defining when the tag manager script is loaded — as all the tags will be loaded only after it. There is a nice article on web.dev with much advice on this subject.
  • Partytown is an awesome solution for moving the analytics scripts to a web worker. It is currently in Beta, so it should be used with certain caution — but it is very promising!
  • If you use a tool that implies non-immediate user interaction, such as a live chat, you can load it with a facade. It means to fake a chat button and not load a script until it’s actually needed. For React apps, I have personally tested react-live-chat-loader, and it works just fine, but you can build a similar solution by yourself.
  • Pre-connect to the script origin domains using link elements like this, it will tell the browser to establish the connection in advance, so you’ll probably save some time when fetching the script:
<head>
<link rel="preconnect" href="https://widget.intercom.io" />
<link rel="dns-prefetch" href="https://widget.intercom.io" />
</head>

TL;DR:

  • Think about what third-party scripts are loaded on the site
  • Defer their loading when possible
  • If there are many of them, think of using a tag manager
  • Fake the live chats until the user clicks on them
  • Pre-connect to the domains that host the scripts

That’s it for today. Web performance is a huge topic, so there’s a lot more to cover, but I tried to describe the topics that impact the initial loading of the application the most and doesn’t require messing up with the existing code base aside from the configuration and few imports. I hope you have learned something new. Let me know what you think!

Write a response