Here’s another case study of mine! (The previous one was as far as two years ago, and it was about Walmart.)
Notion is an advanced note-taking app written with React and Electron. In this case study, I look at how to improve its startup time by ~30% – by removing unused JavaScript, reorganizing the network waterfall, and more:
In 2020, with fewer and fewer IE users, there’s no excuse to serving ES2015 polyfills to anyone. So, here’re three ways to avoid loading polyfills in modern browsers – in my new article on PerfPerfPerf:
Just launched the awesome-webpack-perf repository: a curated list of webpack tools for web performance. Covering the full range of tools – from JS/CSS/CSS-in-JS minifiers to images to analysis tools.
Because of that, a lot of people have to either self-host Google Fonts (which is annoying) – or opt out of font-display completely (which hurts performance). Well, no more.
In e-commerce, the conversion is directly affected by how fast the site loads. For many e-commerce companies, making the site faster by 1 second increased the conversion 1.05, 1.1, or even 1.2 times. That’s because the slower the site, the more users abandon it before it loads, and the lesser is the conversion.
In comparison, for Amazon, the content gets visible at 1.4 seconds. The customer sees the product they came for twice faster!
Let’s analyze the Walmart’s site and see how we can improve the performance – and help Walmart earn more! I’ll use the Lumia 635 product page as an example.
The first issue with the page is that it gets more or less rendered at 2.3s, but the text isn’t visible until 3.0s:
This happens because Walmart uses a custom font, and by default, Chrome and Firefox won’t render the text until the font is loaded. This is how it looks live:
See how the page stays without the text for a second?
(Network throttled with the “Fast 3G” preset in Chrome DevTools)
Flash of unstyled text is when the text was initially rendered with a system font, but later gets re-rendered with a custom one (and jumps around the page). Wikipedia
Browsers delay rendering the text to prevent a flash of unstyled text (FOUT). However, this makes the content invisible for longer – and likely decreases the conversion!
To change this behavior, we can add the font-display: optional rule to the @font-face styles. font-display controls how the custom font is applied. In our case, it tells the browser to just use a fallback font if the custom one is not cached:
Now, when a customer visits the page for the first time, they will see the text immediately, rendered in a fallback font. The browser will download the custom font in the background and use it for subsequent pages. The current page won’t get the custom font – this prevents the FOUT:
Now the text is visible immediately.
(Network throttled with the “Fast 3G” preset in Chrome DevTools. The CSS file was substituted with Fiddler)
With font-display: optional, the font won’t be applied until the user reloads the page. Keep this in mind if you have a single-page app: navigating across routes there won’t make the font active.
Most of <script> tags on the page have either the async or the defer attribute. This is good because the browser can render the page not waiting for these scripts to download:
The page has more scripts in different places, so that’s just an example
However, one large file – bundle.3p.min-[hash].js, 112.3 kB gzipped – doesn’t have either of these attributes. If it takes a while to download (e.g., the customer is on a bad connection), the page will stay blank until the script is fully loaded. Not cool!
To be honest, the bad connection could delay any non-deferred script, even the smallest one. So I’d try to defer as many scripts as I can
To solve this, add the defer attribute to this script tag too. As soon as all JavaScript that relies on bundle.3p.min-[hash].js is also deferred (which seems to be the case), the code will keep working fine.
This code doesn’t work as expected: because of defer, the bundle executes after both of these inline scripts. Just in case somebody from Walmart is reading this.
Chrome DevTools have the “Coverage” tab that analyzes how much CSS and JS is unused. If we open the tab, reload the page and click around a bit to run the most important JavaScript, we’ll see that around 40-60% of JS still hasn’t executed:
This code likely includes modals, popups and other components that aren’t rendered straight when the customer opens the page. They are a good candidate to be loaded only when actually needed. This might save us a few hundred kBs of JS.
This is how you load components dynamically with React and webpack:
In a real app, you’ll likely want to use react-loadable instead
import React from 'react';
class FeedbackButton extends React.Component {
handleButtonClick() {
// ↓ Here, import() will make webpack split FeedbackModal
// into a separate file
// and download it only when import() is called
import('../FeedbackModal/').then(module => {
this.setState({ FeedbackModal: module.default });
});
}
render() {
const FeedbackModal = this.state.FeedbackModal;
return <React.Fragment>
<button onClick={this.handleButtonClick}>
Provide feedback!
</button>
{FeedbackModal && <FeedbackModal />}
</React.Fragment>;
}
};
Another problem is that the Object.assign polyfill is served in 3 files simultaneously:
The polyfill is small on its own, but this might be a sign that more modules are duplicated across the bundles. I’d try looking into that if I had access to sources.
By default, webpack bundles polyfills for Node.js-specific functions when it sees them used. Theoretically, this is useful: if a library relies on setImmediate or Buffer which are only available in Node.js, it will still work in a browser thanks to the polyfill. In practice, however, I’ve seen the following happen:
// node_modules/random-library/index.js
const func = () => { ... };
if (typeof setImmediate !== 'undefined') {
// ↑ Webpack decides that `setImmediate` is used
// and adds the polyfill
setImmediate(func);
} else {
setTimeout(func, 0);
}
The library is adapted to work in the browser, but because webpack sees that it references setImmediate, it bundles the polyfill.
Node polyfills are small (a few kBs minified), so removing them usually doesn’t make sense. Still, it’s a good candidate to optimize if we were squeezing the last milliseconds from the page. Removing them is super easy (but needs to be tested – what if some code really needs them?):
Apart from JS, page rendering is also blocked by CSS. The browser won’t render the page until all CSS (and JS) files are downloaded.
The Walmart page initially depends on two CSS files. In my tests, the largest of them takes even longer to download than the JS bundle – so it blocks rendering even after the script got downloaded and executed:
Notice how the page stays blank (look into “Frames” in the bottom half of the image) until the CSS is fully downloaded
How to solve this? We can go the way Guardian went in 2013:
Find the critical CSS and extract it into a separate file. “Critical” means “The page looks funny without it”.
Tools like Penthouse or Critical might be useful here. I’d also tune the result manually to exclude content that’s above the fold but is not very important (e.g., header navigation):
We can show this a couple seconds later in exchange for faster overall rendering
When serving the initial HTML, only load the critical CSS.
Once the page is more or less downloaded (e.g., when the DOMContentLoaded event happens), dynamically add the remaining CSS:
In total, the Walmart page downloads three CSS files: one with the font definitions (BogleWeb.css) and two with the app styles (standard_css.style.[hash].css and style.[hash].css). The latter two seemed pretty similar, so I removed all the content except selectors and tried to compare the files.
Guess what? There’re 3400 common selectors among these files – and these selectors mostly have common styles! For the perspective, the first file has around 7900 selectors total, and the second one has around 4400:
That’s a good area to optimize. This won’t affect time to first paint if we decrease the render-blocking CSS properly, but these CSS files will still load faster!
The Walmart site is not a single-page app. This means that, on different pages, the customer has to download different styles and scripts. This makes every other page load longer, especially if the customer visits the site rarely.
We can improve that by creating a service worker. A service worker is a script that runs in the background even when the site is closed. It can make the app work offline, send notifications, and so on.
With Walmart, we can create a service worker that caches site resources in the background even before the user needs them. There’re multiple ways to do this; the concrete one depends on the Walmart infrastructure. A good example of one approach is available in the GoogleChrome repo.
With service workers, we also get the ability to send notifications to customers! This should be used with caution – or we can annoy them – but this can increase engagement too. Good examples of notifications are “The product you saved for later got a discount” or “John Ford replied to your question about iPhone 8”.
There’s still a room for further optimizations. Here’re some things that might also help – but we need to confirm that on the real app:
Using the local storage for caching large dependencies. The local storage seems to be several times faster than the HTTP cache. We might store large dependencies in the local storage to load them quicker:
Improving the time to first byte. Occasionally, the server spends too much time serving static resources. See the long green bars? That’s the time spent waiting for the server:
These delays are non-deterministic – I’ve seen them pretty often during the analysis, but they keep happening with different resources every time – so this might be a network issue. Still, I’ve noticed them in WebPageTest results too.
Enabling Brotli compression. When you download a text resource from a server, the server would usually compress it with GZip and serve the compressed version. The browser will decompress it later, once received. This compression makes the text several times smaller.
Apart from GZip, there’s also Brotli – a pretty new compression algorithm which compresses text 15-20% better. Right now, all text resources on the Walmart page are compressed with GZip. It makes sense to try Brotli to see if it improves the average download time.
To reduce the size of the images, Walmart compresses them on the server side. The client specifies the image dimensions it expects to receive, and the server sends the appropriate image:
In most cases, this is great. However, for the primary product images, this has a negative effect. When buying an expensive gadget, I often make a final decision by visiting the product page to see the gadget, to imagine how it looks in my hands. But when I come to the Walmart site, I see a low-quality image with compression artifacts:
So, to optimize the product page on the Walmart site, we can:
Fix the invisible text with font-display: optional
Use defer for the large JavaScript bundle
Load non-important code with webpack’s import
Remove polyfills in modern browsers
Decrease render-blocking CSS
Remove duplicated styles
Add a service worker for caching assets in background
With these tricks, we can render the product page earlier by at least 400-600 ms. If we apply similar improvements to the whole site, we can increase orders by at least 3–6% – and help Walmart earn more.
A few months ago, I had to build a full-stack app having only front-end experience. This is an overview of backend tools and practices if you discover yourself in the same situation.
I’m a front-end dev, but I need to write a full-stack app. What to do?#
To write an app, you’ll have to figure out three things: a programming language for the backend, a database to store data, and infrastructure.
Popular backend languages include Ruby, Python, JavaScript (Node.js), Java and PHP. If you’re a front-end developer, start with Node.js – things would be way easier.
I’ll cover databases and infrastructure in the next parts.
Node.js enables you to write servers in JavaScript. It’s a JavaScript engine similar to the one Google Chrome has. You pass it a JS file, and the engine runs it:
To launch a real server, you’ll need to call a specific method provided by Node.js (e.g, createServer() from the net module). In practice, a few people do this – almost everyone uses high-level wrappers.
I’ve heard a bit about Node.js and callbacks. What’s the deal with them?#
Callback is a function that you pass into other function. It’s called when a specific action completes. MDN docs
The primary thing you need to know about Node.js is that it performs all long actions (like reading data from a database) asynchronously – using callbacks. This means that to make a DB request, you don’t do this:
const data = sql.query('SELECT name from users');
console.log(data);
but do this instead:
sql.query('SELECT name from users', (error, data) => {
console.log(data);
});
Under the hood, Node.js sends a request to the database and immediately continues executing the code. And when the request completes, Node.js calls the callback and passes the data to it. This helps to write servers that handle lots of requests at the same time.
Option A: use promises instead of callbacks. You’ll still have to pass functions around – but you’ll write code without excessive nesting which often happens with callbacks:
Use util.promisify to adapt native callback-based APIs to promises
const fs = require('fs');
const data = fs.readFileSync('./text.txt');
However, avoid using them on real servers – or the server would die just from a few simultaneous clients. (These APIs are useful in console scripts though.)
In a real app, apart from an HTTP server, you’ll need to implement a few more things. Here’re the common solutions for them:
Authorization:Passport.js. Passport.js works with login-password pairs, social networks, OAuth and lots of other login strategies. A SaaS solution like Auth0 is also an option
Validating requests:express-validator. express-validator normalizes and validates REST data you recieve from the clients
Sending emails:Nodemailer. Nodemailer works with SMTP; it also has simplified settings for AWS Simple Email Service
Logging:loglevel, debug, or winston. I haven’t found a perfectly satisfying solution
Running the app in production:PM2. PM2 restarts an app in case of crashes, deploys a new version without a downtime, distributes the load to multiple instances and allows to deploy remotely
That’s it! The next part of the guide will introduce you into databases (specifically, into MySQL).
Earlier, I preferred the former way because it seemed “cleaner”. Now, I use the latter one – because it makes the function forward-compatible.
At some moment in the future, you might want to pass an additional parameter specifying the default return value – i.e., what the function should return if the deep property is absent. The only backwards-compatible way to do this is to pass this parameter as the last argument to the function, like this:
function getDeepProperty(
<object>,
<chain of fields>,
<default return value>
) {
// Retrieve the deep property and return it,
// or return the default value
}
And here comes the difference. If your function accepts an array, you just append a new argument, do a switch (arguments.length) inside your function body, and the old code keeps working:
function getDeepProperty(...args) {
if (args.length === 3) {
const [object, fields, defaultValue] = args;
// Execute the new behavior
} else {
const [object, fields] = args;
// Execute the old behavior
}
}
// The new API calls work great: a user passes three arguments,
// the function understands this and returns 'no'
getDeepProperty({}, ['i', 'love', 'designing', 'apis'], 'no');
// The old API calls keep working: a user passes two arguments,
// the function works as usual and e.g. throws an exception
getDeepProperty({}, ['i', 'love', 'designing', 'apis']);
But if your function accepts a variable number of arguments, you become unable to add an argument without breaking the old API users. The function can’t understand whether the last parameter is the default return value or a value in the chain of fields:
function getDeepProperty(object, ...fieldsAndDefaultValue) {
// So, is fieldsAndDefaultValue[fieldsAndDefaultValue.length - 1]
// a default value or just a field in a chain of fields?
// You can’t know
// Execute the new behavior
}
// The new API works great: the function returns 'no'
getDeepProperty({}, 'i', 'love', 'designing', 'apis', 'no');
// But the old API breaks: the function returns 'apis'
getDeepProperty({}, 'i', 'love', 'designing', 'apis');
So, here’s the rule:
When defining a function, prefer arrays over variable number of arguments