Prevent React Native Timers from Stopping in Background: A Complete Guide

Published: (December 25, 2025 at 10:06 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

Building a Habit‑Tracking App with a Background Timer

The challenge:
When the user locks their phone or switches to another app, the JavaScript thread pauses and the timer stops. For a meditation or workout timer this is broken functionality – the user expects the timer to keep running and alert them when it’s done, even if the app isn’t in the foreground.

Solution: Use an Android Foreground Service via react-native-background-actions and synchronize it with a modern state‑management solution – Zustand.

Why a Foreground Service?

Mobile OSes aggressively suspend background JavaScript to save battery. A foreground service tells the OS:

“I’m doing something important that the user knows about – don’t kill me.”

It requires a persistent notification so the user can see that the app is still active.

The Stack

ComponentReason
React Native (Expo Dev Client or bare workflow)UI & cross‑platform code
ZustandSimple, hook‑free state store (crucial for background access)
react-native-background-actionsHandles Android foreground services

1️⃣ Install the library

npm install react-native-background-actions

2️⃣ Android Manifest configuration

Add the required permissions inside the <manifest> tag and declare the service inside the <application> tag of android/app/src/main/AndroidManifest.xml:

<!-- Add your permissions and service declaration here -->

Tip: This is where most developers get stuck. Forgetting either the permissions or the service declaration will cause a crash when you try to start the background task.

3️⃣ Accessing Zustand from a background thread

Important: You cannot use React hooks (useState, useEffect, custom hooks like useHabitStore()) inside the background task loop. Hooks rely on the React render cycle, which isn’t running in the service.

Zustand gives us an escape hatch: imperative access via store.getState(). This lets the background task read and mutate state directly.

4️⃣ The background task definition

The task function must be defined outside any React component.

// timerBackgroundTask.ts
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore'; // Direct import of the Zustand store

// Helper: pause execution without hogging the CPU
const sleep = (time: number) =>
  new Promise((resolve) => setTimeout(resolve, time));

/**
 * Background timer task.
 * @param taskDataArguments Optional data passed when starting the service.
 */
export const timerBackgroundTask = async (
  taskDataArguments?: {
    targetSeconds?: number;
    habitName?: string;
  }
): Promise<void> => {
  const target = taskDataArguments?.targetSeconds ?? 0;
  const habitName = taskDataArguments?.habitName ?? 'Timer';

  await new Promise((resolve) => {
    // Loop while the foreground service is alive
    const loop = async () => {
      while (BackgroundService.isRunning()) {
        // 1️⃣ IMPERATIVE STATE ACCESS (no hooks!)
        const state = useHabitStore.getState();

        // 2️⃣ Stop conditions – user paused or timer already finished
        if (!state.timerIsRunning || state.timerIsCompleted) {
          break;
        }

        // 3️⃣ Timer logic (robust against app pauses)
        const now = Date.now();
        const start = state.timerStartTime;

        // Initialise start time if it’s missing
        if (!start) {
          useHabitStore.getState().setTimerStartTime(now);
          await sleep(1000);
          continue;
        }

        const elapsed = Math.floor((now - start) / 1000); // seconds

        // 4️⃣ Target reached?
        if (target > 0 && elapsed >= target) {
          // Update Zustand state
          useHabitStore.getState().setTimerIsCompleted(true);
          useHabitStore.getState().setTimerIsRunning(false);
          useHabitStore.getState().setTimerTimeElapsed(target);

          // Final notification
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: 'Timer finished!',
            progressBar: {
              max: target,
              value: target,
              indeterminate: false,
            },
          });

          // End the loop
          break;
        } else {
          // 5️⃣ Regular tick – update elapsed time & notification
          useHabitStore.getState().setTimerTimeElapsed(elapsed);

          const remaining = Math.max(0, target - elapsed);
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: `Remaining: ${formatTime(remaining)}`, // helper defined elsewhere
            progressBar: {
              max: target,
              value: elapsed,
              indeterminate: false,
            },
          });
        }

        // 6️⃣ Sleep 1 s to save battery/CPU
        await sleep(1000);
      }

      // Loop exited – resolve the outer promise
      resolve();
    };

    // Kick‑off the async loop
    loop();
  });
};

Key points

  • Imperative store access (useHabitStore.getState()) is the only way to read/write state inside the service.
  • The loop checks BackgroundService.isRunning() each iteration, so the task stops cleanly when the service is stopped.
  • sleep(1000) prevents the loop from hogging the CPU.

5️⃣ Hooking the UI to the background task

// HabitTimer.tsx
import { useEffect } from 'react';
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore';
import { timerBackgroundTask } from './timerBackgroundTask';

// Options for the foreground service notification
const serviceOptions = {
  taskName: 'HabitTimer',
  taskTitle: 'Habit Timer',
  taskDesc: 'Running...',
  // Android only – customize the notification appearance
  notification: {
    channelId: 'habit-timer',
    channelName: 'Habit Timer',
    color: '#ff0000',
    // icon: 'ic_launcher', // optional
  },
};

