rn-persistent-timer
v1.1.0
Published
React Native timers that keep running in the foreground, background, and even after the app is killed — with TypeScript support.
Maintainers
Readme
rn-persistent-timer
React Native timers that keep running — in the foreground, background, and even after the app is killed.
Full TypeScript support. Zero bundled native dependencies.
✨ Features
| Feature | Foreground | Background | Killed App | |---|:---:|:---:|:---:| | JS-only timer | ✅ | ❌ | ❌ | | Background timer | ✅ | ✅ | ❌ | | Kill-proof timer | ✅ | ✅ | ✅ |
- 🕐 Stopwatch (count up) and Countdown (count down to zero)
- 🔔 Persistent Android notification with pause/resume actions (Foreground Service)
- 📱 iOS background task via
UIBackgroundTaskIdentifier+BGTaskScheduler - 💾 Killed-state persistence via
AsyncStorage— restores elapsed time on next open - 🎣 React Hook (
usePersistentTimer) + Imperative class (PersistentTimerManager) - 📦 Full TypeScript — types included, no
@types/package needed - 🔄 Multiple simultaneous timers with independent configs
- ⚡ Zero external JS dependencies beyond
@react-native-async-storage/async-storage
📋 Table of Contents
- Installation
- Android Setup
- iOS Setup
- Quick Start
- Usage Examples
- API Reference
- TypeScript Types
- Troubleshooting
📦 Installation
# npm
npm install rn-persistent-timer @react-native-async-storage/async-storage
# yarn
yarn add rn-persistent-timer @react-native-async-storage/async-storageNote:
@react-native-async-storage/async-storageis required for killed-state persistence.
🤖 Android Setup
1. Add the Package to your MainApplication
MainApplication.kt (Kotlin — React Native 0.71+):
import com.rnpersistenttimer.RNPersistentTimerPackage
override fun getPackages(): List<ReactPackage> = listOf(
MainReactPackage(),
RNPersistentTimerPackage(), // ← add this
)MainApplication.java (Java):
import com.rnpersistenttimer.RNPersistentTimerPackage;
@Override
protected List<ReactPackage> getPackages() {
return Arrays.asList(
new MainReactPackage(),
new RNPersistentTimerPackage() // ← add this
);
}2. Register the Foreground Service in AndroidManifest.xml
<!-- Inside <application> tag -->
<service
android:name="com.rnpersistenttimer.TimerForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Inside <manifest> tag (before <application>) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Android 13+ (API 33+): You must request
POST_NOTIFICATIONSpermission at runtime before starting a timer with notifications.
3. Request Notification Permission (Android 13+)
import { PermissionsAndroid, Platform } from 'react-native';
async function requestNotificationPermission(): Promise<void> {
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
);
}
}🍎 iOS Setup
1. Add Capability: Background Modes
In Xcode:
- Select your project target → Signing & Capabilities
- Click + Capability → add Background Modes
- Enable ✅ Background fetch and ✅ Background processing
2. Register BGTask Identifier in Info.plist
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.timer</string>
</array>Replace
com.yourapp.timerwith your actual bundle ID prefix.
3. Update RNPersistentTimer.m
Open ios/RNPersistentTimer.m and replace the identifier string:
// Find this line in scheduleBGTask:
[[BGAppRefreshTaskRequest alloc] initWithIdentifier:@"com.yourapp.timer"];
// Replace with your actual bundle ID:
[[BGAppRefreshTaskRequest alloc] initWithIdentifier:@"com.YourApp.timer"];4. Install Pods
cd ios && pod install && cd ..⚡ Quick Start
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { usePersistentTimer } from 'rn-persistent-timer';
export default function StopwatchScreen() {
const { snapshot, start, pause, resume, reset, isRunning, isPaused } =
usePersistentTimer({
timerId: 'my-stopwatch', // unique ID — required
mode: 'stopwatch',
runInBackground: true, // keeps ticking when app is backgrounded
});
return (
<View>
<Text>{snapshot.formattedElapsed}</Text> {/* "HH:MM:SS" */}
<Text>State: {snapshot.state}</Text>
{!isRunning && !isPaused && (
<TouchableOpacity onPress={start}>
<Text>▶ Start</Text>
</TouchableOpacity>
)}
{isRunning && (
<TouchableOpacity onPress={pause}>
<Text>⏸ Pause</Text>
</TouchableOpacity>
)}
{isPaused && (
<TouchableOpacity onPress={resume}>
<Text>▶ Resume</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={reset}>
<Text>⟳ Reset</Text>
</TouchableOpacity>
</View>
);
}📖 Usage Examples
Stopwatch — Foreground Only
const timer = usePersistentTimer({
timerId: 'foreground-sw',
mode: 'stopwatch',
runInBackground: false,
runInKilledState: false,
});Stopwatch — Keeps Running in Background
const timer = usePersistentTimer({
timerId: 'bg-stopwatch',
mode: 'stopwatch',
runInBackground: true,
showNotification: true,
notification: {
title: '⏱ Stopwatch Running',
body: 'Elapsed: {time}', // {time} is replaced automatically
color: '#4CAF50',
showActions: true, // adds Pause/Resume buttons to notification
},
onBackground: (snap) => console.log('Backgrounded at', snap.formattedElapsed),
onForeground: (snap) => console.log('Foregrounded at', snap.formattedElapsed),
});Kill-Proof Timer (Survives App Kill)
import { Alert } from 'react-native';
const timer = usePersistentTimer({
timerId: 'kill-proof',
mode: 'stopwatch',
runInBackground: true,
runInKilledState: true, // ← enables AsyncStorage persistence
onRestore: (snap) => {
Alert.alert('Welcome back!', `Timer restored to ${snap.formattedElapsed}`);
},
});Countdown Timer
const timer = usePersistentTimer({
timerId: 'workout-countdown',
mode: 'countdown',
duration: 5 * 60, // 5 minutes in seconds
runInBackground: true,
runInKilledState: true,
notification: {
title: '⏳ Workout Timer',
body: '{time} remaining',
color: '#F44336',
},
onComplete: () => Alert.alert('Time\'s up! 🎉'),
});
// Use snapshot.formattedRemaining for countdown display
// Use snapshot.progress (0→1) for a progress bar
console.log(timer.snapshot.formattedRemaining); // "04:59"
console.log(timer.snapshot.progress); // 0.003...Auto-Pause on Background (Game Timer)
const timer = usePersistentTimer({
timerId: 'game-timer',
mode: 'stopwatch',
pauseOnBackground: true, // auto-pauses when app is backgrounded
runInKilledState: false,
});Multiple Concurrent Timers
const workTimer = usePersistentTimer({ timerId: 'work', mode: 'countdown', duration: 25 * 60, runInBackground: true });
const breakTimer = usePersistentTimer({ timerId: 'break', mode: 'countdown', duration: 5 * 60, runInBackground: true });
const sessionTimer = usePersistentTimer({ timerId: 'session', mode: 'stopwatch', runInBackground: true });Imperative API — Outside React (Redux, MobX, Services)
import { PersistentTimerManager } from 'rn-persistent-timer';
const manager = new PersistentTimerManager({
timerId: 'service-timer',
mode: 'stopwatch',
runInBackground: true,
runInKilledState: false,
});
manager.on('onTick', (snap) => {
console.log('Tick:', snap.formattedElapsed);
reduxStore.dispatch(updateTimer(snap));
});
manager.on('onComplete', (snap) => {
console.log('Done!');
});
manager.start();
// Later...
manager.pause();
manager.resume();
manager.reset();
manager.destroy(); // Always call when done to prevent memory leaksPlatform Support Check
import {
isBackgroundTimerSupported,
isKilledStateTimerSupported,
} from 'rn-persistent-timer';
useEffect(() => {
isBackgroundTimerSupported().then((supported) => {
console.log('Background timer supported:', supported);
});
isKilledStateTimerSupported().then((supported) => {
console.log('Kill-state timer supported:', supported);
});
}, []);Utility Functions
import { formatTime, parseTime } from 'rn-persistent-timer';
formatTime(3661); // → "01:01:01"
formatTime(59); // → "00:00:59"
parseTime('01:30:00'); // → 5400
parseTime('05:30'); // → 330📚 API Reference
usePersistentTimer(config)
React hook. Returns UsePersistentTimerReturn.
| Prop | Type | Default | Description |
|---|---|---|---|
| timerId | string | required | Unique timer ID |
| mode | 'stopwatch' \| 'countdown' | 'stopwatch' | Timer direction |
| duration | number | 0 | Seconds for countdown |
| runInBackground | boolean | true | Continue when app is backgrounded |
| runInKilledState | boolean | false | Restore after app kill |
| interval | number | 1000 | Tick interval (ms) |
| showNotification | boolean | true | Show Android notification |
| notification | AndroidNotificationConfig | {} | Notification options |
| pauseOnBackground | boolean | false | Auto-pause on background |
| resetOnForeground | boolean | false | Auto-reset when returned from kill |
Callbacks (all optional):
| Callback | Signature | Description |
|---|---|---|
| onTick | (snap: TimerSnapshot) => void | Every tick |
| onStart | (snap: TimerSnapshot) => void | Timer started |
| onPause | (snap: TimerSnapshot) => void | Timer paused |
| onResume | (snap: TimerSnapshot) => void | Timer resumed |
| onReset | (snap: TimerSnapshot) => void | Timer reset |
| onComplete | (snap: TimerSnapshot) => void | Countdown finished |
| onBackground | (snap: TimerSnapshot) => void | App went to background |
| onForeground | (snap: TimerSnapshot) => void | App returned to foreground |
| onRestore | (snap: TimerSnapshot) => void | State restored after kill |
| onError | (error: Error) => void | Internal error |
Return value:
| Field | Type | Description |
|---|---|---|
| snapshot | TimerSnapshot | Reactive timer state |
| isRunning | boolean | state === 'running' |
| isPaused | boolean | state === 'paused' |
| isCompleted | boolean | state === 'completed' |
| appState | AppState | Current app state |
| start() | () => void | Start the timer |
| pause() | () => void | Pause the timer |
| resume() | () => void | Resume from pause |
| reset() | () => void | Stop and reset to 0 |
| stop() | () => void | Stop without reset |
| getSnapshot() | () => TimerSnapshot | Synchronous snapshot |
| destroy() | () => void | Clean up all listeners |
PersistentTimerManager
Class-based API for use outside of React components.
const manager = new PersistentTimerManager(config: TimerConfig);
manager.start();
manager.pause();
manager.resume();
manager.reset();
manager.stop();
manager.getSnapshot(): TimerSnapshot;
manager.destroy();
manager.on(event, handler);
manager.off(event, handler);
// Static: restore from killed state
const restored = await PersistentTimerManager.restore(timerId);TimerSnapshot
interface TimerSnapshot {
timerId: string;
elapsed: number; // seconds since start
remaining: number | null; // seconds left (countdown only)
state: TimerState; // 'idle' | 'running' | 'paused' | 'completed'
appState: AppState; // 'foreground' | 'background' | 'killed'
startedAt: number | null; // unix ms when started
pausedAt: number | null; // unix ms when paused
formattedElapsed: string; // "HH:MM:SS"
formattedRemaining: string | null; // "HH:MM:SS" (countdown only)
progress: number | null; // 0→1 (countdown only)
}AndroidNotificationConfig
interface AndroidNotificationConfig {
title?: string; // Notification title
body?: string; // Body text; use {time} for the current timer value
channelId?: string; // Notification channel ID
channelName?: string; // Channel display name
icon?: string; // Drawable resource name
color?: string; // Hex accent color, e.g. '#FF5733'
showTime?: boolean; // Show time in notification
showActions?: boolean;// Show Pause/Resume action buttons
}🔧 Troubleshooting
Native module not found warning
[rn-persistent-timer] Native module not found.iOS: Run npx pod-install (or cd ios && pod install) then rebuild.
Android: Clean and rebuild the Android project: cd android && ./gradlew clean && cd ...
Foreground (JS-only) timers still work without the native module.
Timer stops in background on Android
- Ensure
TimerForegroundServiceis registered inAndroidManifest.xml. - Check that
POST_NOTIFICATIONSpermission is granted on Android 13+. - Set
showNotification: true— Android 8+ requires a visible notification for foreground services. - Some OEM ROMs (Xiaomi, OPPO, etc.) aggressively kill background processes. Guide users to whitelist your app in battery settings.
Timer stops in background on iOS
- Confirm Background Modes capability is added in Xcode.
- Verify
BGTaskSchedulerPermittedIdentifiersis set inInfo.plist. - iOS limits background execution to ~30 seconds unless using
BGTaskScheduler. The module handles this automatically viaUIBackgroundTaskIdentifier.
Timer not restoring after app kill
- Ensure
runInKilledState: truein your config. - Confirm
@react-native-async-storage/async-storageis installed and linked. - Rebuild the project after installing
async-storage.
TypeScript errors
Ensure your tsconfig.json has:
{
"compilerOptions": {
"moduleResolution": "bundler",
"strict": true
}
}📁 Project Structure
rn-persistent-timer/
├── src/
│ ├── index.ts # Public API barrel
│ ├── types.ts # All TypeScript types
│ ├── NativeTimerModule.ts # JS ↔ Native bridge
│ ├── PersistentTimerManager.ts # Core timer class
│ ├── usePersistentTimer.tsx # React hook
│ └── utils.ts # Utility functions
├── android/
│ ├── build.gradle
│ └── src/main/java/com/rnpersistenttimer/
│ ├── RNPersistentTimerModule.java
│ ├── RNPersistentTimerPackage.java
│ └── TimerForegroundService.java
├── ios/
│ ├── RNPersistentTimer.h
│ └── RNPersistentTimer.m
├── example/
│ └── App.tsx # 8-scenario demo app
└── lib/ # Built output (generated)
├── commonjs/
├── module/
└── typescript/🚀 Running the Example App
# 1. Clone or navigate to the package
cd /path/to/rn-persistent-timer
# 2. Install dependencies
yarn install
# 3. Install example dependencies
cd example && yarn install && cd ..
# 4. iOS — install pods
cd example/ios && pod install && cd ../..
npx react-native run-ios
# 5. Android
npx react-native run-android📤 Building & Publishing to npm
# 1. Install dependencies
yarn install
# 2. Build the TypeScript output
yarn build # runs react-native-builder-bob
# 3. Verify types compile
yarn typecheck # runs tsc --noEmit
# 4. Publish
npm publish --access public📄 License
MIT © Vipin Jaiswal
🙏 Contributing
PRs and issues welcome! Please open an issue before starting a large change.
- Fork the repo
- Create your feature branch:
git checkout -b feat/my-feature - Commit changes:
git commit -m 'feat: add my feature' - Push:
git push origin feat/my-feature - Open a Pull Request
