Keeping Vue Apps Lean

Single Page Applications (SPAs) deliver rich, interactive experiences with real-time data, but that power comes at a cost: heavy bundles and sluggish performance. Building Windscope, a data-heavy Vue app for wind farm operators, pushed us to find every viable optimization. Most techniques below work with any framework and bundler, though examples use Vue and Parcel.

Start With The Build

Before hand-tuning anything, ensure your bundler is doing its job. Parcel performs tree shaking, minifies output, and supports Gzip or Brotli compression out of the box, plus scope hoisting for more efficient minification. To see the impact, run Parcel's build with --no-optimize and --no-scope-hoist flags: the result is a 510kB bundle roughly 5 times larger than the optimized version.

Even with a smaller bundle, the browser must still parse and compile JavaScript, which slows perceived performance. Reducing the amount of code shipped is only the first step to minimizing this work.

Write Components For Tree Shaking

Vue 3's Composition API lets you import only the specific functions you use instead of pulling in the entire package. That makes code more tree-shakable and minification-friendly while also enabling reusable composables. The Composition API was backported to Vue 2.7, and an official plugin exists for older versions.

Import Less From Dependencies

Large libraries like D3 contain many modules you may never touch. On Bundlephobia, our app uses less than half of D3's available modules, and not even all functions within those. Importing only what's needed is an easy, high-impact win:

// Previous:
import * as d3 from 'd3'

// Instead:
import { selectAll } from 'd3-selection'

Split Code By Route and Component

Dynamic imports shift module loading from page startup to the moment a feature is used. Rather than statically importing a heavy dependency like AWS Amplify's Auth method at the top of a file:

import { Auth } from '@aws-amplify/auth'

const user = Auth.currentAuthenticatedUser()

import it exactly where needed:

import('@aws-amplify/auth').then(({ Auth }) => {
    const user = Auth.currentAuthenticatedUser()
})

The bundler then splits it into a separate chunk downloaded only when required, and the browser can cache it since dependencies change less often than app code.

Vue Router offers the same benefit. Lazy load route components so their code — including associated dependencies — loads only when a user navigates to that route:

// Previously:
import Home from "../routes/Home.vue";
import About = "../routes/About.vue";

// Lazyload the route components instead:
const Home = () => import("../routes/Home.vue");
const About = () => import("../routes/About.vue");

const routes = [
  {
    name: "home",
    path: "/",
    component: Home,
  },
  {
    name: "about",
    path: "/about",
    component: About,
  },
];

Individual components can also be lazy loaded using defineAsyncComponent:

