Skip to main content

Adapting to Android 15 Foreground Service 'dataSync' Timeouts

 If your application relies on long-running background synchronization, Android 15 (API level 35) introduces a breaking change that demands immediate architectural refactoring.

The days of keeping a Foreground Service alive indefinitely for data synchronization are over. Android 15 enforces a strict 6-hour time limit on dataSync and mediaProcessing foreground service types.

This is not a "soft" recommendation. If your service exceeds this window, the system invokes the new Service.onTimeout(int, int) callback. If the service doesn't stop immediately, the system declares an ANR (Application Not Responding) or kills the app process entirely.

This guide provides a production-grade strategy to handle this limitation using WorkManager and checkpointing, ensuring your large-scale syncs survive the strict new OS governance.

The Root Cause: Why Android 15 Kills Long Syncs

Historically, developers used Foreground Services to bypass battery optimization (Doze mode) and ensure network access for large uploads or database syncs. By attaching a notification, we told the OS: "User is aware, let this run."

However, abusing this for days-long operations degrades battery health and system performance. In Android 15, Google formalized the lifecycle of specific service types.

The Mechanics of the Kill

When you call startForeground with FOREGROUND_SERVICE_TYPE_DATA_SYNC:

  1. Timer Starts: The system initiates a strict countdown (default 6 hours).
  2. Runtime Limit: This timer accumulates even if the service is stopped and restarted, unless the app enters the "cached" state in between.
  3. The Trigger: Once the limit is breached, onTimeout is fired.
  4. The Termination: If the service is still running a few seconds after onTimeout, the system throws a ForegroundServiceDidNotStartInTimeException (or simply kills the process with a distinct exit reason).

WorkManager wraps Foreground Services, meaning your CoroutineWorker will silently fail or cause a crash if it attempts to run a monolithic sync block longer than allowed.

The Solution: Architectural Checkpointing

You cannot bypass the 6-hour wall. The only robust solution is Checkpointing.

Instead of attempting to sync 10GB of data in one massive transaction, we must break the work into idempotent chunks. The worker must monitor its own runtime, save its progress (checkpoint), and gracefully terminate before the OS kills it, returning Result.retry() to resume later with a reset timer.

Step 1: Manifest Configuration

First, ensure your manifest explicitly declares the service type. This is mandatory for Android 14+ and critical for Android 15 behavior.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
    <!-- Required for notification posting -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application>
        <!-- If using WorkManager's default SystemJobService, 
             you generally don't need to declare a service manually.
             However, if you implement a custom service, define types here. -->
        <service
            android:name="androidx.work.impl.foreground.SystemForegroundService"
            android:foregroundServiceType="dataSync"
            tools:node="merge" />
    </application>
</manifest>

Step 2: The Self-Monitoring CoroutineWorker

This implementation uses a CoroutineWorker that tracks its own execution time. It processes data in batches and checks a safety threshold before starting the next batch.

import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import java.time.Duration
import java.time.Instant

