Polling and Retrying

Poll with a chained timeout, pause it when the tab is hidden, back off on failure, and retry only what is safe to repeat.

Syntaxtimer = setTimeout(poll, delay) // after each response, cleared in onUnmounted

Polling means asking the server the same question repeatedly until the answer changes: is this upload processed, has this job finished. It is the plainest way to track server-side progress and needs no extra infrastructure.

Chain the timer, do not interval it

Use setTimeout scheduled after each response arrives, not setInterval. An interval fires on the clock regardless of whether the previous request has come back, so a slow server leaves several requests in flight that can land out of order. A chained timeout keeps exactly one request alive at a time.

Stop when nobody is looking

A hidden tab still runs your timers. Read document.hidden before each request and skip it, then listen for visibilitychange to poll immediately when the tab comes back - which also means the user sees fresh data the moment they return rather than after the next interval. Always remove the listener and clear the timer in onUnmounted, or a closed view keeps polling.

Backoff and what is safe to retry

On failure, wait longer each time: double the delay, cap it, and add jitter so every client in the fleet does not retry in the same millisecond. Retry only requests that are safe to repeat. GET, PUT and DELETE are idempotent by definition; a repeated POST can create a second order. Retry transient statuses - 408, 429, 500, 502, 503, 504 - and network errors. Never retry 400, 401, 403, 404 or 422: the same request will fail the same way.

Example

Example · vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

type Status = 'pending' | 'done' | 'failed'

const props = defineProps<{ jobId: string }>()

const status = ref<Status>('pending')
let timer: ReturnType<typeof setTimeout> | undefined

async function poll(): Promise<void> {
  // Hidden tab: stop the chain. visibilitychange restarts it.
  if (document.hidden) return

  try {
    const response = await fetch(`/api/jobs/${props.jobId}`)
    const job = (await response.json()) as { status: Status }
    status.value = job.status
  } catch {
    // Keep the last known status and try again on the next tick.
  }

  if (status.value === 'pending') {
    timer = setTimeout(poll, 5000)
  }
}

function onVisibility(): void {
  if (!document.hidden && status.value === 'pending') {
    clearTimeout(timer)
    void poll()
  }
}

onMounted(() => {
  void poll()
  document.addEventListener('visibilitychange', onVisibility)
})

onUnmounted(() => {
  clearTimeout(timer)
  document.removeEventListener('visibilitychange', onVisibility)
})
</script>

<template>
  <p>Job status: {{ status }}</p>
</template>

<style scoped>
p {
  margin: 0;
}
</style>

When to use it

  • Show an export job's progress until the download link appears.
  • Refresh an unread count every thirty seconds, but only while the tab is visible.
  • Retry a failed GET on a flaky connection with increasing delays instead of a tight loop.

More examples

Exponential backoff with jitter

Taking the random value as a parameter with a default makes the function testable: pass 0 and 1 to pin the bounds. The cap matters as much as the doubling - without it, attempt 15 schedules a retry nine hours out, and the user is long gone. Jitter is not decoration either; it is what stops a thousand tabs from retrying together and keeping a recovering server down.

Example · ts
// src/lib/backoff.ts

// attempt is 0 for the first retry. The result is between half and all of
// the doubling base, so retries from different clients spread out.
export function backoffDelay(attempt: number, random = Math.random()): number {
  const base = Math.min(1000 * 2 ** attempt, 30000)
  return Math.round(base / 2 + (base / 2) * random)
}

// backoffDelay(0, 0)    -> 500
// backoffDelay(0, 1)    -> 1000
// backoffDelay(3, 0.5)  -> 6000
// backoffDelay(10, 1)   -> 30000   (capped)
// Output: 500 1000 6000 30000

Deciding whether a failure is retryable

Putting this one decision in a named function stops it from being re-guessed in each call site. Keep it pessimistic about POST: a 503 from a load balancer can still mean the request reached the application and succeeded. If your API accepts an idempotency key on writes, send one and you can relax this rule for those endpoints - and record that in the API layer so it is obvious which calls are safe.

Example · ts
// src/lib/retryable.ts
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])

// status is null for a network-level failure, where the request may or may not
// have reached the server.
export function isRetryable(method: string, status: number | null): boolean {
  if (status === null) {
    return method !== 'POST'
  }
  if (!RETRYABLE_STATUS.has(status)) {
    return false
  }
  // 429 means the request was rejected before any work was done, so repeating
  // a POST is safe in that one case.
  return method !== 'POST' || status === 429
}

// isRetryable('GET', 503)     -> true
// isRetryable('GET', 404)     -> false
// isRetryable('POST', 503)    -> false
// isRetryable('POST', 429)    -> true
// isRetryable('DELETE', null) -> true
// Output: true false false true true

The polling loop as a composable

Worth extracting once a second screen needs polling, because the part people forget is the teardown, and here it is written once. The timer keeps ticking while hidden but sends nothing, which trades a negligible timer for simpler code; browsers throttle background timers heavily anyway. onScopeDispose rather than onUnmounted means the composable also cleans up correctly if it is ever used inside a manual effect scope.

Example · ts
// src/composables/usePolling.ts
import { onMounted, onScopeDispose } from 'vue'

export function usePolling(task: () => Promise<void>, interval: number): void {
  let timer: ReturnType<typeof setTimeout> | undefined

  async function tick(): Promise<void> {
    if (!document.hidden) {
      await task()
    }
    timer = setTimeout(tick, interval)
  }

  function onVisibility(): void {
    if (!document.hidden) {
      clearTimeout(timer)
      void tick()
    }
  }

  onMounted(() => {
    void tick()
    document.addEventListener('visibilitychange', onVisibility)
  })

  onScopeDispose(() => {
    clearTimeout(timer)
    document.removeEventListener('visibilitychange', onVisibility)
  })
}

// usePolling(loadUnreadCount, 30000)
// -> one request every 30s while visible, none while hidden,
//    one immediately when the tab is focused again

Discussion

  • Be the first to comment on this lesson.