const KPIComponent = defineAsyncComponent(() => import('../components/KPI.vue))

Loading and error states can be provided while larger components load:

const KPIComponent = defineAsyncComponent({
  loader: () => import('../components/KPI.vue),
  loadingComponent: Loader,
  errorComponent: Error,
  delay: 200,
  timeout: 5000,
});

Parallelize API Requests

Windscope fetches substantial data for visualization, and initial prototype requests per route could leave users staring at a spinner for up to 10 seconds. Splitting the API into several endpoints, one per widget, spreads requests in parallel. Overall response time may increase, but parts of the page render earlier, so the app feels usable sooner. Errors become localized rather than taking down the entire page.

In the example on the right, the user can interact with some components while others are still requesting data. The page on the left has to wait for a large data response before it can be rendered and become interactive.

Combine this with async components so a component only loads after its data request succeeds:

<template>
  <div>
    <component :is="KPIComponent" :data="data"></component>
  </div>
</template>

<script>
import {
  defineComponent,
  ref,
  defineAsyncComponent,
} from "vue";
import Loader from "./Loader";
import Error from "./Error";

export default defineComponent({
    components: { Loader, Error },

    setup() {
        const data = ref(null);

        const loadComponent = () => {
          return fetch('https://api.npoint.io/ec46e59905dc0011b7f4')
            .then((response) => response.json())
            .then((response) => (data.value = response))
            .then(() => import("../components/KPI.vue") // Import the component
            .catch((e) => console.error(e));
        };

        const KPIComponent = defineAsyncComponent({
          loader: loadComponent,
          loadingComponent: Loader,
          errorComponent: Error,
          delay: 200,
          timeout: 5000,
        });

        return { data, KPIComponent };
    }
}

A higher order component called WidgetLoader centralizes this flow (full code in the sample repository). The pattern extends to any interaction — for instance, Windscope loads a map component and all its dependencies only when the user clicks the Map tab, a technique known as Import on interaction.

Lazy loading CSS follows the same approach: import dependencies inside a component's <style> block so styles load alongside the component:

// In MapView.vue
<style>
@import "../../node_modules/leaflet/dist/leaflet.css";

.map-wrapper {
  aspect-ratio: 16 / 9;
}
</style>

Rendering components at different times can make the page janky. Mitigate layout shift by setting an aspect ratio on widgets, approximating the eventual component size and accepting a configurable prop with a default:

// WidgetLoader.vue
<template>
  <div class="widget" :style="{ 'aspect-ratio': loading ? aspectRatio : '' }">
    <component :is="AsyncComponent" :data="data"></component>
  </div>
</template>

<script>
import { defineComponent, ref, onBeforeMount, onBeforeUnmount } from "vue";
import Loader from "./Loader";
import Error from "./Error";

export default defineComponent({
  components: { Loader, Error },

  props: {
    aspectRatio: {
      type: String,
      default: "5 / 3", // define a default value
    },
    url: String,
    importFunction: Function,
  },

  setup(props) {
      const data = ref(null);
      const loading = ref(true);

        const loadComponent = () => {
          return fetch(url)
            .then((response) => response.json())
            .then((response) => (data.value = response))
            .then(importFunction
            .catch((e) => console.error(e))
            .finally(() => (loading.value = false)); // Set the loading state to false
        };

    /* ...Rest of the component code */

    return { data, aspectRatio, loading };
  },
});
</script>

Abort Unneeded Requests

When users navigate away from a page with many in-flight requests, those shouldn't keep running and degrade the experience. The AbortController interface stops them cleanly. Create the controller in the setup function and pass its signal to the fetch request:

setup(props) {
    const controller = new AbortController();

    const loadComponent = () => {
      return fetch(url, { signal: controller.signal })
        .then((response) => response.json())
        .then((response) => (data.value = response))
        .then(importFunction)
        .catch((e) => console.error(e))
        .finally(() => (loading.value = false));
        };
}

Then abort on unmount with Vue's onBeforeUnmount:

onBeforeUnmount(() => controller.abort());

Navigating away mid-request will log aborted request errors in the console — expected confirmation that resources are being freed.

Caching Data for Instant Back-Navigation

So far, the optimizations prevent duplicate requests on the first visit. But once someone travels deeper into the app and returns to a prior view, every component mounts from scratch. The screen flashes back to a loading state, and the same network payloads are fetched again even if nothing changed server-side.

The web has a standard answer for this: the stale-while-revalidate cache strategy. When a resource is requested, the browser can serve an old copy from cache immediately while re-validating it against the network in the background. The same logic applies, with a little help, to Vue component state.

The SWRV library brings that pattern into a composable. The setup mirrors a manual fetch: import the library, define an async function that performs the network call, and hand that function to useSWRV as its second argument. Omitting the second argument makes SWRV default to the Fetch API. Because this example relies on an AbortController to cancel in-flight work, a custom function is required.

import useSWRV from "swrv";
// In setup()
const { url, importFunction } = props;

const controller = new AbortController();

const fetchData = () => {
  return fetch(url, { signal: controller.signal })
    .then((response) => response.json())
    .then((response) => (data.value = response))
    .catch((e) => (error.value = e));
};

const { data, isValidating, error } = useSWRV(url, fetchData);

With SWRV managing request state, the async component no longer needs its loadingComponent and errorComponent properties. Instead, the Loader and Error views are placed directly in the template and conditionally rendered based on the values returned from useSWRV. The composed isValidating flag describes whether a request is in flight for initial data or for revalidation.

// In setup()
const AsyncComponent = defineAsyncComponent({
  loader: importFunction,
  delay: 200,
  timeout: 5000,
});
<template>
  <div>
    <Loader v-if="isValidating && !data"></Loader>
    <Error v-else-if="error" :errorMessage="error.message"></Error>
    <component :is="AsyncComponent" :data="data" v-else></component>
  </div>
</template>

<script>
import {
  defineComponent,
  defineAsyncComponent,
} from "vue";
import useSWRV from "swrv";

export default defineComponent({
  components: {
    Error,
    Loader,
  },

  props: {
    url: String,
    importFunction: Function,
  },

  setup(props) {
    const { url, importFunction } = props;

    const controller = new AbortController();

    const fetchData = () => {
      return fetch(url, { signal: controller.signal })
        .then((response) => response.json())
        .then((response) => (data.value = response))
        .catch((e) => (error.value = e));
    };

    const { data, isValidating, error } = useSWRV(url, fetchData);

    const AsyncComponent = defineAsyncComponent({
      loader: importFunction,
      delay: 200,
      timeout: 5000,
    });

    onBeforeUnmount(() => controller.abort());

    return {
      AsyncComponent,
      isValidating,
      data,
      error,
    };
  },
});
</script>

Any component that needs remote data can reuse this logic. Extracting the whole call and its returned state into a dedicated composable keeps the consuming component’s setup small and makes the pattern easily applicable across views.

// composables/lazyFetch.js
import { onBeforeUnmount } from "vue";
import useSWRV from "swrv";

export function useLazyFetch(url) {
  const controller = new AbortController();

  const fetchData = () => {
    return fetch(url, { signal: controller.signal })
      .then((response) => response.json())
      .then((response) => (data.value = response))
      .catch((e) => (error.value = e));
  };

  const { data, isValidating, error } = useSWRV(url, fetchData);

  onBeforeUnmount(() => controller.abort());

  return {
    isValidating,
    data,
    error,
  };
}
// WidgetLoader.vue
<script>
import { defineComponent, defineAsyncComponent, computed } from "vue";
import Loader from "./Loader";
import Error from "./Error";
import { useLazyFetch } from "../composables/lazyFetch";

export default defineComponent({
  components: {
    Error,
    Loader,
  },

  props: {
    aspectRatio: {
      type: String,
      default: "5 / 3",
    },
    url: String,
    importFunction: Function,
  },

  setup(props) {
    const { aspectRatio, url, importFunction } = props;
    const { data, isValidating, error } = useLazyFetch(url);

    const AsyncComponent = defineAsyncComponent({
      loader: importFunction,
      delay: 200,
      timeout: 5000,
    });

    return {
      aspectRatio,
      AsyncComponent,
      isValidating,
      data,
      error,
    };
  },
});
</script>

Signaling Fresh Data in the Background

During revalidation, the old data is still on screen, so the user might not realize the app is checking for updates. A small indicator—for instance, a spinner in the corner of the component—communicates that a background refresh is happening while the existing content remains interactive. Pairing that with Vue’s built-in Transition component on the data container smooths the visual switch when new results arrive.

<template>
  <div
    class="widget"
    :style="{ 'aspect-ratio': isValidating && !data ? aspectRatio : '' }"
  >
    <Loader v-if="isValidating && !data"></Loader>
    <Error v-else-if="error" :errorMessage="error.message"></Error>
    <Transition>
        <component :is="AsyncComponent" :data="data" v-else></component>
    </Transition>

    <!--Indicator if data is updating-->
    <Loader
      v-if="isValidating && data"
      text=""
    ></Loader>
  </div>
</template>

The Payoff of a Performance Budget

A fast single-page application is not merely pleasant; it expands its reach to users on slow connections and low-end devices. The techniques covered here—dynamic imports, async components, debouncing search queries, caching, and stale-while-revalidate—form a toolbox for treating speed as a feature. Taken together, they are the difference between a front-end that feels responsive and one that fights the user. SPAs can deliver rich experiences, but they demand deliberate engineering to avoid becoming their own worst bottleneck.