First Paint
What has to happen before a Vue app appears, which of it you can preload or defer, and why a spinner is not a performance fix.
const Chat = defineAsyncComponent(() => import('./components/Chat.vue'))In a client-rendered app nothing is on screen until JavaScript runs. The browser parses index.html, finds the module script, downloads the entry chunk and everything it imports, parses it, runs createApp(App).mount('#app'), renders, and only then paints.
Shortening the chain
Two things matter: how many bytes sit on that path, and how many round trips it takes to discover them. Lazy routes and defineAsyncComponent take code off the path. Vite handles the round trips: it generates <link rel="modulepreload"> directives for the entry chunk and its direct imports in the built HTML, so the browser fetches them in parallel instead of finding them one import at a time. Fonts you preload yourself, because the browser cannot know about a font until the CSS that references it has arrived.
defineAsyncComponent(() => import('./Chat.vue')) is shorthand for defineAsyncComponent({ loader: () => import('./Chat.vue') }); the same call, with room for loadingComponent, delay (200ms by default) and timeout when you need them.
An app shell
Markup placed inside <div id="app"> paints with the HTML, before any JavaScript. When a component with a template mounts into that container, it replaces the existing DOM nodes there. A header and a skeleton give the user something true to look at in the meantime.
A spinner is not a fix
A spinner changes what the wait looks like, not how long it lasts, and it adds a render of its own. If first paint genuinely matters, render the HTML on the server.
Example
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { RouterView } from 'vue-router'
// Shorthand for defineAsyncComponent({ loader: () => import(...) }).
const LiveChat = defineAsyncComponent(
() => import('./components/LiveChat.vue')
)
</script>
<template>
<div>
<header>
<h1>Reports</h1>
</header>
<router-view />
<live-chat />
</div>
</template>
<style scoped>
header {
padding: 1rem 0;
}
div {
max-width: 60rem;
margin: 0 auto;
}
</style>When to use it
- A dashboard that shows a white screen for two seconds on a mid-range phone
- A marketing page where search ranking depends on how fast content appears
- Deciding what belongs in the entry chunk and what can be fetched later
- Replacing a full-page spinner with a skeleton that matches the real layout
More examples
An app shell and a preloaded font
This HTML paints before the entry chunk has finished downloading, so the page is not blank while the app boots. app.mount('#app') replaces everything inside the container, so the shell needs no cleanup code. The font preload starts the download in parallel with the JavaScript rather than after the stylesheet is parsed; crossorigin is required on font preloads or the browser fetches the file twice.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reports</title>
<link
rel="preload"
href="/fonts/inter-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<div id="app">
<header><h1>Reports</h1></header>
<p>Loading your reports</p>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Keeping routes off the first download
The home view is imported statically because it is almost always the first thing rendered; waiting for a second request to show it would make first paint worse. The other two are functions returning a dynamic import, so each becomes its own chunk that is fetched when someone navigates there. This is the single biggest lever on first paint in a routed app, and it needs no build configuration: Vite splits on the dynamic import.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'home', component: HomeView },
{
path: '/reports',
name: 'reports',
component: () => import('../views/ReportsView.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue')
}
]
})
export default routerMeasuring the paint you are trying to improve
Largest Contentful Paint is reported repeatedly as the browser finds bigger candidates, so the last entry is the one that counts. buffered: true replays entries that fired before this code ran, which matters because the first paint happens early. Log it while you work on startup, and send it to your analytics endpoint in production so you learn what real connections see rather than what your laptop does.
// src/perf/lcp.ts
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const last = entries[entries.length - 1]
console.log(`largest contentful paint: ${last.startTime.toFixed(0)}ms`)
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
// Output: largest contentful paint: 1240ms
Discussion