DEV Community

Cover image for Web performance case study
Ilia
Ilia

Posted on Edited on

Web performance case study

In this article I want to share my experience running a full optimization pass over a real project, with examples. In this case it's an SPA built with Vue 3, using Vuetify for part of the UI and Firebase for auth.

Some context: I work as a frontend developer at a company that mostly builds MVPs, but also develops and maintains long-lived products. MVPs in particular tend to accumulate problems, because refactoring and optimization get the bare minimum of time, and the same problems repeat across projects, since we often start from the same template.

Measuring performance

It all starts with metrics: to make something better, you first need to know what "before" looked like. I used three tools: PageSpeed, Lighthouse, and rollup-plugin-visualizer (our project runs on Vite).

You'd think PageSpeed and Lighthouse were the same thing, because they use the same engine, but in practice they report noticeably different Performance scores. In my case PageSpeed was consistently 20 points worse than Lighthouse. So which one do you believe?

That's the first problem: any Performance metric is always a simulation, never reality. Your website can work perfectly on real users' devices while the Lighthouse engine tells you the opposite. And the other way around.

But if your website is in production with enough traffic, PageSpeed can help: it has a section showing actual performance based on real user data.

Here's an example of that field data, while PageSpeed's own score for the same page sat somewhere around 45–55, rating every individual metric noticeably worse:

So I think it's worth looking at both PageSpeed and Lighthouse in DevTools. You get far more information that way, and a better sense that both of them only roughly estimate performance by simulating particular hardware and network conditions — affected by things like where the server physically sits.

The next tool is rollup-plugin-visualizer, which shows you visually what your bundle is made of, whether there's anything in there you don't want, and how much each piece weighs. I used it only because Lighthouse doesn't show the bundle tree for Vite (unlike Vue CLI with webpack).

So, here's where we start:

Optimizing fonts and icons

The biggest impact on load speed comes from whatever blocks rendering — scripts, styles, or fonts loaded in <head> that block the render until they've downloaded and executed.

In our case, among other things, fonts and the mdi (Material Design Icons) library had a noticeable effect.

Fonts were setup like this:

Let's see what's wrong with it:

  • Every possible weight from 100 to 900 is loaded, even though the site only uses 400, 500, 600, and 700.

  • There's no display=swap, so there's no way to show a fallback font while the main one loads (without it, users see no content at all until the font arrives).

  • The font loads from an external resource, Google Fonts, so it would be good to add preconnect to those origins and do the DNS/TCP/TLS handshakes ahead of time.

In our case the requests go to https://fonts.googleapis.com and https://fonts.gstatic.com. For the external origin the font itself is downloaded from (for Google Fonts that's https://fonts.gstatic.com), you need the crossorigin attribute — otherwise preconnect will only do the DNS part.

Incidentally, Google Fonts now offers all of this by default, but our project was apparently created long before that.

  • You can use preload to request the font at the very start of the critical rendering path, without waiting for the CSSOM to be built (by default the browser only downloads a font after it has built the CSSOM). Since the font isn't loaded after HTML and CSS parsing in that case, you may not even need the fallback font at all — which eliminates the layout shift you get when the two fonts don't match in size.

But be careful with preload: you're delaying the loading and parsing of other resources, and you're downloading the font unconditionally, even if it turns out not to be used on the page. So you need to measure its effect on load and render speed and weigh up whether it's worth it.

That gives us this:

The next optimization opportunity is a variable font. In short, variable fonts are "programmable" fonts you can tune flexibly along different axes without downloading each variant separately up front, the way you have to with regular fonts — which drives up both the number of requests and the amount of data transferred.

Google Fonts already has a variable version of Roboto called Roboto Flex, so let's use that:

For comparison, here's how many requests we had with regular Roboto:

And here's how many with Roboto Flex, now that we no longer need to download each weight separately:

Now for the icon library. It's connected like this:

What's the problem here? It downloads a single file containing every icon in the library, even though the vast majority of them are never used in the project.

The fix is fairly simple: instead of the CDN we pull in @mdi/js, which lets you import only the icons you actually use, so only those end up in the final bundle.

Here's what that looks like with Vuetify:

First results

So far we've only optimized how fonts and icons are loaded — but those were contributing a lot to Total Blocking Time. Let's measure Performance again and compare with where we started:


Before


After

We gained 10 points and noticeably reduced Total Blocking Time. But these are only the first steps, so let's keep going.

Eliminating layout shift

As I mentioned in the fonts section, layout shift is, in simple terms, content jumping around when data loads late or changes size and pushes the surrounding content out of the way. It sounds like something that has nothing to do with load speed, but since Lighthouse v10 this metric contributes a full 25% to the overall Performance score (Total Blocking Time is first, at 30%), so you can't ignore it.

Right now the metric is in the red at around 0.5, and the page load timeline looks like this:

You can see that in the first seconds of loading the user sees the site's footer, and only then does everything snap into place. That happens because between the header, the text banner, and the footer there's content that loads and renders dynamically. Until it arrives, the footer takes its place.

The simplest fix — though not always the best one, since the footer then gets hidden below the fold even when the main content doesn't fill the whole height — is to give the main content, which is almost always dynamic, a minimum height of, say, 100vh, so it occupies all the available screen height:

The second, more involved option is skeleton loaders: while data loads you show placeholders that mirror the structure and size of the real content, which also avoids the shift. For now we're going with the first option.

Let's check what effect this had on layout shift:

We brought it to 0, and the overall score is now 56, it's roughly a 10-point gain from one small change. And indeed, in the timeline at the bottom you can see the footer is no longer visible during the first seconds and everything sits where it should.

Firebase

The short version of the problem:

