Building Little Snuggle: How We Keep Baby-Sleep Timers Accurate With a Foreground Service

Building Little Snuggle: How We Keep Baby-Sleep Timers Accurate With a Foreground Service

Arthur

Sleep tracking sounds simple — start a timer, stop a timer, store the duration. Anyone who's actually built it on Android in 2026 knows the simple version is a lie. Here's what we ran into building Little Snuggle, and the architecture we landed on.

The deceptively simple problem

Little Snuggle is a baby-tracking app: parents log sleeps, feeds, nappies, baths, tummy time, growth measurements, and the rest of the controlled chaos of the first year. The headline feature — and the one that makes the rest of the data trustworthy — is timed activities. A parent taps "Start Sleep" at 19:42, the baby actually falls asleep at 19:47, and at 21:13 they wake up. The app needs to know all three.

That's where the technical fun starts.

Why a timer is harder than it looks on Android

The job description: keep an accurate timer running for up to several hours, even if:

  • The screen is off (which is the default — parents don't sit holding a phone).

  • The app is backgrounded (parent switches to a podcast or texts a co-parent).

  • The OS decides our process can be killed (which it absolutely will, given enough memory pressure).

  • The phone reboots in the middle of a session (rare, but happens).

A naive CountDownTimer in a ViewModel survives roughly until the user puts the phone down. Android's process lifecycle is brutal to anything that thinks it can run forever in the background.

The architecture: foreground service + Firestore + Compose

What we ended up with:

  • A foreground service (TimerForegroundService) that owns the running timer state and posts a persistent notification.

  • Firestore as the source of truth for completed activity records and baby/profile data, synced reactively.

  • Hilt for DI across the service, ViewModels, and repositories.

  • Jetpack Compose + Material 3 for the UI, with state flowing one-way from Firestore → repository → ViewModel → composable.

  • Coil for image loading on the small handful of images we have (profile photos, milestone photos).

Why a foreground service, specifically

Android distinguishes between regular background services (which the OS will happily murder) and foreground services (which require a persistent notification but get strong "do not kill" guarantees). For activity timers, foreground is the only correct choice — and it's the one Google explicitly endorses for "user-initiated tasks the user expects to continue."

Our manifest declaration:

<service
    android:name=".service.TimerForegroundService"
    android:foregroundServiceType="specialUse"
    android:exported="false">
    <property
        android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
        android:value="user_initiated_timer" />
</service>

The service starts when the user taps "Start Sleep" (or "Start Feed"), shows a notification with the elapsed time updating every second, and stops automatically when all active timers are stopped. No background processing happens outside of user-initiated sessions. That last sentence matters a lot — it's both the user-facing privacy story and the answer Play Store review wants.

Surviving process death

Here's the bit that'd bite you if you didn't know: a foreground service still doesn't guarantee your process stays alive. The OS can kill the whole app under extreme memory pressure; it just has to give you priority over non-FGS apps. So the timer state has to be recoverable.

What we persist (synchronously, on every state transition):

  • Timer started-at timestamp (epoch ms, not elapsed-since-boot — boot time is wrong if the phone reboots).

  • Activity type and the baby it belongs to.

  • The user ID, in case the app's auth state needs to rebuild.

On service start (whether fresh or after a kill) the service checks DataStore for an in-flight timer; if it finds one, it resumes silently. The user doesn't know we died and came back. From their perspective the timer was always running, because functionally it was — start time was preserved, elapsed time is just now - start.

Firestore as the sync layer

Once an activity completes, it goes to Firestore, scoped per user and per baby. We picked Firestore over a custom REST + DB stack for three reasons:

  1. Real-time sync between devices is built in. A parent on their phone, a co-parent on a tablet — both see updates within a second, no polling, no socket layer to maintain.

  2. Offline mode is free. The Firestore SDK queues writes locally and syncs when reconnected. Babies don't wait for cell service.

  3. Security rules are colocated with the data model. "Only the owning user can read this baby's records" is one rule, applied uniformly across all reads and writes.

The trade-off: Firestore's query model is restrictive (no joins, no full-text search), and the cost model is per-document-read so you have to think about your access patterns. For our access pattern — "load this baby's last 30 days of activities" — those constraints are fine.

State flow into Compose

The thing that made the UI side feel right was committing to a unidirectional flow:

Firestore listener
   ↓ Flow<List<Activity>>
Repository (Hilt-injected, single instance)
   ↓ StateFlow
ViewModel (per screen, scoped to NavBackStackEntry)
   ↓ collectAsStateWithLifecycle()
Composable

Activities are emitted as a Flow from the repository, the ViewModel converts to a StateFlow, the composable collects with lifecycle-awareness so we don't churn during config changes. No mutable state held outside the ViewModel; no manual "refresh" button anywhere in the app. Updates from the foreground service flow through the same path because the service writes timer state to a shared DataStore-backed flow that ViewModels also collect from.

What we got wrong (and fixed)

Two embarrassments worth admitting:

Time arithmetic with elapsed-since-boot. Our first cut used SystemClock.elapsedRealtime() for the start time, because "monotonic, won't drift if user changes the clock." Then we tested phone reboots. Boot resets elapsedRealtime to zero, so a timer started at "boot + 30min" looked like it had started 30 minutes after the new boot — wildly wrong. Switched to wall-clock time, accepted the (very rare) risk of clock changes, never hit it in practice.

Compose recomposition while the timer ticks. We had a naive timer ticking every 100ms and recomposing the whole screen. Battery test was ugly. Two fixes: tick the elapsed-time text from a derived state with a 1s cadence (parents do not need 100ms precision on a 90-minute nap), and use derivedStateOf so unrelated state changes don't recompose the timer text and vice versa.

The takeaway

"Just a timer" turned out to be the most architecturally interesting part of the app. The right answer was a foreground service for liveness, persistent state for survivability, Firestore for sync, and disciplined unidirectional flow for the UI. None of those pieces are exotic on their own — getting them to play nicely together for a feature parents use 8 times a day in their sleep is the actual work.

Little Snuggle is built by CloudBlue Digital. If you're working on something with similar background-execution-meets-real-time-sync constraints, we're always up for a conversation.

We use cookies to analyse site traffic and improve your experience. See our Privacy Policy for details.