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:
- Timer Starts: The system initiates a strict countdown (default 6 hours).
- Runtime Limit: This timer accumulates even if the service is stopped and restarted, unless the app enters the "cached" state in between.
- The Trigger: Once the limit is breached,
onTimeoutis fired. - The Termination: If the service is still running a few seconds after
onTimeout, the system throws aForegroundServiceDidNotStartInTimeException(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.
- Start your app and trigger the sync.
- Find your UID (User ID):
adb shell dumpsys package com.your.package | grep userId - Reduce the FGS timeout window (e.g., to 10 seconds):
Note: This command availability varies by API level and manufacturer implementations on beta builds.adb shell cmd activity set-fgs-timeout-duration 10000
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.