ShipNative
PricingBlogFAQContact
Build my app
ShipNative
PricingBlogFAQContact
Build my app
GuideOctober 2026 · 8 min read · By Saurabh Singh

React Native Countdown Timer: Accurate, Background-Safe

A countdown timer looks like the simplest component in the app and ships with the most bugs. It drifts by a few seconds over ten minutes. It freezes when the user switches apps and comes back showing a time that is already in the past. It flashes 0:00 a second early. All three come from the same mistake: counting down a number instead of reading a clock. This guide builds the version that works, a useCountdown hook you can paste, then puts a Reanimated ring on it and a notification behind it.

Why setInterval minus one is wrong

The first version everyone writes:

// Do not ship this.
const [seconds, setSeconds] = useState(600);
useEffect(() => {
  const id = setInterval(() => setSeconds((s) => s - 1), 1000);
  return () => clearInterval(id);
}, []);

It has two independent problems. First, setInterval(fn, 1000) does not fire every 1000ms. It fires every 1000ms plus whatever the JavaScript thread was busy with, and on a phone that is routinely 10 to 50ms per tick during animation or list scrolling. Over a ten-minute timer that is several seconds of drift, all in the same direction.

Second, when the app is backgrounded, iOS suspends the JavaScript thread within seconds and Android throttles timers hard. The interval stops firing. When the user comes back two minutes later the counter has lost two minutes and cheerfully continues from where it left off. For a cooking timer or a workout rest that is not a rounding error, it is a broken feature.

The fix: count from a timestamp

Store when the countdown ends. Every tick, ask the clock how far away that is. The interval becomes a refresh rate for the display, not the source of truth, so neither drift nor a paused thread can make the value wrong. Here is the whole hook, including pause, resume, and the app-state subscription:

// useCountdown.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState } from 'react-native';

type Options = { onComplete?: () => void; tickMs?: number };

export function useCountdown(durationMs: number, { onComplete, tickMs = 250 }: Options = {}) {
  // endsAt is the source of truth while running. null = paused/stopped.
  const [endsAt, setEndsAt] = useState<number | null>(null);
  // remaining is only for display.
  const [remaining, setRemaining] = useState(durationMs);
  const completed = useRef(false);

  const recompute = useCallback(() => {
    if (endsAt === null) return;
    const left = Math.max(0, endsAt - Date.now());
    setRemaining(left);
    if (left === 0 && !completed.current) {
      completed.current = true;
      setEndsAt(null);
      onComplete?.();
    }
  }, [endsAt, onComplete]);

  // Tick while running.
  useEffect(() => {
    if (endsAt === null) return;
    recompute();
    const id = setInterval(recompute, tickMs);
    return () => clearInterval(id);
  }, [endsAt, recompute, tickMs]);

  // The moment the app returns to the foreground, read the clock again.
  useEffect(() => {
    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active') recompute();
    });
    return () => sub.remove();
  }, [recompute]);

  const start = useCallback(() => {
    completed.current = false;
    setEndsAt(Date.now() + remaining);
  }, [remaining]);

  const pause = useCallback(() => {
    if (endsAt === null) return;
    setRemaining(Math.max(0, endsAt - Date.now()));
    setEndsAt(null);
  }, [endsAt]);

  const reset = useCallback(() => {
    completed.current = false;
    setEndsAt(null);
    setRemaining(durationMs);
  }, [durationMs]);

  return { remaining, isRunning: endsAt !== null, endsAt, start, pause, reset };
}

A 250ms tick is deliberate. It costs almost nothing and means the displayed second changes within a quarter second of the real one, so two timers on screen never appear out of step. Rendering four times a second is fine as long as the component that re-renders is small, which is the next point.

Formatting without the jitter

export function formatCountdown(ms: number) {
  // ceil, not floor: show 0:01 until the moment it is actually 0.
  const total = Math.ceil(ms / 1000);
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  const pad = (n: number) => String(n).padStart(2, '0');
  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
import { memo } from 'react';
import { Text } from 'react-native';

// Keep the frequently re-rendering part tiny and memoised.
export const CountdownDigits = memo(function CountdownDigits({ ms }: { ms: number }) {
  return (
    <Text
      style={{ fontSize: 56, fontWeight: '600', fontVariant: ['tabular-nums'] }}
      accessibilityLiveRegion="polite"
      accessibilityLabel={`${formatCountdown(ms)} remaining`}
    >
      {formatCountdown(ms)}
    </Text>
  );
});

fontVariant: ['tabular-nums'] is the detail that makes a timer look finished. Proportional digits have different widths, so a 1 flipping to a 7 shifts every character after it and the whole number wobbles once a second. Tabular numerals are fixed-width and the digits stay put. It works with the system fonts on both platforms and with any custom font that ships tabular figures.

A progress ring with Reanimated

The ring should not be driven by the 250ms tick; that gives you a stepping animation. Instead, animate a shared value from the current fraction to zero over the remaining time, once, when the countdown starts, and let Reanimated run it on the UI thread at 60fps:

import { useEffect } from 'react';
import Svg, { Circle } from 'react-native-svg';
import Animated, { Easing, useAnimatedProps, useSharedValue, withTiming, cancelAnimation } from 'react-native-reanimated';

const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const SIZE = 220, STROKE = 12, R = (SIZE - STROKE) / 2, C = 2 * Math.PI * R;

export function CountdownRing({ remaining, duration, isRunning }:
  { remaining: number; duration: number; isRunning: boolean }) {
  const progress = useSharedValue(remaining / duration); // 1 = full, 0 = done

  useEffect(() => {
    if (isRunning) {
      // One animation for the whole remaining time. Linear, so it matches the clock.
      progress.value = withTiming(0, { duration: remaining, easing: Easing.linear });
    } else {
      cancelAnimation(progress);
      progress.value = remaining / duration; // snap to the paused value
    }
  }, [isRunning]); // intentionally not on every tick

  const animatedProps = useAnimatedProps(() => ({
    strokeDashoffset: C * (1 - progress.value),
  }));

  return (
    <Svg width={SIZE} height={SIZE}>
      <Circle cx={SIZE / 2} cy={SIZE / 2} r={R} stroke="rgba(255,255,255,0.12)" strokeWidth={STROKE} fill="none" />
      <AnimatedCircle
        cx={SIZE / 2} cy={SIZE / 2} r={R}
        stroke="#f97316" strokeWidth={STROKE} fill="none" strokeLinecap="round"
        strokeDasharray={C}
        animatedProps={animatedProps}
        transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
      />
    </Svg>
  );
}

When the app comes back from the background the Reanimated timing animation will also have been paused, so the ring can be behind the digits. The simple fix is to re-run the effect on resume: include the endsAt value from the hook in the dependency array and restart withTiming from the fresh fraction. If you would rather not own the SVG, react-native-countdown-circle-timer draws the same ring; keep the hook above as the source of truth and pass it the remaining seconds.

When it ends and the app is closed

No JavaScript of yours runs reliably while the app is backgrounded, so onComplete will fire late, on resume, if the user has left. The dependable signal is a local notification scheduled for the end time the moment the timer starts:

import * as Notifications from 'expo-notifications';

export async function scheduleEndNotification(endsAt: number) {
  return Notifications.scheduleNotificationAsync({
    content: { title: 'Time is up', body: 'Your timer has finished.', sound: true },
    trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(endsAt) },
  });
}

