If you've spent any real time building production apps with .NET MAUI, you already know the truth: background tasks are a pain. It's one of those areas where the "cross-platform" promise breaks down fast. You need to sync data, process uploads, or track location while the app is backgrounded — and .NET MAUI just doesn't give you a unified API for it. Each platform has its own rules, its own limitations, and its own quirks.
So, let's dive into what actually works. This guide walks through implementing background tasks on every .NET MAUI target platform — Android, iOS, and Windows — with real code you can drop into your projects. We'll cover WorkManager, foreground services, BGTaskScheduler, and a cross-platform abstraction to tie it all together.
Understanding the Background Execution Landscape
Before writing any code, it helps to understand why background task execution differs so much between platforms. Each OS manages background work based on its own priorities: battery life, memory pressure, and user experience. And honestly, they all take a different stance on how aggressive they are about killing your background work.
Android Background Execution Model
Android is the most generous of the bunch. It offers several mechanisms for background execution:
- WorkManager — the recommended API for deferrable, guaranteed background work that survives app restarts and device reboots
- Foreground Services — for long-running operations that display a persistent notification to the user
- AlarmManager — for exact-time scheduling of periodic tasks
- JobScheduler — the underlying API that WorkManager wraps on newer API levels
That said, it's not a free-for-all. Starting with Android 12 (API 31) and further tightened in Android 14 (API 34), Google has imposed stricter rules on foreground service types and background task behavior. Your AndroidManifest.xml must declare the exact foreground service type, and runtime permissions for notifications are required on Android 13+.
iOS Background Execution Model
iOS is a completely different story. Apple is significantly more restrictive about what your app can do in the background. You're limited to a set of predefined modes:
- BGAppRefreshTask — periodic background fetch, system-scheduled, no guaranteed timing
- BGProcessingTask — longer processing tasks that can optionally require external power or network
- Background location updates — continuous location tracking
- Audio playback, VoIP, and remote notifications — specific background modes declared in Info.plist
Here's the kicker: iOS will not wake your app for background work if the user has force-quit it. Background tasks run only when the app is suspended (not terminated). The OS decides when to execute your scheduled tasks based on battery level, usage patterns, and system load. You don't get a say in the timing.
Windows Background Execution Model
.NET MAUI on Windows uses WinUI 3, which has more limited background task support compared to the older UWP framework. Timer-triggered background tasks and in-process background services are really your main options here.
Android: Implementing WorkManager for Deferrable Tasks
WorkManager is the go-to API for scheduling background work that must be guaranteed to execute, even if the app exits or the device restarts. It handles backward compatibility internally, using JobScheduler on API 23+ and a combination of AlarmManager and BroadcastReceiver on older versions.
Let's build it step by step.
Step 1: Add the NuGet Package
Add the Xamarin.AndroidX.Work.Runtime NuGet package to your .NET MAUI project. This provides the C# bindings for Android WorkManager.
dotnet add package Xamarin.AndroidX.Work.Runtime
Step 2: Create a Worker Class
Define a class that extends Worker and override the DoWork method. This runs on a background thread provided by WorkManager.
// Platforms/Android/Workers/DataSyncWorker.cs
using Android.Content;
using AndroidX.Work;
namespace YourApp.Platforms.Android.Workers;
public class DataSyncWorker : Worker
{
public const string TAG = "DataSyncWorker";
public DataSyncWorker(Context context, WorkerParameters workerParams)
: base(context, workerParams) { }
public override Result DoWork()
{
try
{
// Perform your background work here
// e.g., sync data with a remote API
SyncDataToServer();
return Result.InvokeSuccess();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"DataSyncWorker failed: {ex.Message}");
return Result.InvokeRetry();
}
}
private void SyncDataToServer()
{
// Your synchronous data sync logic
// Important: DoWork() is synchronous — do not use async/await here.
// If you need async, use ListenableWorker instead.
var httpClient = new HttpClient();
var response = httpClient.GetAsync("https://api.example.com/sync")
.GetAwaiter().GetResult();
// Process response...
}
}
Important: The DoWork method is synchronous. Android's documentation explicitly states that you should do all background work in a blocking fashion. If you need async support, extend ListenableWorker instead of Worker. I've seen folks burn hours trying to get async/await working inside DoWork — don't make that mistake.
Step 3: Schedule the Work Request
Now create a service class that enqueues work requests with WorkManager. You can schedule one-time or periodic work with constraints like network availability or battery level.
// Platforms/Android/Services/BackgroundWorkScheduler.cs
using AndroidX.Work;
namespace YourApp.Platforms.Android.Services;
public static class BackgroundWorkScheduler
{
public static void ScheduleOneTimeSync()
{
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.SetRequiresBatteryNotLow(true)
.Build();
var workRequest = new OneTimeWorkRequest.Builder(typeof(Workers.DataSyncWorker))
.SetConstraints(constraints)
.AddTag(Workers.DataSyncWorker.TAG)
.Build();
WorkManager.GetInstance(global::Android.App.Application.Context)
.EnqueueUniqueWork(
Workers.DataSyncWorker.TAG,
ExistingWorkPolicy.Keep,
workRequest);
}
public static void SchedulePeriodicSync(int intervalMinutes = 60)
{
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build();
var workRequest = new PeriodicWorkRequest
.Builder(typeof(Workers.DataSyncWorker),
TimeSpan.FromMinutes(intervalMinutes))
.SetConstraints(constraints)
.AddTag(Workers.DataSyncWorker.TAG)
.Build();
WorkManager.GetInstance(global::Android.App.Application.Context)
.EnqueueUniquePeriodicWork(
Workers.DataSyncWorker.TAG,
ExistingPeriodicWorkPolicy.Keep,
workRequest);
}
}
One thing to keep in mind: Android enforces a minimum periodic interval of 15 minutes for PeriodicWorkRequest. Pass in anything shorter and it'll get rounded up silently.
Step 4: Trigger from Shared Code with Conditional Compilation
// In your ViewModel or service
public void StartBackgroundSync()
{
#if ANDROID
Platforms.Android.Services.BackgroundWorkScheduler.SchedulePeriodicSync(30);
#elif IOS
// iOS implementation (see next section)
#endif
}
Android: Implementing Foreground Services for Long-Running Tasks
Foreground Services are for when you have a long-running operation that the user should know about — think music playback, file downloads, or continuous location tracking. Unlike WorkManager tasks, foreground services display a persistent notification and run with higher priority.
Step 1: Declare Permissions in AndroidManifest.xml
<!-- Platforms/Android/AndroidManifest.xml -->
<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" />
On Android 13+ (API 33), you'll also need to request the POST_NOTIFICATIONS permission at runtime before the notification appears. Easy to forget, and your service will just silently not show anything.
Step 2: Create the Foreground Service Class
// Platforms/Android/Services/DataSyncForegroundService.cs
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;
namespace YourApp.Platforms.Android.Services;
[Service(ForegroundServiceType = ForegroundService.TypeDataSync)]
public class DataSyncForegroundService : Service
{
private const string ChannelId = "data_sync_channel";
private const int NotificationId = 1001;
private CancellationTokenSource _cts;
public override void OnCreate()
{
base.OnCreate();
_cts = new CancellationTokenSource();
}
public override StartCommandResult OnStartCommand(
Intent intent, StartCommandFlags flags, int startId)
{
CreateNotificationChannel();
var notification = new NotificationCompat.Builder(this, ChannelId)
.SetContentTitle("Data Sync")
.SetContentText("Syncing your data in the background...")
.SetSmallIcon(Resource.Mipmap.appicon)
.SetOngoing(true)
.Build();
StartForeground(NotificationId, notification);
// Run the actual work on a background thread
Task.Run(async () =>
{
try
{
await PerformLongRunningWorkAsync(_cts.Token);
}
finally
{
StopForeground(StopForegroundFlags.Remove);
StopSelf();
}
});
return StartCommandResult.Sticky;
}
private async Task PerformLongRunningWorkAsync(CancellationToken token)
{
// Your long-running async work here
using var httpClient = new HttpClient();
// e.g., upload files, sync large datasets
await Task.Delay(TimeSpan.FromSeconds(30), token);
}
private void CreateNotificationChannel()
{
if (Build.VERSION.SdkInt < BuildVersionCodes.O) return;
var channel = new NotificationChannel(
ChannelId,
"Data Synchronization",
NotificationImportance.Low)
{
Description = "Background data sync notifications"
};
var manager = (NotificationManager)GetSystemService(NotificationService);
manager?.CreateNotificationChannel(channel);
}
public override void OnDestroy()
{
_cts?.Cancel();
base.OnDestroy();
}
public override IBinder OnBind(Intent intent) => null;
}
Step 3: Start and Stop the Foreground Service
// Start the service
public static void StartDataSync()
{
#if ANDROID
var intent = new Android.Content.Intent(
Android.App.Application.Context,
typeof(Platforms.Android.Services.DataSyncForegroundService));
Android.App.Application.Context.StartForegroundService(intent);
#endif
}
// Stop the service
public static void StopDataSync()
{
#if ANDROID
var intent = new Android.Content.Intent(
Android.App.Application.Context,
typeof(Platforms.Android.Services.DataSyncForegroundService));
Android.App.Application.Context.StopService(intent);
#endif
}
iOS: Implementing BGTaskScheduler for Scheduled Background Work
On iOS, BGTaskScheduler is the modern way to schedule background work. It replaces the older background fetch API and gives you two task types: BGAppRefreshTask for short periodic updates and BGProcessingTask for longer operations.
Fair warning — this is where things get a bit frustrating. iOS gives you very little control over when your tasks actually run.
Step 1: Configure Info.plist
Declare your background task identifiers and enable the required background modes:
<!-- Platforms/iOS/Info.plist -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourcompany.yourapp.datasync</string>
<string>com.yourcompany.yourapp.dbcleanup</string>
</array>
Step 2: Register Tasks in AppDelegate
Register your background tasks in FinishedLaunching — and this must happen before the app finishes launching. Miss this window and your tasks won't get registered.
// Platforms/iOS/AppDelegate.cs
using BackgroundTasks;
using Foundation;
using UIKit;
namespace YourApp.Platforms.iOS;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
private const string RefreshTaskId = "com.yourcompany.yourapp.datasync";
private const string ProcessingTaskId = "com.yourcompany.yourapp.dbcleanup";
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool FinishedLaunching(
UIApplication application, NSDictionary launchOptions)
{
// Register the background refresh task
BGTaskScheduler.Shared.Register(
RefreshTaskId,
null,
task => HandleAppRefresh((BGAppRefreshTask)task));
// Register a longer processing task
BGTaskScheduler.Shared.Register(
ProcessingTaskId,
null,
task => HandleProcessingTask((BGProcessingTask)task));
return base.FinishedLaunching(application, launchOptions);
}
private void HandleAppRefresh(BGAppRefreshTask task)
{
// Schedule the next refresh
ScheduleAppRefresh();
var cts = new CancellationTokenSource();
task.ExpirationHandler = () =>
{
cts.Cancel();
task.SetTaskCompleted(false);
};
Task.Run(async () =>
{
try
{
await PerformDataSyncAsync(cts.Token);
task.SetTaskCompleted(true);
}
catch
{
task.SetTaskCompleted(false);
}
});
}
private void HandleProcessingTask(BGProcessingTask task)
{
ScheduleDatabaseCleanup();
var cts = new CancellationTokenSource();
task.ExpirationHandler = () =>
{
cts.Cancel();
task.SetTaskCompleted(false);
};
Task.Run(async () =>
{
try
{
await PerformDatabaseCleanupAsync(cts.Token);
task.SetTaskCompleted(true);
}
catch
{
task.SetTaskCompleted(false);
}
});
}
public static void ScheduleAppRefresh()
{
var request = new BGAppRefreshTaskRequest(RefreshTaskId)
{
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(15 * 60)
};
BGTaskScheduler.Shared.Submit(request, out var error);
if (error != null)
System.Diagnostics.Debug.WriteLine(
$"Could not schedule app refresh: {error}");
}
public static void ScheduleDatabaseCleanup()
{
var request = new BGProcessingTaskRequest(ProcessingTaskId)
{
RequiresNetworkConnectivity = false,
RequiresExternalPower = false,
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(60 * 60)
};
BGTaskScheduler.Shared.Submit(request, out var error);
if (error != null)
System.Diagnostics.Debug.WriteLine(
$"Could not schedule processing task: {error}");
}
private async Task PerformDataSyncAsync(CancellationToken token)
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(
"https://api.example.com/sync", token);
// Process the response...
}
private async Task PerformDatabaseCleanupAsync(CancellationToken token)
{
// Clean up old records, compact the database, etc.
await Task.Delay(5000, token);
}
}
Step 3: Schedule the Initial Background Task
Call the scheduling methods when your app enters the background. The Backgrounding lifecycle event is a good spot:
// App.xaml.cs or wherever you handle lifecycle
public partial class App : Application
{
protected override Window CreateWindow(IActivationState activationState)
{
var window = base.CreateWindow(activationState);
window.Backgrounding += (s, e) =>
{
#if IOS
Platforms.iOS.AppDelegate.ScheduleAppRefresh();
#endif
};
return window;
}
}
Testing iOS Background Tasks in the Simulator
Apple provides a debugger command to force-trigger background tasks during development. In the Xcode debugger console, pause the app and run:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourcompany.yourapp.datasync"]
This forces the system to execute your registered background task immediately. It's essential for testing since you have zero control over when iOS normally schedules them. Without this, you'd be staring at the screen hoping the OS decides to run your task sometime today.
Windows: Background Execution with Timer-Based Services
.NET MAUI on Windows uses WinUI 3, and the background task story here is... limited. The most practical approach for periodic background work is using Microsoft.Extensions.Hosting with a BackgroundService, since the Windows app process stays alive while the app is open.
// Services/WindowsBackgroundSyncService.cs
using Microsoft.Extensions.Hosting;
namespace YourApp.Services;
public class WindowsBackgroundSyncService : BackgroundService
{
private readonly TimeSpan _interval = TimeSpan.FromMinutes(15);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
while (!stoppingToken.IsCancellationRequested
&& await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await PerformSyncAsync(stoppingToken);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"Background sync failed: {ex.Message}");
}
}
}
private async Task PerformSyncAsync(CancellationToken token)
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(
"https://api.example.com/sync", token);
// Process response...
}
}
Register it in MauiProgram.cs with conditional compilation:
// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
#if WINDOWS
builder.Services.AddHostedService<Services.WindowsBackgroundSyncService>();
#endif
return builder.Build();
}
Big caveat: This only works while the app window is open. When the user closes the app, the hosted service stops. For true background execution after app close on Windows, you'd need a separate Windows Service process or a scheduled task registered with the Windows Task Scheduler. It's not ideal, but that's where WinUI 3 stands right now.
Building a Cross-Platform Abstraction
Each platform requires its own implementation, but you can (and should) create a thin abstraction layer that your shared code interacts with. This keeps your ViewModels and services clean and testable.
// Services/IBackgroundTaskService.cs
namespace YourApp.Services;
public interface IBackgroundTaskService
{
void SchedulePeriodicSync(int intervalMinutes);
void ScheduleOneTimeTask(string taskId);
void CancelTask(string taskId);
}
Then implement it per platform:
// Platforms/Android/Services/AndroidBackgroundTaskService.cs
#if ANDROID
using AndroidX.Work;
namespace YourApp.Services;
public class AndroidBackgroundTaskService : IBackgroundTaskService
{
public void SchedulePeriodicSync(int intervalMinutes)
{
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build();
var request = new PeriodicWorkRequest
.Builder(typeof(Platforms.Android.Workers.DataSyncWorker),
TimeSpan.FromMinutes(intervalMinutes))
.SetConstraints(constraints)
.Build();
WorkManager.GetInstance(global::Android.App.Application.Context)
.EnqueueUniquePeriodicWork(
"periodic_sync",
ExistingPeriodicWorkPolicy.Update,
request);
}
public void ScheduleOneTimeTask(string taskId)
{
var request = new OneTimeWorkRequest.Builder(
typeof(Platforms.Android.Workers.DataSyncWorker))
.AddTag(taskId)
.Build();
WorkManager.GetInstance(global::Android.App.Application.Context)
.Enqueue(request);
}
public void CancelTask(string taskId)
{
WorkManager.GetInstance(global::Android.App.Application.Context)
.CancelAllWorkByTag(taskId);
}
}
#endif
Register the correct implementation using dependency injection in MauiProgram.cs:
#if ANDROID
builder.Services.AddSingleton<IBackgroundTaskService, AndroidBackgroundTaskService>();
#elif IOS
builder.Services.AddSingleton<IBackgroundTaskService, iOSBackgroundTaskService>();
#elif WINDOWS
builder.Services.AddSingleton<IBackgroundTaskService, WindowsBackgroundTaskService>();
#endif
Using Shiny.NET for Simplified Background Jobs
If writing all that platform-specific code feels like too much, Shiny.NET is worth a look. It provides a cross-platform jobs API for .NET MAUI and handles WorkManager on Android and BGTaskScheduler on iOS internally.
Step 1: Install the Package
dotnet add package Shiny.Jobs
Step 2: Define a Job
using Shiny.Jobs;
namespace YourApp.Jobs;
public class SyncJob : Job
{
public SyncJob() : base("SyncJob") { }
protected override async Task Run(CancellationToken cancelToken)
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(
"https://api.example.com/sync", cancelToken);
// Process the sync response
}
}
Step 3: Register in MauiProgram.cs
using Shiny;
builder.Services.AddJob(typeof(SyncJob),
identifier: "sync_job",
requiredNetwork: Shiny.Jobs.InternetAccess.Any,
runInForeground: true);
A few important caveats though: Shiny.NET jobs are executed by the operating system on its own schedule. There's no guarantee of exact timing — the OS decides when to run them based on battery state, network conditions, and user patterns. Also, Shiny.NET doesn't currently support Windows background jobs, so you'll still need a platform-specific solution there.
Best Practices for Background Tasks in .NET MAUI
Battery and Resource Efficiency
- Batch your work — combine multiple small operations into a single background task execution to minimize wake-ups
- Use constraints — set network type, battery level, and charging state requirements on WorkManager tasks
- Respect the user — don't schedule background tasks more frequently than necessary. Both Android and iOS will throttle apps that are too aggressive, and that's a problem you don't want
Error Handling and Retry Logic
- Return
Result.InvokeRetry()from a WorkManager Worker when a transient failure occurs — WorkManager handles exponential backoff automatically, which is genuinely nice - On iOS, always set the
ExpirationHandleron yourBGTaskto gracefully cancel in-progress work when the system reclaims your time allocation - Never leave a
BGTaskwithout callingSetTaskCompleted— the system will penalize your app if tasks time out without reporting completion. I've seen this cause apps to stop getting background time entirely
Testing Strategies
- Android: Use
adb shell cmd jobscheduler runto force-trigger WorkManager jobs during development - iOS: Use the Xcode debugger
_simulateLaunchForTaskWithIdentifiercommand to trigger BGTasks - Always test on physical devices — emulators and simulators don't accurately replicate OS-level background task scheduling
- Test with battery saver mode enabled to verify your tasks still execute under restricted conditions
When to Use Each Approach
| Scenario | Android | iOS | Windows |
|---|---|---|---|
| Periodic data sync (minutes to hours) | WorkManager Periodic | BGAppRefreshTask | BackgroundService timer |
| One-time deferred task | WorkManager OneTime | BGProcessingTask | BackgroundService |
| Long-running with user awareness | Foreground Service | Not supported natively | BackgroundService |
| Exact-time scheduling | AlarmManager | Push Notification trigger | Windows Task Scheduler |
| Must survive app kill | WorkManager | BGTask (suspended only) | External service |
Frequently Asked Questions
Can .NET MAUI run background tasks when the app is completely closed?
It depends on the platform. On Android, WorkManager tasks and foreground services survive app termination and device reboots — they're pretty resilient. On iOS, background tasks only run when the app is suspended. If the user force-quits your app from the app switcher, iOS won't wake it for background execution. Period. On Windows with WinUI 3, background processing stops when the app process ends. For true post-termination execution, you need platform-native solutions outside of the MAUI app process.
Why doesn't Microsoft.Extensions.Hosting BackgroundService work on iOS?
iOS enforces strict rules about what code can execute in the background. The BackgroundService from Microsoft.Extensions.Hosting runs on a standard .NET thread, but iOS suspends all app threads when the app moves to the background. Only system-registered background tasks via BGTaskScheduler are allowed to execute. You have to use Apple's native background task APIs — there's no workaround for this one.
What is the minimum interval for periodic background tasks?
On Android, WorkManager enforces a minimum periodic interval of 15 minutes. On iOS, you have essentially no control over the scheduling interval at all — EarliestBeginDate is just a hint, and the system decides when to actually run your task based on device conditions and user behavior. In practice, iOS background refresh tasks may run anywhere from 15 minutes to several hours apart. It's unpredictable, and you need to design around that.
Should I use Shiny.NET or write platform-specific code directly?
Shiny.NET is a solid choice if you want faster development and your requirements fit within its capabilities (periodic jobs with network and foreground constraints). Write platform-specific code directly when you need fine-grained control over foreground service notifications, exact scheduling, or Windows support — areas where Shiny.NET has limitations.
Honestly, many production apps use a hybrid approach: Shiny.NET for the simple periodic sync jobs and platform-specific code for the specialized stuff. That's probably the most pragmatic path.
How do I debug background tasks that only run intermittently?
Both Android and iOS provide developer tools to force-trigger background tasks. On Android, use adb shell cmd jobscheduler run -f <your-package> <job-id>. On iOS, use the Xcode debugger command _simulateLaunchForTaskWithIdentifier. Beyond that, add comprehensive logging (using System.Diagnostics.Debug.WriteLine or something like Serilog) so you can review what happened during background execution. And I can't stress this enough: always test on physical devices. Simulators just don't replicate real-world OS scheduling behavior.