Screenshot of the Afitpilot homepage showing a hero section with instant background video rendering after performance optimizations in a Nuxt SSR app.

How We Fixed Slow Hero Video Rendering in Our Nuxt SSR App

Every page on Afitpilot opens with a full-bleed looping background video. It sets the tone immediately: athletes in motion, intent, energy.

But for too long, users were greeted with something else entirely.

A black rectangle.

On cold loads, especially over mobile connections, the hero section stayed empty for seconds before anything appeared. No motion. No visual anchor. Just a dark void where the first impression should have been.

This is how we diagnosed the problem, why it happened, and how we shipped a fix that delivers an immediate visual on every page load.


The symptoms

On a cold load over a typical 4G connection:

  • The hero section rendered as an empty black block for 2–4 seconds
  • The gradient background appeared, but the <video> element was not in the DOM yet
  • On mobile, both desktop and mobile videos were downloaded simultaneously, even though only one was ever displayed

The experience was worst on slower connections. The page technically loaded, but visually it felt broken.


The root cause

Afitpilot is built with Nuxt 3 using server-side rendering.

The hero video was gated behind two reactive conditions:

<template v-if="!prefersReducedMotion && isInView">
  <video class="hidden md:block ...">
    <source src="/home_page_desktop.mp4" />
  </video>
  <video class="block md:hidden ...">
    <source src="/home_page_mobile.mp4" />
  </video>
</template>

Both prefersReducedMotion and isInView are client-only values.

During SSR:

  • prefersReducedMotion defaults to true
  • isInView defaults to false

That means the <video> element does not exist in the initial HTML at all.

It only mounts after hydration and after the IntersectionObserver fires, which can take several seconds on real networks. Until then, the hero is empty.

There was a second, more subtle problem.

Even when the video did mount, both variants mounted at once:

  • Desktop video (3.8 MB)
  • Mobile video (4.8 MB)

CSS (hidden md:block / block md:hidden) hid the wrong one visually, but the browser still downloaded both files.

On the homepage alone, that meant over 8 MB fetched for a 5-second loop.


The fix: three changes

1. Single-variant rendering with useBreakpoint

We replaced CSS-based responsive hiding with a Vue composable that uses matchMedia to determine the viewport at runtime.

export function useBreakpoint() {
  const isDesktop = ref(true) // SSR default: desktop

  onMounted(() => {
    const mq = window.matchMedia('(min-width: 768px)')
    isDesktop.value = mq.matches
    mq.addEventListener('change', (e) => {
      isDesktop.value = e.matches
    })
  })

  return { isDesktop }
}

The template now mounts exactly one video:

<template v-if="isDesktop">
  <video src="/home_page_desktop.mp4" ... />
</template>
<template v-else>
  <video src="/home_page_mobile.mp4" ... />
</template>

No CSS tricks. No hidden elements. One <video> tag, one download.

Mobile users fetch the mobile video. Desktop users fetch the desktop video. Nothing else.


2. Poster-first loading

The hero now renders a static poster image immediately, with the video loading behind it.

<img
  src="/home_page_desktop.webp"
  class="hero-poster"
  :class="{ 'hero-poster--hidden': heroVideoReady }"
  loading="eager"
  fetchpriority="high"
/>

<video
  class="hero-video"
  :class="{ 'hero-video--hidden': !heroVideoReady }"
  @canplay="heroVideoReady = true"
  autoplay
  muted
  loop
  playsinline
  preload="auto"
>
  <source src="/home_page_desktop.mp4" type="video/mp4" />
</video>

When the video fires canplay, we cross-fade from poster to video with a 0.3s opacity transition.

The result:

  • Instant visual feedback on first paint
  • Smooth, deliberate transition
  • No jarring pop-in

A .webp poster is a fraction of the video size. It is cheap insurance for perceived performance.


3. CSS-only reduced motion

Previously, reduced-motion handling happened in JavaScript. That meant the video had to mount before we could decide to hide it.

Now it is pure CSS:

@media (prefers-reduced-motion: reduce) {
  .hero-video {
    display: none !important;
  }
}

If a user prefers reduced motion:

  • The video is never displayed
  • The video is never downloaded
  • The poster remains as a static hero background

No JS checks. No hydration edge cases. No wasted bytes.


What we removed

This mattered as much as what we added.

  • useMotionPreference composable
    Replaced by a CSS media query
  • useInView composable
    IntersectionObserver was the primary cause of delayed rendering. Above-the-fold heroes should not be lazy-loaded.
  • preload="metadata"
    Changed to preload="auto" so the browser starts fetching immediately
  • Double video elements
    Each page now mounts one and only one <video>

The result

MetricBeforeAfter
Time to first visual2–4 secondsImmediate (poster image)
Videos downloaded per page2 (desktop + mobile)1
Bytes wasted on mobile~3.8 MB0
Reduced motion behaviorJS-gated, video still mountsCSS-only, video never loads
SSR HTML contains hero visualNoYes (poster <img>)

The hero now shows meaningful content on the very first paint. The video fades in when ready. Mobile users download roughly half the data they used to. Reduced-motion users get a clean static image with zero unnecessary network requests.


Lessons learned

Do not gate above-the-fold content on client-only state.
IntersectionObserver is for content below the fold. Heroes belong in the initial HTML.

Use v-if to prevent downloads, not CSS.
display: none, hidden md:block, and similar utilities do not stop the browser from fetching media. Removing the element from the DOM does.

Poster images are cheap insurance.
A few hundred kilobytes buys you instant perceived performance and a smoother experience.

Prefer CSS over JavaScript for media queries.
prefers-reduced-motion in CSS is simpler, more reliable, and avoids SSR hydration timing issues entirely.

For deep thinkers, creators, and curious minds. One post. Zero noise.

We don’t spam! Read our privacy policy for more info.