DEV Community

Cover image for Why Your Loading Spinner Flashes, and the Two Numbers That Fix It in Vue
Navid jaberi
Navid jaberi

Posted on

Why Your Loading Spinner Flashes, and the Two Numbers That Fix It in Vue

You click a button, the request takes 80ms, and a spinner appears for a single frame. It doesn't look fast. It looks broken.

Some context first. I started vue-smart-loading-kit because of skeletons. In my Vue projects I used Vuetify's skeleton loader, but I couldn't get the colors and animation I wanted, and its presets didn't match a lot of my real layouts. I kept hand-writing CSS to make a skeleton look like the actual component.

At some point I asked myself: why not turn the real layout itself into its skeleton, with CSS only and without touching the DOM? That became v-skeleton, which I wrote about in my previous article. A reader there spotted a bug with SVG icons in the comments, and I shipped the fix in 0.3.4 the same day.

But a skeleton that looks right is only half of a good loading state. The other half is timing.

The naive version

<Spinner v-if="loading" />
Enter fullscreen mode Exit fullscreen mode

The loader follows loading exactly. That's the problem: users don't care how long loading was true. They care about what they saw. An 80ms request shows a spinner for 80ms, which is one flash.

Adding a delay creates a new problem

The obvious fix is to wait a little before showing the loader. Say 200ms. Now the 80ms request shows nothing, which is what we want.

But take a request that lasts 250ms. The spinner appears at 200ms and disappears at 250ms: it's on screen for 50ms. The flash is back, just later. I call that a blink.

Two numbers

When I built SmartLoader, the timing came down to two numbers:

  • delay (200ms): a load must last this long before any loader appears. Fast loads show nothing at all.
  • minDuration (500ms): once a loader is visible, it stays at least this long. It never just blinks.

One more rule: if loading starts again while the loader is still up, the loader simply stays.

Here's what that does to a few loads:

The load takes What the user sees
120ms No loader. The content just appears.
300ms Loader from 200ms to 700ms
2s Loader from 200ms to 2s

Timeline with a 200ms delay and 500ms minimum duration: a 120ms load shows no loader, a 300ms load shows it from 200ms to 700ms, and a 2s load shows it from 200ms to 2s

A simplified version of the idea

This isn't the package's code, just the core logic in a few lines:

import { ref, watch, onScopeDispose, type Ref } from 'vue'

export function useDelayedLoadingSimple(
  loading: Ref<boolean>,
  delay = 200,
  minDuration = 500,
) {
  const show = ref(false)
  let delayTimer: ReturnType<typeof setTimeout> | undefined
  let minTimer: ReturnType<typeof setTimeout> | undefined
  let shownAt = 0

  watch(
    loading,
    (isLoading) => {
      if (isLoading) {
        clearTimeout(minTimer) // restarted: keep the loader if it's up
        if (show.value) return
        delayTimer = setTimeout(() => {
          show.value = true
          shownAt = Date.now()
        }, delay)
      } else {
        clearTimeout(delayTimer) // done before the delay: never show
        if (!show.value) return
        const remaining = minDuration - (Date.now() - shownAt)
        minTimer = setTimeout(() => (show.value = false), Math.max(0, remaining))
      }
    },
    { immediate: true },
  )

  onScopeDispose(() => {
    clearTimeout(delayTimer)
    clearTimeout(minTimer)
  })

  return show
}
Enter fullscreen mode Exit fullscreen mode

In the package this is useDelayedLoading. loading and both options can be refs or getters:

import { useDelayedLoading } from 'vue-smart-loading-kit'

const showLoader = useDelayedLoading(loading, { delay: 200, minDuration: 500 })
Enter fullscreen mode Exit fullscreen mode

Timing is only half of it

A real loading state needs more than a boolean, so SmartLoader wraps the content and also:

  • keeps the content's height while the loader stands in for it, so the page below doesn't jump;
  • sets aria-busy="true" as soon as loading starts, and announces a label to screen readers while the loader is visible;
  • shows an error with a Try again button that emits retry. During a retry the error stays on screen until the loader appears, so nothing flashes in between.
<SmartLoader :loading="loading" :error="error" @retry="loadUsers">
  <UserList :users="users" />
</SmartLoader>
Enter fullscreen mode Exit fullscreen mode

It has three modes:

Mode While loading Good for
replace (default) Swaps the content for a skeleton First load, no data yet
overlay Keeps the content dimmed and inert, with a spinner on top Refreshing data, submitting forms
skeletonize Turns the content into its own skeleton with v-skeleton First load with placeholder data

Trade-offs, and what else is out there

minDuration isn't free. If a request takes 250ms, the data is ready at 250ms but the loader stays until 700ms. I think a steady half second reads better than a 50ms blink, but it's a trade-off, which is why both numbers are props you can change.

The idea isn't new either. In React, spin-delay does the same thing with different defaults (500ms delay, 200ms minimum). There's no single right pair of numbers. It depends on how fast your API usually is and how heavy your loader looks.

A few related tools solve other parts of the problem:

  • CSS animation-delay can hide a loader for the first 200ms, but it can't keep one on screen after loading turns false, because the element is removed right away.
  • Vue's <Suspense> (still experimental) has a timeout prop that delays the fallback while content is being replaced, but there's no minimum duration, and it only covers async components.
  • Keeping the old data on screen while new data loads (stale-while-revalidate, or SmartLoader's overlay mode) is often better than any loader when you're refreshing.

When you can avoid a loader entirely, do that. SmartLoader is for the cases where you can't.

Takeaway

If you remember one thing: don't bind a loader directly to loading. Put a short delay in front of it and a minimum duration behind it.

The package is on npm as vue-smart-loading-kit, and the live demo lets you play with both numbers.

What delay do you use for loaders in your apps, or do you show them right away?

Top comments (1)

Collapse
 
mercerk17 profile image
Kai Mercer •

The delay and minDuration split is a clean way to explain it. One thing I'd test is layout shift: for the first 200ms of a slow load nothing is shown, so if the loader and the content take different amounts of space, the page jumps when either one appears. A min-height on the container (or letting the skeleton hold the space and only delaying the spinner) avoids that. A fake-timer test for the 120ms, 250ms and restart-while-visible cases from your table would also keep the timing from drifting.