export const HabitTimer = () => {
  const {
    timerIsRunning,
    timerIsCompleted,
    timerTargetSeconds,
    habitName,
    setTimerIsRunning,
    setTimerIsCompleted,
    setTimerTimeElapsed,
    // any other needed actions...
  } = useHabitStore();

  // Start the foreground service when the timer becomes active
  useEffect(() => {
    const startService = async () => {
      if (timerIsRunning && !timerIsCompleted) {
        await BackgroundService.start(timerBackgroundTask, serviceOptions, {
          // Pass any data the task needs
          targetSeconds: timerTargetSeconds,
          habitName,
        });
      }
    };

    startService();

    // Cleanup – stop the service when the component unmounts or timer stops
    return () => {
      if (!timerIsRunning || timerIsCompleted) {
        BackgroundService.stop();
      }
    };
  }, [timerIsRunning, timerIsCompleted, timerTargetSeconds, habitName]);

  // UI rendering (simplified)
  return (
    <div>
      {habitName}
      {/* Render timer UI, start/pause buttons, etc. */}
    </div>
  );
};

Explanation

  1. useEffect watches the relevant pieces of Zustand state (timerIsRunning, timerIsCompleted, etc.).
  2. When the timer is started, we call BackgroundService.start(...) with:
    • the task function (timerBackgroundTask)
    • notification options (serviceOptions)
    • any arguments the task needs (targetSeconds, habitName).
  3. The cleanup function stops the service if the timer is paused or completed, or when the component unmounts.

6️⃣ Helper: Formatting time (optional)

// utils.ts
export const formatTime = (seconds: number): string => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};

🎉 Recap

StepWhat you did
1️⃣ InstallAdded react-native-background-actions
2️⃣ ManifestDeclared foreground‑service permission & service
3️⃣ Store accessSwitched from hooks to store.getState() inside the background loop
4️⃣ TaskImplemented an async loop that updates Zustand, updates the notification, and sleeps 1 s each tick
5️⃣ UI wiringUsed a useEffect to start/stop the foreground service based on Zustand state
6️⃣ ExtrasAdded a tiny formatTime helper for pretty notifications

Now the timer keeps running even when the app is backgrounded, the user sees a persistent notification, and the UI stays in sync thanks to Zustand’s imperative API. Happy habit‑building! 🚀

Background Service Integration

import { timerBackgroundTask } from './backgroundTasks'; // Import the function above
import { COLORS } from './theme';

// Inside your TimerComponent...
const {
  timerIsRunning,
  timerIsCompleted,
  selectedHabit,
  targetSeconds,
} = useHabitStore();

useEffect(() => {
  const manageBackgroundService = async () => {
    // -------------------------------------------------
    // Condition to **START**: timer is running, not finished,
    // and a habit is selected
    // -------------------------------------------------
    if (timerIsRunning && !timerIsCompleted && selectedHabit) {
      // Options for the native notification
      const options = {
        taskName: 'Habit Timer Task',
        taskTitle: selectedHabit.name,
        taskDesc: 'Timer Running...',
        taskIcon: {
          name: 'ic_launcher', // Ensure this icon exists in android/app/src/main/res/mipmap-*
          type: 'mipmap',
        },
        color: COLORS.primary,
        parameters: {
          // Pass initial data to the background‑task arguments
          targetSeconds,
          habitName: selectedHabit.name,
        },
      };

      try {
        // Only start if it's not already running
        if (!BackgroundService.isRunning()) {
          await BackgroundService.start(timerBackgroundTask, options);
          console.log('Background service started successfully');
        }
        // Optional: else { BackgroundService.updateNotification(...) } 
        // if you need UI‑only updates
      } catch (error) {
        console.error('Background Service Start failed:', error);
      }
    } else {
      // -------------------------------------------------
      // Logic to **STOP**: timer paused in UI or completed
      // -------------------------------------------------
      if (BackgroundService.isRunning()) {
        console.log('Stopping background service...');
        await BackgroundService.stop(); // Initiates teardown of the while loop
      }
    }
  };

  manageBackgroundService();

  // Re‑run this logic whenever the timer state changes
}, [timerIsRunning, timerIsCompleted, selectedHabit, targetSeconds]);

Implementation Tips

  • State‑Management Paradox
    The background task is a plain JavaScript function, not a React component.
    You cannot use hooks inside it. If you rely on Redux, Zustand, or another store, use their imperative APIs (store.getState(), store.dispatch()) to read/write data from within the loop.

  • The Notification Loop
    BackgroundService.updateNotification lets you keep the notification “alive.”
    Updating the progress bar and timer text each second reassures the user that the app is still running while it’s in their pocket.

  • Clean‑Up Is Vital
    The useEffect dependency array is crucial. When timerIsRunning flips to false in the UI, you must call BackgroundService.stop().
    Failing to do so leaves “ghost processes” that drain battery and annoy users.

  • Robust Timing
    Notice that the background task uses Date.now() subtraction rather than a simple elapsed++ counter.
    Using system‑time differences guarantees accurate timing even if the OS briefly pauses execution.

This approach gave me the robust “set it and forget it” experience my habit tracker needed, matching the sleekness of the swipe‑based UI.


For more information about web or mobile development, feel free to visit my website.

Back to Blog

Related posts

Read more »