DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Background Work & Task Scheduling (Production Architecture)

Background work is where many SwiftUI apps quietly fail.

Symptoms:

  • sync stops when app closes
  • notifications arrive late
  • background refresh never fires
  • battery drains
  • tasks get killed
  • “it works on simulator” lies

SwiftUI doesn’t change how background execution works — it just makes it easier to ignore.

This post shows how to design reliable, battery-safe background work architecture in SwiftUI:

  • background refresh
  • processing tasks
  • silent pushes
  • task coordination
  • state recovery
  • real-world constraints

No myths. No magic. Just how it actually works.


🧠 The Core Principle

Background execution is a contract, not a guarantee.

The system decides:

  • when
  • if
  • how long
  • with what priority

Your job is to prepare work, not demand it.


🧱 1. The Three Background Mechanisms

1. Background App Refresh

  • periodic
  • opportunistic
  • short execution window

2. Background Processing Tasks

  • longer running
  • requires power + network
  • scheduled by the system

3. Silent Push Notifications

  • server-triggered
  • immediate (sometimes)
  • unreliable delivery

You usually need all three.


⚙️ 2. Register Background Tasks

In AppDelegate or app entry:

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.app.refresh",
    using: nil
) { task in
    handleRefresh(task: task as! BGAppRefreshTask)
}
Enter fullscreen mode Exit fullscreen mode
BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.app.processing",
    using: nil
) { task in
    handleProcessing(task: task as! BGProcessingTask)
}
Enter fullscreen mode Exit fullscreen mode

Identifiers must be in:

  • Info.plist
  • App Capabilities

🔄 3. Scheduling Tasks

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    try? BGTaskScheduler.shared.submit(request)
}
Enter fullscreen mode Exit fullscreen mode
func scheduleProcessing() {
    let request = BGProcessingTaskRequest(identifier: "com.app.processing")
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false
    try? BGTaskScheduler.shared.submit(request)
}
Enter fullscreen mode Exit fullscreen mode

You request. The system decides.


🧩 4. Handling the Task

func handleRefresh(task: BGAppRefreshTask) {
    scheduleRefresh() // Always reschedule

    let operation = Task {
        await syncData()
    }

    task.expirationHandler = {
        operation.cancel()
    }

    Task {
        await operation.value
        task.setTaskCompleted(success: true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Key rules:

  • always reschedule
  • always handle expiration
  • always call setTaskCompleted

🧠 5. Background Work Architecture

Never do work directly in the handler.

Instead:

func syncData() async {
    await repository.sync()
}
Enter fullscreen mode Exit fullscreen mode

Your background task calls the same code path as:

  • manual refresh
  • pull-to-refresh
  • app launch sync

One pipeline. Multiple triggers.


🌐 6. Silent Push Triggers

Payload:

{
  "aps": {
    "content-available": 1
  },
  "type": "sync"
}
Enter fullscreen mode Exit fullscreen mode

Handler:

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable : Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    Task {
        await syncData()
        completionHandler(.newData)
    }
}
Enter fullscreen mode Exit fullscreen mode

Never rely on silent pushes alone.
They are best-effort.


🔋 7. Battery & Priority Design

Not all work is equal.

Define priority:

enum SyncPriority {
    case critical
    case normal
    case low
}
Enter fullscreen mode Exit fullscreen mode

Then:

func sync(priority: SyncPriority) async {
    switch priority {
    case .critical:
        await performCriticalSync()
    case .normal:
        await performNormalSync()
    case .low:
        break // defer
    }
}
Enter fullscreen mode Exit fullscreen mode

Background tasks should never do everything.


🧬 8. Partial Sync & Checkpointing

Never assume you’ll finish.

Design for interruption:

func syncStep1() async throws { }
func syncStep2() async throws { }
func syncStep3() async throws { }
Enter fullscreen mode Exit fullscreen mode

Checkpoint:

saveProgress(step: 2)
Enter fullscreen mode Exit fullscreen mode

On next run, resume.

This is how large syncs survive background limits.


🧪 9. Testing Background Behavior

Simulate in Xcode:

  • Debug > Simulate Background Fetch
  • Debug > Simulate Silent Push

Also test:

  • low battery
  • low signal
  • airplane mode
  • app force quit

Background code that only works on Wi-Fi + power is broken.


❌ 10. Common Anti-Patterns

Avoid:

  • doing heavy work in view .task
  • assuming background always runs
  • blocking the main thread
  • not handling expiration
  • not rescheduling tasks
  • syncing everything every time
  • ignoring battery impact

These cause:

  • kills
  • throttling
  • App Store rejections

🧠 Mental Model

Think:

Trigger (system / push / user)
 → Task Request
   → Limited Execution Window
     → Small, Critical Work
       → Checkpoint
Enter fullscreen mode Exit fullscreen mode

Not:

“Run my sync in background”


🚀 Final Thoughts

A real background architecture gives you:

  • reliable sync
  • timely updates
  • better battery life
  • fewer data bugs
  • happier users

Background work is not a feature.
It’s infrastructure.

Design it like one.

Top comments (0)