Building an Offline-First Sleep Tracker with React Native and WatermelonDB
Health apps face a unique challenge: users expect them to work flawlessly at all hours, including during the night when network connectivity may be unreliable or intentionally disabled. An offline-first architecture ensures that sleep data is captured reliably regardless of network conditions, then synchronized when connectivity is restored.
This tutorial walks through building a production-ready sleep tracker that stores all data locally first using WatermelonDB, a high-performance reactive database for React Native applications.
Why Offline-First for Health Apps?
Before diving into code, it is important to understand why offline-first is the correct default for health and wellness applications.
Reliability: Sleep tracking happens during the night. Users often enable airplane mode or have poor signal in bedrooms. Data must never be lost due to network issues.
Performance: Local reads are orders of magnitude faster than network requests. Querying sleep history should feel instant, whether the user has 3 days or 3 years of data.
Privacy: Health data stays on the device until the user explicitly chooses to sync. This reduces attack surface and aligns with privacy-by-design principles.
User experience: No loading spinners for core features. The app works immediately on launch, even on the first frame.
Architecture Overview
Our sleep tracker follows a local-first architecture with three layers:
- Local database layer (WatermelonDB): All reads and writes happen here first
- Background sync layer: Queues changes and pushes them when connectivity is available
- Remote API layer: Receives synced data and handles conflict resolution
+-------------------+ +------------------+ +------------------+
| React Native UI |---->| WatermelonDB |---->| Sync Engine |
| (Screens & |<----| (Local SQLite) |<----| (Background |
| Components) | | | | Queue) |
+-------------------+ +------------------+ +------------------+
|
v
+------------------+
| Remote API |
| (REST/GraphQL) |
+------------------+
Prerequisites
Ensure your development environment has the following:
- Node.js 20 or later
- React Native CLI or Expo SDK 52+
- iOS Simulator or Android Emulator
- Basic familiarity with TypeScript and React Hooks
Project Setup
Initialize a new React Native project and install dependencies:
npx react-native init SleepTracker --template react-native-template-typescript
cd SleepTracker
# Core dependencies
npm install @nozbe/watermelondb @nozbe/with-observable
npm install @nozbe/watermelondb/Schema @nozbe/watermelondb/Model
npm install @nozbe/watermelondb/adapters/sqlite
# Navigation and UI
npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context
# Background tasks
npm install react-native-background-actions react-native-background-timer
# Date utilities
npm install date-fns
Defining the Database Schema
WatermelonDB uses a declarative schema system. For a sleep tracker, we need tables for sleep sessions, sleep events (interruptions, stage changes), and daily summaries.
Create the schema file at src/database/schema.ts:
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'sleep_sessions',
columns: [
{ name: 'started_at', type: 'number' },
{ name: 'ended_at', type: 'number', isOptional: true },
{ name: 'duration_minutes', type: 'number', isOptional: true },
{ name: 'quality_score', type: 'number', isOptional: true },
{ name: 'deep_sleep_minutes', type: 'number', isOptional: true },
{ name: 'light_sleep_minutes', type: 'number', isOptional: true },
{ name: 'rem_sleep_minutes', type: 'number', isOptional: true },
{ name: 'awake_minutes', type: 'number', isOptional: true },
{ name: 'interruption_count', type: 'number', isOptional: true },
{ name: 'notes', type: 'string', isOptional: true },
{ name: 'is_synced', type: 'boolean' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'sleep_events',
columns: [
{ name: 'session_id', type: 'string', isIndexed: true },
{ name: 'event_type', type: 'string' },
{ name: 'timestamp', type: 'number' },
{ name: 'duration_seconds', type: 'number', isOptional: true },
{ name: 'metadata', type: 'string', isOptional: true },
{ name: 'is_synced', type: 'boolean' },
{ name: 'created_at', type: 'number' },
],
}),
tableSchema({
name: 'daily_summaries',
columns: [
{ name: 'date', type: 'string', isIndexed: true },
{ name: 'total_sleep_minutes', type: 'number' },
{ name: 'average_quality', type: 'number', isOptional: true },
{ name: 'bedtime_hour', type: 'number', isOptional: true },
{ name: 'wake_hour', type: 'number', isOptional: true },
{ name: 'sleep_efficiency', type: 'number', isOptional: true },
{ name: 'is_synced', type: 'boolean' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
});
Creating Model Classes
Each table needs a corresponding Model class that defines field accessors and associations.
Create src/database/models/SleepSession.ts:
import { Model, Q } from '@nozbe/watermelondb';
import { field, date, readonly, children, relation } from '@nozbe/watermelondb/decorators';
import type { Observable } from '@nozbe/with-observable';
export default class SleepSession extends Model {
static table = 'sleep_sessions';
static associations = {
sleep_events: { type: 'has_many', foreignKey: 'session_id' },
};
@date('started_at') startedAt!: Date;
@date('ended_at') endedAt!: Date | null;
@field('duration_minutes') durationMinutes!: number | null;
@field('quality_score') qualityScore!: number | null;
@field('deep_sleep_minutes') deepSleepMinutes!: number | null;
@field('light_sleep_minutes') lightSleepMinutes!: number | null;
@field('rem_sleep_minutes') remSleepMinutes!: number | null;
@field('awake_minutes') awakeMinutes!: number | null;
@field('interruption_count') interruptionCount!: number | null;
@field('notes') notes!: string | null;
@field('is_synced') isSynced!: boolean;
@readonly('created_at') createdAt!: Date;
@readonly('updated_at') updatedAt!: Date;
@children('sleep_events') events!: Observable<any>;
get isActive(): boolean {
return this.endedAt === null;
}
get durationHours(): number | null {
if (!this.durationMinutes) return null;
return Math.round((this.durationMinutes / 60) * 10) / 10;
}
get sleepEfficiency(): number | null {
if (!this.durationMinutes || !this.deepSleepMinutes || !this.lightSleepMinutes) {
return null;
}
const totalSleep = this.deepSleepMinutes + this.lightSleepMinutes +
(this.remSleepMinutes || 0);
return Math.round((totalSleep / this.durationMinutes) * 100);
}
}
Create src/database/models/SleepEvent.ts:
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export type SleepEventType =
| 'stage_change'
| 'interruption'
| 'movement'
| 'alarm'
| 'snooze';
export default class SleepEvent extends Model {
static table = 'sleep_events';
@field('session_id') sessionId!: string;
@field('event_type') eventType!: SleepEventType;
@date('timestamp') timestamp!: Date;
@field('duration_seconds') durationSeconds!: number | null;
@field('metadata') metadata!: string | null;
@field('is_synced') isSynced!: boolean;
@readonly('created_at') createdAt!: Date;
get parsedMetadata(): Record<string, unknown> | null {
if (!this.metadata) return null;
try {
return JSON.parse(this.metadata);
} catch {
return null;
}
}
}
Create src/database/models/DailySummary.ts:
import { Model } from '@nozbe/watermelondb';
import { field, readonly } from '@nozbe/watermelondb/decorators';
export default class DailySummary extends Model {
static table = 'daily_summaries';
@field('date') date!: string;
@field('total_sleep_minutes') totalSleepMinutes!: number;
@field('average_quality') averageQuality!: number | null;
@field('bedtime_hour') bedtimeHour!: number | null;
@field('wake_hour') wakeHour!: number | null;
@field('sleep_efficiency') sleepEfficiency!: number | null;
@field('is_synced') isSynced!: boolean;
@readonly('created_at') createdAt!: Date;
@readonly('updated_at') updatedAt!: Date;
get totalSleepHours(): number {
return Math.round((this.totalSleepMinutes / 60) * 10) / 10;
}
}
Database Initialization
Wire everything together with a database initialization module at src/database/index.ts:
import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import SleepSession from './models/SleepSession';
import SleepEvent from './models/SleepEvent';
import DailySummary from './models/DailySummary';
const adapter = new SQLiteAdapter({
schema,
dbName: 'sleeptracker',
jsi: true,
onSetUpError: (error) => {
console.error('Database setup failed:', error);
},
});
export const database = new Database({
adapter,
modelClasses: [SleepSession, SleepEvent, DailySummary],
});
export { SleepSession, SleepEvent, DailySummary };
Building the Sleep Tracking Service
The core business logic lives in a service class that handles creating sessions, logging events, and computing summaries.
Create src/services/SleepTrackingService.ts:
import { database, SleepSession, SleepEvent, DailySummary } from '../database';
import { Q } from '@nozbe/watermelondb';
import { format, subDays } from 'date-fns';
class SleepTrackingService {
/**
* Start a new sleep session. Only one active session is allowed at a time.
*/
async startSession(notes?: string): Promise<SleepSession> {
const activeSession = await this.getActiveSession();
if (activeSession) {
throw new Error('An active sleep session already exists. End it first.');
}
return await database.write(async () => {
return await database.get<SleepSession>('sleep_sessions').create((session) => {
session.startedAt = new Date();
session.notes = notes || null;
session.isSynced = false;
});
});
}
/**
* End the current active session and calculate summary metrics.
*/
async endSession(qualityScore?: number): Promise<SleepSession> {
const session = await this.getActiveSession();
if (!session) {
throw new Error('No active sleep session found.');
}
return await database.write(async () => {
const endedAt = new Date();
const durationMs = endedAt.getTime() - session.startedAt.getTime();
const durationMinutes = Math.round(durationMs / (1000 * 60));
// Fetch events to compute stage breakdowns
const events = await session.events.fetch();
const interruptionCount = events.filter(
(e: SleepEvent) => e.eventType === 'interruption'
).length;
await session.update((s) => {
s.endedAt = endedAt;
s.durationMinutes = durationMinutes;
s.qualityScore = qualityScore || null;
s.interruptionCount = interruptionCount;
s.isSynced = false;
});
// Update or create daily summary
await this.updateDailySummary(session.startedAt);
return session;
});
}
/**
* Log a sleep event (stage change, interruption, movement, etc.)
*/
async logEvent(
sessionId: string,
eventType: SleepEvent['eventType'],
metadata?: Record<string, unknown>
): Promise<SleepEvent> {
return await database.write(async () => {
return await database.get<SleepEvent>('sleep_events').create((event) => {
event.sessionId = sessionId;
event.eventType = eventType;
event.timestamp = new Date();
event.metadata = metadata ? JSON.stringify(metadata) : null;
event.isSynced = false;
});
});
}
/**
* Get the currently active (unfinished) sleep session.
*/
async getActiveSession(): Promise<SleepSession | null> {
const sessions = await database
.get<SleepSession>('sleep_sessions')
.query(Q.where('ended_at', null))
.fetch();
return sessions.length > 0 ? sessions[0] : null;
}
/**
* Get sleep sessions for the last N days.
*/
async getRecentSessions(days: number = 7): Promise<SleepSession[]> {
const cutoff = subDays(new Date(), days).getTime();
return await database
.get<SleepSession>('sleep_sessions')
.query(
Q.where('ended_at', Q.notEq(null)),
Q.where('started_at', Q.gte(cutoff)),
Q.sortBy('started_at', Q.desc)
)
.fetch();
}
/**
* Update or create a daily summary for a given date.
*/
private async updateDailySummary(date: Date): Promise<void> {
const dateStr = format(date, 'yyyy-MM-dd');
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
const sessions = await database
.get<SleepSession>('sleep_sessions')
.query(
Q.where('started_at', Q.gte(dayStart.getTime())),
Q.where('started_at', Q.lte(dayEnd.getTime())),
Q.where('ended_at', Q.notEq(null))
)
.fetch();
const totalSleepMinutes = sessions.reduce(
(sum, s) => sum + (s.durationMinutes || 0), 0
);
const qualityScores = sessions
.map((s) => s.qualityScore)
.filter((q): q is number => q !== null);
const averageQuality = qualityScores.length > 0
? qualityScores.reduce((a, b) => a + b, 0) / qualityScores.length
: null;
await database.write(async () => {
const existing = await database
.get<DailySummary>('daily_summaries')
.query(Q.where('date', dateStr))
.fetch();
if (existing.length > 0) {
await existing[0].update((summary) => {
summary.totalSleepMinutes = totalSleepMinutes;
summary.averageQuality = averageQuality;
summary.isSynced = false;
});
} else {
await database.get<DailySummary>('daily_summaries').create((summary) => {
summary.date = dateStr;
summary.totalSleepMinutes = totalSleepMinutes;
summary.averageQuality = averageQuality;
summary.isSynced = false;
});
}
});
}
}
export const sleepTrackingService = new SleepTrackingService();
React Hooks for Reactive Data
WatermelonDB integrates with React through observables. Create custom hooks at src/hooks/useSleepData.ts:
import { useState, useEffect } from 'react';
import { database, SleepSession, DailySummary } from '../database';
import { Q } from '@nozbe/watermelondb';
import { withObservables } from '@nozbe/with-observable';
import { Observable } from 'rxjs';
export function useActiveSession(): SleepSession | null {
const [session, setSession] = useState<SleepSession | null>(null);
useEffect(() => {
const query = database
.get<SleepSession>('sleep_sessions')
.query(Q.where('ended_at', null))
.observe();
const subscription = query.subscribe((sessions) => {
setSession(sessions.length > 0 ? sessions[0] : null);
});
return () => subscription.unsubscribe();
}, []);
return session;
}
export function useRecentSessions(days: number = 7): SleepSession[] {
const [sessions, setSessions] = useState<SleepSession[]>([]);
useEffect(() => {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
cutoff.setHours(0, 0, 0, 0);
const query = database
.get<SleepSession>('sleep_sessions')
.query(
Q.where('ended_at', Q.notEq(null)),
Q.where('started_at', Q.gte(cutoff.getTime())),
Q.sortBy('started_at', Q.desc)
)
.observe();
const subscription = query.subscribe(setSessions);
return () => subscription.unsubscribe();
}, [days]);
return sessions;
}
export function useDailySummary(date: string): DailySummary | null {
const [summary, setSummary] = useState<DailySummary | null>(null);
useEffect(() => {
const query = database
.get<DailySummary>('daily_summaries')
.query(Q.where('date', date))
.observe();
const subscription = query.subscribe((summaries) => {
setSummary(summaries.length > 0 ? summaries[0] : null);
});
return () => subscription.unsubscribe();
}, [date]);
return summary;
}
Building the Sleep Tracking Screen
Create src/screens/SleepTrackerScreen.tsx with the main user interface:
import React, { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
FlatList,
Alert,
} from 'react-native';
import { sleepTrackingService } from '../services/SleepTrackingService';
import { useActiveSession, useRecentSessions } from '../hooks/useSleepData';
import { format } from 'date-fns';
export default function SleepTrackerScreen() {
const activeSession = useActiveSession();
const recentSessions = useRecentSessions(14);
const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const handleStartSession = async () => {
setIsStarting(true);
try {
await sleepTrackingService.startSession();
} catch (error: any) {
Alert.alert('Error', error.message);
} finally {
setIsStarting(false);
}
};
const handleEndSession = async () => {
setIsStopping(true);
try {
const session = await sleepTrackingService.endSession();
// Prompt for quality rating
Alert.alert(
'Session Ended',
`You slept for ${session.durationHours} hours. How did you feel?`,
[
{ text: 'Poor (1)', onPress: () => rateQuality(1) },
{ text: 'Fair (2)', onPress: () => rateQuality(2) },
{ text: 'Good (3)', onPress: () => rateQuality(3) },
{ text: 'Great (4)', onPress: () => rateQuality(4) },
{ text: 'Excellent (5)', onPress: () => rateQuality(5) },
]
);
} catch (error: any) {
Alert.alert('Error', error.message);
} finally {
setIsStopping(false);
}
};
const rateQuality = async (score: number) => {
const session = await sleepTrackingService.getActiveSession();
if (!session) return;
await database.write(async () => {
await session.update((s) => {
s.qualityScore = score;
});
});
};
const formatDuration = (minutes: number | null): string => {
if (!minutes) return '--';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};
return (
<View style={styles.container}>
<View style={styles.activeSection}>
{activeSession ? (
<>
<Text style={styles.activeLabel}>Currently Tracking</Text>
<Text style={styles.activeTime}>
Started at {format(activeSession.startedAt, 'h:mm a')}
</Text>
<TouchableOpacity
style={styles.stopButton}
onPress={handleEndSession}
disabled={isStopping}
>
<Text style={styles.stopButtonText}>
{isStopping ? 'Ending...' : 'End Sleep Session'}
</Text>
</TouchableOpacity>
</>
) : (
<>
<Text style={styles.inactiveLabel}>Ready to Track</Text>
<TouchableOpacity
style={styles.startButton}
onPress={handleStartSession}
disabled={isStarting}
>
<Text style={styles.startButtonText}>
{isStarting ? 'Starting...' : 'Start Sleep Session'}
</Text>
</TouchableOpacity>
</>
)}
</View>
<Text style={styles.historyTitle}>Recent Sleep History</Text>
<FlatList
data={recentSessions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.sessionCard}>
<Text style={styles.sessionDate}>
{format(item.startedAt, 'EEE, MMM d')}
</Text>
<Text style={styles.sessionDuration}>
{formatDuration(item.durationMinutes)}
</Text>
{item.qualityScore && (
<Text style={styles.sessionQuality}>
Quality: {item.qualityScore}/5
</Text>
)}
</View>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>No sleep data yet.</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0a0a1a', padding: 20 },
activeSection: {
backgroundColor: '#1a1a2e',
borderRadius: 16,
padding: 24,
alignItems: 'center',
marginBottom: 24,
},
activeLabel: {
color: '#4ade80',
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
inactiveLabel: {
color: '#94a3b8',
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
},
activeTime: { color: '#e2e8f0', fontSize: 14, marginBottom: 16 },
startButton: {
backgroundColor: '#6366f1',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 12,
},
startButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
stopButton: {
backgroundColor: '#ef4444',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 12,
},
stopButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
historyTitle: {
color: '#e2e8f0',
fontSize: 20,
fontWeight: '700',
marginBottom: 12,
},
sessionCard: {
backgroundColor: '#1a1a2e',
borderRadius: 12,
padding: 16,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sessionDate: { color: '#e2e8f0', fontSize: 14 },
sessionDuration: { color: '#a78bfa', fontSize: 14, fontWeight: '600' },
sessionQuality: { color: '#4ade80', fontSize: 12 },
emptyText: { color: '#64748b', fontSize: 14, textAlign: 'center', marginTop: 20 },
});
Implementing Background Sync
Offline-first requires a robust sync mechanism. Create src/services/SyncService.ts:
import { database } from '../database';
import { Q } from '@nozbe/watermelondb';
import { SleepSession, SleepEvent, DailySummary } from '../database';
import NetInfo from '@react-native-community/netinfo';
class SyncService {
private syncInProgress = false;
/**
* Sync all unsynchronized records to the remote server.
*/
async syncToRemote(): Promise<{ synced: number; failed: number }> {
if (this.syncInProgress) {
return { synced: 0, failed: 0 };
}
this.syncInProgress = true;
let synced = 0;
let failed = 0;
try {
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
return { synced: 0, failed: 0 };
}
// Sync sessions
const unsyncedSessions = await database
.get<SleepSession>('sleep_sessions')
.query(Q.where('is_synced', false))
.fetch();
for (const session of unsyncedSessions) {
try {
await this.pushSession(session);
await database.write(async () => {
await session.update((s) => { s.isSynced = true; });
});
synced++;
} catch {
failed++;
}
}
// Sync events
const unsyncedEvents = await database
.get<SleepEvent>('sleep_events')
.query(Q.where('is_synced', false))
.fetch();
for (const event of unsyncedEvents) {
try {
await this.pushEvent(event);
await database.write(async () => {
await event.update((e) => { e.isSynced = true; });
});
synced++;
} catch {
failed++;
}
}
} finally {
this.syncInProgress = false;
}
return { synced, failed };
}
private async pushSession(session: SleepSession): Promise<void> {
const response = await fetch('https://api.example.com/sleep/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: session.id,
startedAt: session.startedAt.toISOString(),
endedAt: session.endedAt?.toISOString() || null,
durationMinutes: session.durationMinutes,
qualityScore: session.qualityScore,
notes: session.notes,
}),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
}
private async pushEvent(event: SleepEvent): Promise<void> {
const response = await fetch('https://api.example.com/sleep/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: event.id,
sessionId: event.sessionId,
eventType: event.eventType,
timestamp: event.timestamp.toISOString(),
metadata: event.metadata,
}),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
}
/**
* Start watching connectivity and auto-sync when online.
*/
startAutoSync(intervalMs: number = 30000): () => void {
const interval = setInterval(() => {
this.syncToRemote().catch(console.error);
}, intervalMs);
return () => clearInterval(interval);
}
}
export const syncService = new SyncService();
Conflict Resolution Strategy
When syncing from multiple devices, conflicts are inevitable. Implement a last-write-wins strategy with device timestamps:
import { Model } from '@nozbe/watermelondb';
interface SyncableRecord {
updatedAt: Date;
isSynced: boolean;
}
function resolveConflict<T extends Model & SyncableRecord>(
local: T,
remote: { updatedAt: string }
): 'local' | 'remote' {
const remoteDate = new Date(remote.updatedAt);
return local.updatedAt >= remoteDate ? 'local' : 'remote';
}
For health data where accuracy matters, consider a more nuanced approach:
- Duration data: Prefer the longer measurement, as short sessions are usually incomplete
- Quality scores: Average local and remote if both exist
- Events: Merge by timestamp, deduplicating on exact match within a 30-second window
Testing the Offline Behavior
Write integration tests to verify the offline-first guarantees:
import { database, SleepSession } from '../database';
import { sleepTrackingService } from '../services/SleepTrackingService';
describe('SleepTrackingService', () => {
beforeEach(async () => {
await database.write(async () => {
await database.unsafeDeleteAllRecords('sleep_sessions');
await database.unsafeDeleteAllRecords('sleep_events');
await database.unsafeDeleteAllRecords('daily_summaries');
});
});
it('creates a new sleep session', async () => {
const session = await sleepTrackingService.startSession('Test notes');
expect(session).toBeDefined();
expect(session.isActive).toBe(true);
expect(session.notes).toBe('Test notes');
});
it('prevents multiple active sessions', async () => {
await sleepTrackingService.startSession();
await expect(sleepTrackingService.startSession()).rejects.toThrow(
'An active sleep session already exists'
);
});
it('ends a session and calculates duration', async () => {
const session = await sleepTrackingService.startSession();
const ended = await sleepTrackingService.endSession(4);
expect(ended.isActive).toBe(false);
expect(ended.durationMinutes).toBeGreaterThanOrEqual(0);
expect(ended.qualityScore).toBe(4);
});
it('retrieves recent sessions within date range', async () => {
await sleepTrackingService.startSession();
await sleepTrackingService.endSession(3);
const recent = await sleepTrackingService.getRecentSessions(7);
expect(recent).toHaveLength(1);
expect(recent[0].qualityScore).toBe(3);
});
});
Performance Considerations
WatermelonDB is designed for large datasets, but health apps that accumulate data over years need additional optimization:
Index frequently queried columns: The started_at and date columns are indexed in our schema. Add indexes for any column used in Q.where() clauses.
Batch writes: When importing historical data or syncing many records, use database.batch():
await database.write(async () => {
const batch = sessions.map((data) =>
database.get<SleepSession>('sleep_sessions').prepareCreate((session) => {
session.startedAt = new Date(data.startedAt);
session.endedAt = new Date(data.endedAt);
session.durationMinutes = data.durationMinutes;
session.isSynced = true;
})
);
await database.batch(...batch);
});
Lazy-load associations: Do not eagerly fetch all sleep events when displaying a session list. Only load events when the user navigates to a session detail screen.
Conclusion
Building an offline-first sleep tracker with WatermelonDB gives you a robust foundation for health data collection. The local-first approach ensures data is never lost due to network issues, while the reactive query system keeps your UI automatically in sync with the database state.
Key takeaways from this implementation:
- All writes go to WatermelonDB first, with sync happening asynchronously in the background
- Observable queries provide real-time UI updates without manual refresh logic
- The sync service handles connectivity detection and retry logic transparently
- Conflict resolution strategies should be tailored to the specific data type
For production deployments, consider adding encryption at rest for the SQLite database, implementing proper authentication tokens in the sync layer, and setting up comprehensive error monitoring for sync failures.