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" />
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 |
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
}
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 })
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 alabelto 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>
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-delaycan hide a loader for the first 200ms, but it can't keep one on screen afterloadingturns false, because the element is removed right away. -
Vue's
<Suspense>(still experimental) has atimeoutprop 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
overlaymode) 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.
- GitHub: https://github.com/navidjaberi/vue-smart-loading-kit
- I write about Vue and frontend on LinkedIn
What delay do you use for loaders in your apps, or do you show them right away?

Top comments (1)
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.