Vue is an awesome frontend technology, but there are a lot of little things you need to look out for when it comes to performance in your application. And when we talk about performance, we’re talking about real world performance: browser tab usage, memory usage, responsiveness, etc.
1. Use less Refs
AI seems to have a hard time distinguishing when to use a ref, but the reality is: if the variable is not going to cause a UI mutation, then you don’t need a ref. And if you can avoid it, definitely don’t use a ref on a complex object that may self-mutate and trigger re-renders unnecessarily.
If you’re working with refs, or even objects that are watched, it’s helpful to reduce those objects into a set of the things that you actually need. A good example of this is just reducing complex objects from libraries into simple, flat objects.
const complexObjectsFromLibrary = ref(MyService.getInstance().getComplexObject());
This is a prime case where you would want to actually parse these out into a more simple, UI ready object. We’ve seen this first hand with some chat libraries where the complex object contains references (or even copies) of the root library. This inefficient construction enables wonderful features on the pure js level:
complexObjectsFromLibrary[0].addReaction(emoji);
The problem is that you’re passing this whole object as a ref. If you use a shallowRef, you may lose the emojis nested within the object that self mutates as well. Passing these around, at the scale of 10’s or 100’s, will cause memory to grow exponentially during mounting and unmounting. This will sneak up on you in an SPA where the first few routes feel okay, then all of a sudden performance starts to tank the longer the app is open.
Smoosh it down, post process it into a simple object with simple primitives.
2. Bundle Sizes
One often overlooked problem is bundle sizes.
npx vite-bundle-visualizer
Running this command will create a table where you can visually see how big your packages are, because installed third-party libraries can be quite large. One of the biggest culprits is UI libraries.
A common theme is using libraries that offer polyfill functionality by default, or things like twMerge which cause the main app.js to swell in size. Vite does support async components, but that won’t do you any good with a giant UI library sitting in your Layout.vue that cannot be trimmed. These libraries will also bring in JS libraries to help position elements like popovers, which is now included across all major browsers for the most part.
Another thing this can help you identify is redundant dependencies. Lets say a downstream dependency requires Axios but so does your main application - you need to pick one version and stick with it. This will dedup what would otherwise load both.
How to approach bundle size optimization
The main thing you need to understand when looking at bundle sizes is simple: which component is the page that contains what I want to display? Everything else - forms, tabs, etc: those can be lazy loaded. Async components, intersection observers, and other tools can help you dynamically load chunks of the UI when ready.
Your goal should be to get all bundles as small as possible, but focus on app.js first because that is going to be loaded by all visitors.
3. Efficient DOM tree
When you have a larger Vue application, you tend to have a lot more information on the screen at one time. Take a chat app as an example. When rendering elements onto the screen, there’s no reason to render every single chat message on earth into the DOM at the same time or on initial load. This will significantly decrease performance, and your browser is going to struggle to load all these elements at one time.
Using a virtual scroller to prevent off-screen messages from rendering is a great way to save performance.
In reality, you only need to show maybe five messages on the screen at one time. Once you reach a certain threshold, you can transition the elements onto or off the screen using CSS.
4. Reusable Overlays
Connected to the above, ensuring that components for things such as popovers that may need to be rendered in the hundreds are placed into the same layer. Think of a dropdown menu sitting inside of an interface with 100 elements: we’re probably looking at 100 teleports in a trenchcoat.
In our applications that are resource constrained we’ve deployed solutions like Pinia connected to a central store which kept track of which item was highlighted and re-used the same vue component. The only real compute happening is switching out a pretty simple flag to know if we’re the owner of the message.
For what it’s worth, we did try just nesting these into components directly and the performance was so bad, our app grew to a few gigabytes. I think discord has that problem right now, in fact. These little tricks are hard to pin down - but the basic gist of it is: if you need to draw it 100 times, then put it into a layer and leverage a shared state.
5. v-if vs. v-show
A few things to call out here: v-if unmounts. If the code in your onMounted() is taxing, it’s going to keep firing over and over when users switch tabs. An intersection observer or a state for the initial view (a real view), and controlling the visibility with v-show is a great way to handle this.
An example could be a tab for comments or history - you can load them lazily when the user actually clicks the tab. However, you don’t want to load them over and over when the user clicks out, and back in. This is a good use case: v-show and trap the initial load behind a flag.
6. Use Singleton Services, Provide, Inject
This is kind of simple but I see it skipped a lot - if you don’t need reactivity and just need an object to interact with, please use a service. Use a singleton so you can share it where needed, or use provide, inject pattern if you are exclusively using this service with Vue and not tapping into it from other areas (such as a Pinia store).
Wrapping Up
There is a lot of specific details here and they may not apply to your app, but these are some of the things we’ve ran into in the wild. Each solution has to be a little bit different, but with some of these approaches we’ve gotten our apps to an extremely small footprint across a wide variety of devices that are resource constrained. Real world data is important, but just as important is using your app like a maniac and making sure javascripts garbage collection is working, CPU usage is not going haywire, and your app is able to remain calm under pressure.