class ResilientSyncWorker(
    context: Context,
    parameters: WorkerParameters
) : CoroutineWorker(context, parameters) {

    companion object {
        // Safety buffer: Stop 30 minutes before the 6-hour limit to account 
        // for wake-up latency and cleanup.
        private const val MAX_RUN_TIME_MS = 5 * 60 * 60 * 1000L // 5.5 Hours
        private const val NOTIFICATION_ID = 42
        private const val CHANNEL_ID = "sync_channel"
    }

    private val startTime = Instant.now()
    private val repo = SyncRepository() // Pseudo-dependency

    override suspend fun doWork(): Result {
        // 1. Promote to Foreground Service immediately
        // Android 15 requires strictly defined types for FGS
        val foregroundInfo = createForegroundInfo()
        setForeground(foregroundInfo)

        try {
            // 2. Fetch total work (e.g., list of file IDs or DB offsets)
            // Ideally, this cursor comes from inputData if resuming
            var nextCursor = inputData.getString("cursor") ?: repo.getInitialCursor()
            
            while (nextCursor != null) {
                // 3. CRITICAL: Check Time Budget
                if (isTimeBudgetExceeded()) {
                    // Save state to disk/DB so we know where to resume
                    repo.saveCheckpoint(nextCursor)
                    
                    // Return Retry to let WorkManager reschedule us.
                    // This resets the Foreground Service timer in the new run.
                    return Result.retry()
                }

                // 4. Process a single chunk (small batch)
                // Check for cancellation (user force stop or system preemption)
                if (isStopped) {
                    repo.saveCheckpoint(nextCursor)
                    return Result.retry()
                }

                val processingResult = repo.syncBatch(nextCursor)
                
                // Update cursor for next loop
                nextCursor = processingResult.nextCursor
                
                // 5. Update Notification Progress
                updateProgress(processingResult.itemsProcessed)
                
                // Cooperative multitasking yield
                yield() 
            }

            return Result.success()

        } catch (e: Exception) {
            // Log error
            return Result.failure()
        }
    }

    private fun isTimeBudgetExceeded(): Boolean {
        val currentTime = Instant.now()
        val duration = Duration.between(startTime, currentTime)
        return duration.toMillis() > MAX_RUN_TIME_MS
    }

    private fun createForegroundInfo(): ForegroundInfo {
        val title = "Synchronizing Data"
        val cancel = "Cancel"
        
        // Ensure Notification Channel exists (omitted for brevity)
        // ...

        val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
            .setContentTitle(title)
            .setTicker(title)
            .setContentText("Syncing large dataset...")
            .setSmallIcon(android.R.drawable.ic_popup_sync)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .build()

        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 14/15 requires the type to match the Manifest
            ForegroundInfo(
                NOTIFICATION_ID, 
                notification, 
                ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
            )
        } else {
            ForegroundInfo(NOTIFICATION_ID, notification)
        }
    }
    
    private suspend fun updateProgress(count: Int) {
        setForeground(createForegroundInfo()) // Updates the notification
        setProgress(workDataOf("Progress" to count))
    }
}

Deep Dive: Why This Works

The architecture above solves the Android 15 constraint through three mechanisms:

1. The Token Bucket Reset

When Result.retry() is returned, the current WorkRequest finishes, and the associated Foreground Service is torn down. When WorkManager reschedules the work (based on your BackoffCriteria), it starts a new service instance. This effectively resets the OS-level 6-hour timer.

2. Atomic Batches

By processing data in loops rather than streams, we create natural "exit points." If the OS delivers a termination signal (via onStopped), or if our manual timer expires, we are never more than one batch away from a safe save state.

3. Cooperative Multitasking

Calling yield() inside the loop allows the coroutine dispatcher to check for cancellation. This is essential for responsiveness. If the system needs to reclaim resources or applies a new thermal throttling policy, the worker responds immediately rather than hanging until a blocking IO operation finishes.

Handling the onTimeout Callback (Non-WorkManager)

If you are not using WorkManager and managing Service manually, you must override the new callback in your Service class.

// Only relevant for manual Service implementations, NOT WorkManager users
override fun onTimeout(startId: Int, fgsType: Int) {
    super.onTimeout(startId, fgsType)
    
    if (fgsType == ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) {
        // 1. Stop all active threads/coroutines immediately
        cancelScope()
        
        // 2. Save state synchronously if possible
        saveState()
        
        // 3. Stop the service to prevent a crash
        stopSelf() 
    }
}

Note: The system gives you only a few seconds after this callback before taking drastic action.

Testing for Timeout Compliance

You do not need to wait 6 hours to test this logic. Android provides ADB commands to simulate timeouts.

  1. Start your app and trigger the sync.
  2. Find your UID (User ID):
    adb shell dumpsys package com.your.package | grep userId
    
  3. Reduce the FGS timeout window (e.g., to 10 seconds):
    adb shell cmd activity set-fgs-timeout-duration 10000
    
    Note: This command availability varies by API level and manufacturer implementations on beta builds.

Alternatively, simply lower the MAX_RUN_TIME_MS constant in your Worker to 1 minute for local testing to verify the retry logic works correctly.

Conclusion

Android 15's dataSync limits are a clear signal: mobile devices are not servers. Long-running monolithic processes are now second-class citizens. By adopting a "store-and-forward" pattern with frequent checkpoints and leveraging WorkManager's retry mechanism, you comply with the new OS strictures while improving the resilience of your application against crashes and battery drain.