
Three.js and Image Optimization in Nuxt - A Practical Guide
How to use Three.js in Nuxt without killing your Lighthouse score. Covers lazy-loading WebGL, code splitting Three.js bundles, NuxtImg optimization, and Core Web Vitals tuning.
Three.js and Image Optimization in Nuxt
Combining Three.js with a content-heavy site can hurt Core Web Vitals if you don't optimize. Here's a practical approach we use: defer WebGL, split the Three.js bundle, and serve images in modern formats with the right loading strategy.
The Challenge
- Three.js is large and can block the main thread, hurting First Input Delay (FID) and Total Blocking Time (TBT).
- Hero and gallery images can blow up Largest Contentful Paint (LCP) and bandwidth if they're not sized, lazy-loaded, or converted to WebP/AVIF.
We wanted a starfield background (Three.js) and a big hero image without sacrificing a 99 Lighthouse performance score. These are the changes that made it possible.
1. Defer Three.js Until the Browser Is Idle
Initializing WebGL on mount can create a long task and block user input. We delay setup until the browser is idle (or after a short timeout).
// components/StarryBackground.vue
onMounted(() => {
const start = () => {
initStars();
window.addEventListener("resize", handleResize);
};
if (typeof requestIdleCallback !== "undefined") {
requestIdleCallback(start, { timeout: 2000 });
} else {
setTimeout(start, 0);
}
});
requestIdleCallbackruns when the main thread is free, so the first paint and critical JS aren't delayed.timeout: 2000ensures we still run after 2s if the browser never reports idle.- Fallback
setTimeout(start, 0)for environments that don't supportrequestIdleCallback.
Result: Three.js no longer sits on the critical path; LCP and FID stay good.
2. Put Three.js in Its Own Chunk
Bundling Three.js with the main app bundle would make the initial JS payload huge. We split it so it loads in parallel and doesn't block parsing.
// nuxt.config.ts
vite: {
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules/three")) return "three";
},
},
},
},
},
- The three chunk loads when the component that imports it is needed.
- Main bundle stays smaller; Three.js is cached separately and doesn't block first load.
3. WebGL and Renderer Tweaks
Small renderer settings that help performance and battery:
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
powerPreference: "high-performance"hints the GPU to prefer performance (where supported).- Capping
devicePixelRatioat 2 avoids rendering at 3x on high-DPI screens, which can be expensive for particle-heavy scenes.
We also debounce resize (e.g. 100ms) and dispose geometry, material, texture, and renderer in onUnmounted to avoid leaks and extra work.
4. Hero Image: Above-the-Fold Priority
The hero image is LCP candidate, so we treat it as critical:
<NuxtImg
src="/images/avatar.png"
alt="WonderCoder Avatar"
class="hero-bg-image w-full h-full object-cover lg:object-contain lg:object-right"
format="webp"
quality="80"
sizes="sm:100vw md:100vw lg:60vw"
preload
fetchpriority="high"
loading="eager"
/>
format="webp"(and AVIF in config) keeps size down.sizesensures the right width is requested per viewport.preload+fetchpriority="high"+loading="eager"make the browser fetch and decode the hero image early so LCP is fast.
We combine this with responsive CSS (e.g. object-fit, mask, and max width) so the image doesn't overflow and layout stays stable.
5. Below-the-Fold: Lazy and Responsive
Cards and lists use lazy loading and responsive sizes so we don't load full-size images for thumbnails:
<NuxtImg
:src="project.image"
:alt="project.name"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
loading="lazy"
format="webp"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
loading="lazy"defers load until near the viewport.sizesmatches layout (e.g. 1 column on mobile, 2 on tablet, 3 on desktop) so the server (or Nuxt Image) can serve an appropriately sized file.
Same idea applies to product cards, blog cards, and carousels: lazy + webp + sensible sizes.
6. Global Image and Cache Settings
In nuxt.config.ts we set defaults and long-lived cache for assets:
image: {
domains: ["img.youtube.com"],
format: ["avif", "webp"],
quality: 75,
screens: { sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 },
},
And in Nitro route rules:
"/_nuxt/**": { headers: { "Cache-Control": "public, max-age=31536000, immutable" } },
"/images/**": { headers: { "Cache-Control": "public, max-age=31536000" } },
- AVIF/WebP and quality 75 keep a good balance between size and visual quality.
- Immutable for hashed JS/CSS and long cache for
/images/**improve repeat visits.
Summary
| Area | What we did |
|---|---|
| Three.js | Defer init with requestIdleCallback; put Three in a separate chunk; cap pixel ratio; dispose on unmount. |
| Hero image | NuxtImg with WebP, sizes, preload, fetchpriority="high", loading="eager". |
| Cards / lists | NuxtImg with loading="lazy", format="webp", and layout-matched sizes. |
| Global | Nuxt Image config (formats, quality, screens) and strong cache headers for static assets. |
With this, we keep a 99 Lighthouse performance score while using a Three.js starfield and large imagery. You can reuse the same patterns: idle deferral for heavy JS, code splitting for Three.js, and NuxtImg with the right priority and sizes for each image.