// On pause/reset/complete-in-foreground:
export function cancelEndNotification(id: string) {
  return Notifications.cancelScheduledNotificationAsync(id);
}

Schedule on start, cancel on pause or reset, and cancel again in onComplete so the user is not notified about a timer they watched finish. Permissions, the Android channel, and testing on a real device are covered in the Expo notifications guide; local notifications use the same setup minus the push token.

Libraries, if you want them

OptionWhat it gives youPick it forThe cost
Your own useCountdown hookTimestamp-based logic, resume handling, formattingZero dependencies, exactly your behaviourYou draw the UI
react-native-countdown-circle-timerAnimated circular ring with Reanimated, color stopsThe ring UI without writing SVG mathOpinionated API; verify its resume behaviour in your app
react-timer-hookuseTimer / useStopwatch hooksFamiliar if you used it on the webLittle beyond 40 lines of your own; check background handling
expo-notificationsLocal notification at the end timeThe only reliable "it finished" signal when the app is closedPermission prompt; test on a device, not the simulator

The test for any timer library is the one from the first section: background the app for a minute and come back. If the display is wrong, the library decrements state instead of reading the clock, and no amount of styling will save it.

Skip the wiring

A timestamp-based hook, an AppState listener, tabular digits, a UI-thread ring, a local notification. It is a solved problem that still eats a day in every fitness, cooking, focus, and auction app that gets built. ShipNative generates a real Expo app from a description, with patterns like this one already correct, previews it on your phone, and hands you the full React Native project. Describe the timer screen you want and edit the hook like any other file you wrote.

Frequently Asked Questions

How do I make a countdown timer in React Native?

Store the moment the countdown should end as a timestamp, then run a setInterval that recomputes remaining = endsAt - Date.now() every tick and renders it. Do not decrement a seconds counter on each tick; intervals are not exact and the error accumulates. Clear the interval in the effect cleanup and when remaining reaches zero. The useCountdown hook in this guide is a complete version.

Why does my React Native timer stop when the app goes to the background?

JavaScript timers are paused or heavily throttled when an app is backgrounded on both iOS and Android, so a timer that counts down by one each tick will be wrong when the user comes back. Timers that compute remaining time from an end timestamp are unaffected: when the app returns to the foreground, the next tick reads the real clock and shows the correct value. Listen to AppState to force an immediate recompute on resume.

How do I notify the user when a countdown finishes while the app is closed?

Schedule a local notification for the end time with expo-notifications when the countdown starts, and cancel it if the user stops the timer early. No JavaScript runs reliably in the background on either platform, so the notification is the only dependable way to signal completion when the app is not in the foreground.

What is the best countdown timer library for React Native?

For a circular progress countdown, react-native-countdown-circle-timer renders a smooth ring with Reanimated and is the most used. For logic only, a small hook you own is usually better than a dependency; libraries like react-timer-hook work but add little over 40 lines of code. Avoid libraries that count down by decrementing state, because they drift and freeze in the background.

How do I format a countdown as mm:ss or hh:mm:ss in React Native?

Work from total remaining milliseconds: seconds = Math.ceil(ms / 1000), then derive hours, minutes and seconds with Math.floor and the modulo operator, and pad each part with String(n).padStart(2, "0"). Use Math.ceil rather than Math.floor so the display shows 0:01 until the moment it hits zero instead of showing 0:00 a second early.

→

Push Notifications in Expo

Local notifications are the same library; this covers permissions and setup.

Read guide →
→

React Native Animations with Reanimated

The shared-value patterns the countdown ring uses.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code, free to start.

Build with ShipNative
ShipNative

Build mobile apps with AI. Describe it, try it on your phone, own the code.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide
© 2026 ShipNative
PrivacyTermsAboutFAQPricingDiscordShipNative - Featured on Startup FameFeatured on Twelve ToolsFeatured on TinyShelfShipNative - Launched on Fazier