The spinner you show for 200ms is worse than no spinner

Compose gives you progress indicators; it doesn't tell you when to show one. Duration decides: under 300ms show nothing, over a second show progress, and never let an indicator replace content that could be a skeleton.

6 min read
androidcomposekotlinmaterial3

Day 41 — The spinner you show for 200ms is worse than no spinner

Day 41 of 100. Progress indicators are two components and a lot of judgment, and the judgment is what separates an app that feels fast from one that is fast and doesn't.

The symptom

A list that flickers on every open:

when (val s = state) {
    is UiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
        CircularProgressIndicator()
    }
    is UiState.Ready -> MailList(s.items)
}

The data is cached, so it arrives in about 80 milliseconds. What the user sees is a white screen, a spinner that appears and disappears too fast to read, then content — a visible flash on every single open.

It looks broken in a way that's hard to name, and it's strictly worse than showing nothing for 80ms.

Why the obvious fix fails

The obvious fix is a minimum display time:

var showSpinner by remember { mutableStateOf(false) }
LaunchedEffect(loading) {
    if (loading) { showSpinner = true; delay(500) }      // hold it for at least 500ms
    showSpinner = false
}

This makes an 80ms load take 500ms. You've fixed the flicker by making the app slower, which trades a visual artefact for real latency — and users notice latency.

The problem isn't how long the spinner shows. It's that a spinner was shown at all for an operation that fast.

Duration decides the feedback: nothing, skeleton, indeterminate, or determinate progress

The actual mechanism

The decision is about expected duration, and the thresholds are well established:

Duration Show
< 300ms Nothing. The eye reads it as instant.
300ms – 1s A skeleton or a subtle indicator in place
1s – 10s An indeterminate indicator, positioned where the content will be
> 10s, known total A determinate indicator with a percentage
> 10s, unknown Indeterminate plus a description of what's happening

The delay-before-showing pattern is the one worth implementing, and it's the opposite of the minimum-display fix:

val showSpinner by produceState(false, loading) {
    value = if (loading) { delay(300); true } else false
}

if (showSpinner) CircularProgressIndicator()

A fast load finishes before the 300ms elapses, so the spinner never appears at all and there is no flash. A slow load shows it, 300ms late, which nobody perceives as a delay because they were already waiting.

Determinate needs an honest number

LinearProgressIndicator(progress = { uploaded.toFloat() / total })

Note progress is a lambda — Day 10's deferred read, so a value changing on every chunk doesn't invalidate the composition that contains it.

The rule for determinate progress: use it only when you can compute a real fraction. A progress bar driven by a guess — three fake steps, or a timer — is worse than indeterminate, because it makes a promise about remaining time that it then breaks. A bar that sits at 90% for thirty seconds is the canonical example.

If the total is unknown until part-way through, start indeterminate and switch when you know. Both components accept that transition without remounting.

Skeletons beat spinners for content

For a list or a detail screen whose shape you know in advance, a skeleton is strictly better than a spinner:

@Composable
fun MailRowSkeleton() {
    Row(Modifier.padding(16.dp)) {
        Box(Modifier.size(40.dp).clip(CircleShape).shimmer())
        Column(Modifier.padding(start = 12.dp)) {
            Box(Modifier.height(14.dp).fillMaxWidth(0.4f).clip(shape).shimmer())
            Spacer(Modifier.height(6.dp))
            Box(Modifier.height(12.dp).fillMaxWidth(0.8f).clip(shape).shimmer())
        }
    }
}

Two reasons, and the second matters more. It communicates the shape of what's coming, so the eye is already positioned. And because the skeleton occupies the same space as the real content, nothing shifts when the data arrives — no layout jump, which is the other half of why loading states feel bad.

A centred spinner guarantees a jump, since the content that replaces it is never spinner- shaped.

Refresh, and the state that belongs to the gesture

Pull-to-refresh is a container plus a state object, following Day 17's pattern:

val pullState = rememberPullToRefreshState()

PullToRefreshBox(
    isRefreshing = uiState.isRefreshing,
    onRefresh = viewModel::refresh,
    state = pullState,
) {
    MailList(uiState.items)
}

The important part is that isRefreshing comes from the ViewModel while pullState — the gesture's own animation state — stays in the composition. That's the Day 17 split exactly: screen state above, UI element state local.

Refreshing should never replace the list with a spinner. The user is looking at content they asked to update; removing it to show a loading indicator loses their place for no benefit.

The error state that everyone forgets

Loading has three outcomes, not two, and the third is usually added late:

when (val s = state) {
    Loading -> Skeleton()
    is Ready -> MailList(s.items)
    is Failed -> ErrorState(message = s.message, onRetry = viewModel::retry)
}

An error state needs a retry affordance and a message that says what failed. "Something went wrong" tells the user nothing and gives them nothing to do. Modelling all three as a sealed hierarchy — Day 12 — is what makes the compiler ask about the third one.

How to prove it

The flicker is measurable rather than a matter of taste:

@Test fun fastLoadShowsNoSpinner() = runComposeUiTest {
    mainClock.autoAdvance = false
    setContent { MailScreen(viewModel) }
    viewModel.emitLoading()
    mainClock.advanceTimeBy(100)
    onNodeWithTag("spinner").assertDoesNotExist()      // still hidden at 100ms
    viewModel.emitReady(sampleMail)
    mainClock.advanceTimeBy(300)
    onNodeWithTag("spinner").assertDoesNotExist()      // never appeared
}

mainClock.autoAdvance = false is what makes timing assertions possible at all, and it's the technique for any test involving a delay.

On device, the check is to throttle the network to 3G in the emulator and open every screen. Anything that flashes has no delay threshold; anything that jumps has a spinner where a skeleton belongs.

Accessibility of a busy state

An indicator is a visual signal, and by default it says nothing to a screen reader. Two things make the wait legible to everyone.

Give a determinate indicator its value in semantics, so the announcement is a number rather than "progress bar":

LinearProgressIndicator(
    progress = { fraction },
    modifier = Modifier.semantics {
        progressBarRangeInfo = ProgressBarRangeInfo(fraction, 0f..1f)
    },
)

And announce the transition, since a screen-reader user gets no visual cue that content replaced a skeleton:

LaunchedEffect(state) {
    if (state is Ready) view.announceForAccessibility("Loaded ${state.items.size} messages")
}

Both are small, and both convert a loading state from a purely visual event into one the whole audience can follow.

What this generalizes to

The principle is feedback should match the wait. Showing progress for something instant adds noise; showing nothing for something slow makes the app look broken. Both are failures to model duration, and duration is a property of the operation rather than of the component.

The corollary is that loading UI can't be decided in a design system in the abstract. The component library gives you indicators; which one to show, and whether to show one at all, depends on how long the thing actually takes — which is a measurement, and worth taking.

The same reasoning explains why "add a loading spinner" is rarely the right ticket. The useful question is how long the operation takes at the 50th and 95th percentile, and the answer often removes the ticket entirely — or replaces it with caching work that makes the wait disappear rather than decorating it.

Tomorrow, Day 42: pickers — dates and times, and the state objects behind them.


Day 41 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Progress indicators.