SPAs built with React or Vue can feel great, but they can also be painfully slow if you’re not careful. Getting SPA performance right directly affects whether users stick around and convert, because a slow site just bleeds engagement.
Key Takeaways
- Use code splitting with dynamic imports to slash your initial bundle size by up to 60% and improve first contentful paint.
- For React optimization, stop pointless re-renders in components and expensive calculations with React.memo, useCallback, and useMemo.
- In Vue, use the keep-alive component to cache inactive components and the v-once directive to render static stuff just one time.
- Set up server-side rendering (SSR) or static site generation (SSG) to get faster initial loads and better SEO, which can cut Time to Interactive by an average of 30%.
- Run Lighthouse audits in Chrome DevTools all the time. Your target for performance should be 90+ on both desktop and mobile.
1. Implement Aggressive Code Splitting
The biggest reason SPAs load slow is almost always the giant initial JavaScript bundle. A new user has to download your entire app’s code, including features they won’t even see on their first visit. Code splitting fixes this. You just break the app into smaller chunks that load as needed. In React, you’ll use React.lazy with Suspense, and in Vue, it’s dynamic imports with defineAsyncComponent.
Take a React app with an admin dashboard. There’s no reason a regular user visiting your homepage should have to download all the admin-specific code. You can just lazy-load the dashboard components with a dynamic import.
// Before:
import AdminDashboard from './AdminDashboard'; // After (with code splitting):
const AdminDashboard = React.lazy(() => import('./AdminDashboard')); // In your router:
<Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/admin" element={<AdminDashboard />} /> </Routes>
</Suspense>
It’s the same idea in Vue. If you have a huge component, say a big charting library, you can load it asynchronously so it doesn’t block the initial render.
// Before:
import BigChart from './components/BigChart.vue'; // After:
const BigChart = defineAsyncComponent(() => import('./components/BigChart.vue'));
This can slash your initial payload. I’ve been on projects where just doing this well dropped the initial JS bundle from 2MB to under 500KB. That kind of change has a massive, direct impact on your Time to First Byte and First Contentful Paint metrics.
Pro Tip: Analyze Your Bundle
You need to use a bundle analyzer. Something like Webpack Bundle Analyzer or Rollup Plugin Visualizer will give you a map of what’s actually in your JS bundles. It makes it obvious when you’ve got some huge, unnecessary dependency that you can either split out or load conditionally.
2. Optimize Component Re-renders in React
Pointless component re-renders are a classic performance killer in React. Even though React’s reconciliation is fast, it gets bogged down when components re-render for no reason (when their props or state are the same). The whole game of React optimization is about stopping these wasted renders.
Your main tools here are React.memo, useCallback, and useMemo.
React.memo: This is a higher-order component for memoizing functional components. When a component’s props haven’t changed since the last render, React will just skip re-rendering it and use the last result. It’s perfect for those “pure” components that always produce the same output for the same props.
// Before:
function MyComponent({ data }) { // ... rendering logic
} // After:
const MyComponent = React.memo(({ data }) => { // ... rendering logic
});
useCallback: This memoizes functions. If you pass a callback function down to a child component, a new function is created on every single parent render, which forces the child to re-render even if it’s wrapped inReact.memo. UsinguseCallbackgives you a stable function reference between renders, so it only gets recreated if its dependencies actually change.
// Before:
function ParentComponent() { const handleClick = () => console.log('Clicked'). Return <ChildComponent onClick={handleClick} />;
} // After:
function ParentComponent() { const handleClick = useCallback(() => console.log('Clicked'), []); // Empty dependency array means it's created once return <ChildComponent onClick={handleClick} />;
}
useMemo: This one memoizes values. If you’re doing some heavy lifting in a component, like a complex calculation,useMemowill cache the result. It only re-runs the calculation if the dependencies change, which saves you from re-running expensive logic on every single render.
// Before:
function MyComponent({ items }) { const expensiveCalculation = items.filter(item => item.isActive).map(item => item.value * 2). Return <div>{expensiveCalculation.join(', ')}</div>;
} // After:
function MyComponent({ items }) { const expensiveCalculation = useMemo(() => { return items.filter(item => item.isActive).map(item => item.value * 2); }, [items]); // Re-compute only when 'items' changes return <div>{expensiveCalculation.join(', ')}</div>;
}
Common Mistake: Over-optimization with Memoization
Don’t go crazy with memoization. It isn’t free, React still has to do the work of comparing props or dependencies. If that comparison is more expensive than just re-rendering the component, or if the component rarely re-renders anyway, you can actually make performance worse. So be smart about it and focus on components that re-render a lot or do expensive calculations.
3. Use Vue’s Built-in Performance Features
Vue has some great built-in tools for boosting Vue speed and cutting out wasted work. If you actually learn and use them, you’ll see a real improvement in your app’s responsiveness.
v-once: This directive tells Vue to render an element and its children just one time. On any later re-renders, Vue just skips right over it. It’s really handy for any static content that never needs to update when your data changes.
<template> <div> <h1 v-once>This title will only render once</h1> <p>Current time: {{ currentTime }}</p> </div>
</template>
<script setup>
import { ref } from 'vue'. Const currentTime = ref(new Date().toLocaleTimeString()). SetInterval(() => { currentTime.value = new Date().toLocaleTimeString();
}, 1000);
</script>
In the example above, that <h1> will never update, even though currentTime is changing every second, because it’s marked with v-once.
<keep-alive>: With this built-in component, you can cache component instances when you swap between them. So when you switch back to a component, it doesn’t have to be totally recreated from scratch. Its state is preserved, and you skip a costly re-initialization. This is a lifesaver for things like tabbed UIs or complex forms.
<template> <button @click="toggleComponent">Toggle</button> <keep-alive> <component :is="currentComponent"></component> </keep-alive>
</template>
<script setup>
import { ref, computed } from 'vue'. Import ComponentA from './ComponentA.vue'. Import ComponentB from './ComponentB.vue'. Const showA = ref(true). Const currentComponent = computed(() => (showA.value ? ComponentA : ComponentB)). Const toggleComponent = () => (showA.value = !showA.value);
</script>
When you toggle between ComponentA and ComponentB in this code, the one that’s hidden stays alive in memory, so it’s instantly ready when you switch back. I’ve used this on complex dashboards that users configure themselves and seen it cut the perceived load time in half.
- Lazy Loading Components: Just like with React, you can and should be lazy loading components in Vue with dynamic imports. We already covered this in the first section, but it’s so important for Vue devs that it’s worth saying again.
Pro Tip: Track Component Performance
Get familiar with the performance tab in the Vue DevTools. It’ll show you exactly which components are slow to render or updating too often. You can see the ones with high “Component render” times and use that information to figure out where v-once or <keep-alive> will give you the biggest bang for your buck.
4. Implement Server-Side Rendering (SSR) or Static Site Generation (SSG)
SPAs feel dynamic, but they start with a blank white page while the JavaScript downloads and runs. This is bad for users and bad for Search Engine Optimization (SEO). SSR and SSG fix this problem by rendering your app on the server first, so the browser gets a complete HTML page right away.
- Server-Side Rendering (SSR): With Server-Side Rendering, the server generates the initial HTML for your React or Vue app and sends that to the browser first. As soon as the JavaScript bundle finishes loading, the client-side app “hydrates” the static HTML and makes everything interactive. The result is a fast initial paint and much better SEO, since search engine crawlers get to see the fully rendered content. This is what frameworks like Next.js (for React) and Nuxt.js (for Vue) are built to do.
- Static Site Generation (SSG): With Static Site Generation, you render the entire application to static HTML files when you build your project. You can then throw these files on a CDN for ridiculously fast and secure delivery. SSG is perfect for sites where the content doesn’t change much, like a blog, documentation site, or a company’s marketing pages. Both Next.js and Nuxt.js can do this, and so can dedicated tools like Gatsby for React or VitePress for Vue.
So which one do you pick? It depends. If your content is super dynamic and personalized for each user, you’ll probably want SSR. But if the content is mostly static or only updates every now and then, SSG is going to give you the best possible performance.
On a recent e-commerce project, we moved the main product pages over to Next.js with SSR. The First Contentful Paint (FCP) went from a sluggish 3+ seconds down to under 800ms. That’s a huge win for how fast the site feels and it absolutely helped lower our bounce rates.
Common Mistake: Overcomplicating SSR/SSG for Dynamic Content
SSR and SSG are great, but they do add complexity. Think about a dashboard behind a login where data is changing every second. Does it need SSR? Probably not. A standard client-side rendered app is simpler to build and will be just as fast after that initial login and load. Don’t try to shoehorn SSR or SSG into a project where the data patterns just don’t make sense for it.
5. Optimize Images and Other Media
Often the heaviest part of your page isn’t the code, it’s the images and videos. If you don’t optimize your media, your SPA performance will be terrible no matter how clean your JavaScript is. Getting this right is non-negotiable for any web app.
- Image Compression: You have to compress your images before they go live. Use a tool like Squoosh.app or TinyPNG, or even better, automate it with a Webpack or Vite plugin. You can get huge file size reductions without any visible quality loss. Also, start using modern formats like WebP or AVIF. They compress way better than old-school JPEGs and PNGs. As Google points out in one of their developer articles, you can expect WebP to be 25-34% smaller than an equivalent JPEG.
- Lazy Loading Images: For any images that are off-screen when the page loads, you should lazy-load them. Modern browsers make this incredibly easy with the native
loading="lazy"attribute on your<img>tags, which is supported almost everywhere now. If you need to support ancient browsers or want more fine-grained control, you can still use a small JS library.
<img src="placeholder.jpg" data-src="actual-image.jpg" alt="Description" class="lazyload" />
This just means the browser won’t download off-screen images until the user actually scrolls near them, which cuts down on both the initial page load time and data usage.
- Responsive Images: You should also be using responsive images with the
<picture>element and thesrcsetattribute. This lets you serve different image files based on the user’s screen size. It stops a mobile user from having to download a massive desktop-sized image that the browser just shrinks down anyway.
<picture> <source srcset="large.webp 1200w, medium.webp 800w, small.webp 400w" type="image/webp"> <img src="fallback.jpg" alt="Description" loading="lazy">
</picture>
- Video Optimization: For videos, make sure you’re using the right formats (like MP4 with H.264 or WebM with VP9/AV1) and that they’re compressed. If video is a big part of your app, look into a streaming service that can do adaptive bitrate streaming for you. And please, use autoplaying videos carefully and always make sure they’re muted by default.
Pro Tip: CDN for Assets
Put your static assets on a Content Delivery Network (CDN), especially images and videos. A CDN copies your files to servers all over the world and serves them to users from whichever server is physically closest to them. This dramatically cuts latency and makes your assets load way faster. All the major cloud providers have one (AWS CloudFront, Google Cloud CDN, Cloudflare).
6. Implement Efficient Data Fetching and State Management
The way your SPA gets and handles its data is a huge factor in how fast it feels. If you’re inefficient, you get annoying loading spinners, way too many network requests, and a UI that feels like it’s stuck in mud. Good state management just means the data is there when it needs to be, without creating extra work for the browser.
- Batching and Debouncing API Calls: Stop making tons of tiny API requests. If you can, batch related requests together into one bigger call. And for anything that triggers an API call from user input, like a search-as-you-type feature, you need to debounce it. Just wait until the user stops typing for 300-500ms before you fire the request.
- Caching Data: You should be caching data on the client side, especially data that doesn’t change often. This could be as simple as just holding it in local state, or you can use a library that handles it for you like React Query (now TanStack Query) or Vue’s Pinia/Vuex. Think about a list of product categories, fetch it once, cache it, and stop re-fetching it on every single page navigation.
- Optimistic UI Updates: When a user does something like “like” a post or add an item to their cart, update the UI instantly, as if the action already succeeded. Don’t wait for the server to respond. This makes the app feel incredibly fast. If the server call does end up failing, you just roll back the UI change. This pattern makes a huge difference in how responsive your app feels.
- Efficient State Management Libraries: React’s Context API and Vue’s built-in `provide` are fine for simple state, but once your app gets bigger, you’ll want a real state management library like Redux or Zustand for React, or Pinia/Vuex for Vue. They give you a structured way to handle global state, which makes it easier to see how data is flowing and helps you avoid “prop-drilling” down through tons of components (which can cause its own re-rendering headaches if you’re not careful).
On a big financial dashboard I refactored, we were making an average of 12 API calls every time a page loaded. By batching some requests and caching static lookup data on the client, we got that down to just 4. That simple change cut more than 1.5 seconds off the page load time.
Common Mistake: Over-fetching or Under-fetching Data
Watch out for over-fetching (requesting way more data than the component needs) and under-fetching (making a waterfall of sequential requests to get all the data). You’re trying to find a balance. If your backend supports it, something like GraphQL is great for this because it lets the client ask for exactly the data it needs in one shot.
7. Monitor and Audit Performance Regularly
Performance isn’t a one-and-done job;