Almost 2 MB of JavaScript, taking up 70% of the bundle. The reason is simple: we're on Firebase 8, which doesn't support importing only the modules you need, unlike the current version 9.

We upgrade Firebase to 9, rewrite our code to match the API changes, look at the bundle again, and… we've gone from 1.78 MB to 310 KB. Still a lot, but 6× better.

But that's not all. Looking again at what gets loaded, we spot an iframe.js from Firebase weighing 279 KB. A bit of digging shows it's only needed for certain auth flows — for example signInWithPopup for Google or Facebook sign-in. We don't use that method, so all that code is dead weight sitting there just in case. Worth removing.

We started with this code, using getAuth(), which pulls in a lot of unused code:

We rewrite it to use initializeAuth() with only the imports we need, and leave a comment for whoever comes next warning them to be careful with the methods that needed that iframe.js:

We check the bundle one more time and there's nothing extraneous left:

Interim results

Last time we were at a Performance score of 56; after the Firebase work we're at 75:

Async components and routes

Looking at the final bundle, you can see that even though we're on the home page it contains other pages we don't need right now — NotFoundPage and TermsPage:

Vue Router supports dynamic imports out of the box, so pages we don't need get split into separate chunks and are requested only when required, instead of ending up in the main bundle. And yes, every other page was already wired up that way — these two were apparently just forgotten or overlooked.

We fix router.js, replacing the static imports with dynamic ones:

By the way, back when the project was on Vue CLI, a side effect of dynamic imports was that each chunk generated a prefetch — and with enough of them that hurt load speed, because the browser had to make a lot of extra requests for things it didn't need right then:

To prevent that, I configured webpack to skip prefetching:

If you want to keep prefetch for specific routes, you can enable it per route with a webpackPrefetch: true comment on the dynamic import: import(/* webpackPrefetch: true */ './somePage.vue')

Another problem with dynamic routes: if a component had logic tied to route information (say, checking $route.name to work out that we're on the login page and hide the footer), it could misbehave, because at the moment the condition was evaluated the route itself might not have loaded yet.

So in places like that we had to write conditions so that the component, or part of it, only rendered once the route had loaded and therefore had a name property, with the dependent logic running after that:

Now for async components. The idea is roughly the same as with routes: we can load them only when we need them. We only started doing this after moving to Vue 3, where it's quite simple and convenient.

For example, we have a global component for error notifications that sits unused most of the time and is only needed when an error actually occurs (the same goes for plenty of modals). A good candidate for an async component.

Let's rewrite its import using defineAsyncComponent:

By the way, the overall Performance score didn't move after all these changes, so there's nothing to show. But that's only because we removed a couple of lightweight routes and one small component — the bigger and more complex the project, the more you stand to gain from these Vue and Vue Router features.

Optimizing requests

On top of everything else, we can reduce both the number and the size of requests.

For example, on the pages listing athletes or coaches on the platform we were requesting 50 people per paginated page — even though in practice a user on a phone initially sees only 3–5 cards, and on desktop 10–15, depending on screen size. The obvious fix: cut the number of athletes and coaches requested to 15 per page:

Next: the site has a lot of images (competition photos and their thumbnails, user avatars), so for images that aren't visible on screen at load time, or that sit behind pagination, I add loading="lazy". We have a shared image component, so that's what I change:

I decided not to use a dedicated lazy-loading library, because support for the attribute is already close to 93%.

We could also add an SVG sprite to cut down the number of image requests, since besides the mdi icons we have a lot of custom ones. That task is still in progress, though.

Then we go through the project looking for chains of awaited requests where each one waits for the previous to finish, even though in some cases they don't depend on each other and could run in parallel with Promise.all():

Equally important: look at what the backend actually returns, and talk to the backend developers. Tell them when responses contain unused fields that bloat the payload, or suggest splitting large endpoints into several smaller ones where the situation allows.

Vue 3

Another improvement to the Performance score came from migrating the project from Vue 2 to Vue 3, so if you're still deciding whether to make that move, this can be one more argument in favor.

I found the numbers on the speed difference between Vue versions here. In practice, for us the difference in Performance was 10–15 points in Vue 3's favor. I'm sure everyone's results will differ, but that was our experience.

Redesign

Something outside my control contributed too: a redesign of the home page (keeping in mind that the Performance score on other pages obviously didn't change as a result). Instead of one large banner at the top, the page got several smaller, lighter images, which can also be loaded with loading="lazy". That was worth roughly another 10 points:

PWA

Finally, we converted our SPA into a Progressive Web App, which lets us cache almost any resource the site uses — CSS, JS, fonts, images. From then on they're served from cache without being re-downloaded until a new release goes out. And while this doesn't affect load speed on a first visit, it significantly speeds up loading and navigation on subsequent ones.

But caching is a hidden devil. It's important to be sure that when the site's code is updated, users always get the latest version and don't get stuck on a stale cache.

Wrapping up

Let's sum up and draw some conclusions from the optimization work:

  • We doubled Performance (from 30–40 to 70–80 points).

  • The most noticeable results came from loading fonts properly (including using a variable font), from the icon library, and from the modular Firebase 9.

  • Given how much fonts matter, discuss the topic with your designer up front so they can use variable fonts where possible.

  • Layout shift is an important factor in the Performance score. Bringing it to 0 gives a meaningful boost, and also improves the actual experience of using the site.

  • Vue 3 gave a decent gain, which is one more argument for migrating from Vue 2.

  • Async components, async routes, lazy loading, SVG sprites, request optimization — these give fairly limited results, but the payoff grows with the size and complexity of the project.

  • Sometimes changing the design is enough: making it lighter. But it's important to account for this from the start, sometimes by talking your designer out of things like very large images.

  • Going PWA can bring a significant Performance improvement through caching.

Top comments (0)