Energy-efficient network request batching for iOS
A Swift Package that reduces battery drain by intelligently batching non-essential network requests (analytics, telemetry, crash reports) and transmitting them when conditions are optimal.
Modern iOS apps are riddled with constant network polling from multiple SDKs:
| SDK Type | Examples | Typical Frequency |
|---|---|---|
| Analytics | Firebase, Amplitude, Mixpanel | 1-10 events/minute |
| Crash Reporting | Crashlytics, Sentry, Bugsnag | On crash + heartbeats |
| A/B Testing | LaunchDarkly, Optimizely | Config fetches |
| Attribution | Branch, AppsFlyer, Adjust | Events + attribution |
| Ads | AdMob, Facebook, ironSource | Impressions + events |
Each request wakes the cellular radio, which has a "tail energy" problem:
Radio Wake Cycle:
┌─────────┬──────────────┬───────────────────────────┬─────────┐
│ Ramp │ Active │ Tail (Idle) │ Sleep │
│ ~2s │ (data) │ 5-10 seconds │ │
└─────────┴──────────────┴───────────────────────────┴─────────┘
↑ ↑
High power Still high power!
A 50-byte analytics ping costs the same energy as a 500KB download.
With 5 SDKs sending 1 event/minute each, you get:
- 300 radio wake-ups per hour
- ~50 minutes of radio activity per hour
- Significant battery drain from "idle" apps
NetworkBatcher reduces this to 12 batch transmissions per hour - a 95% reduction in radio activity.
Before NetworkBatcher After NetworkBatcher
──────────────────── ────────────────────
│▌│▌│▌│▌│▌│▌│▌│▌│▌│ │ ▐████│
300 wake-ups/hour 12 batches/hour
50 min radio/hour 3 min radio/hour
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/yourusername/NetworkBatcher.git", from: "1.0.0")
]Or in Xcode: File → Add Packages → Enter repository URL
import NetworkBatcher
// Enqueue analytics events for batched transmission
Task {
try await NetworkBatcher.shared.enqueue(
url: URL(string: "https://api.amplitude.com/2/httpapi")!,
method: "POST",
headers: ["Content-Type": "application/json"],
body: eventData,
priority: .deferrable
)
}
// Events are automatically sent when:
// - Device connects to WiFi
// - Device starts charging
// - Another network request warms up the radio
// - Queue reaches size/time limitsimport NetworkBatcher
import NetworkBatcherAnalytics
// Register your analytics providers
let amplitude = AmplitudeProvider(apiKey: "YOUR_API_KEY")
await BatchedAnalytics.shared.register(provider: amplitude)
// Track events - they're automatically batched!
await BatchedAnalytics.shared.track(
event: "button_tapped",
properties: ["screen": "home", "button": "signup"]
)// When user initiates network activity, notify the batcher
// This allows queued requests to piggyback on the warm radio
func fetchUserProfile() async throws -> Profile {
await NetworkBatcher.shared.notifyUserNetworkActivity()
return try await api.fetchProfile()
}// Before user logout or critical sync point
try await NetworkBatcher.shared.flush(reason: "User logout")var config = BatcherConfiguration.balanced
// Timing
config.maxDeferralTime = 15 * 60 // 15 minutes max wait
config.minBatchInterval = 60 // At least 1 min between batches
config.piggybackWindow = 5 // 5 sec window after user activity
// Size limits
config.maxQueueSize = 100 // Flush at 100 requests
config.maxPayloadSize = 100_000 // 100KB max batch
// Conditions
config.preferWiFi = true // Wait for WiFi when possible
config.preferCharging = true // Prefer charging state
config.piggybackOnUserRequests = true
config.flushOnBackground = true // Flush when app backgrounds
// Apply configuration
NetworkBatcher.shared.configuration = config// Aggressive battery saving
NetworkBatcher.shared.configuration = .batterySaver
// Default balanced approach
NetworkBatcher.shared.configuration = .balanced
// Minimal batching (fresher data)
NetworkBatcher.shared.configuration = .minimal| Priority | Max Delay | Use Case |
|---|---|---|
.immediate |
0 | Auth, payments, user-blocking requests |
.soon |
30s | Push token updates, config fetches |
.deferrable |
15min | Analytics, telemetry, crash reports |
.bulk |
60min | Large uploads, backups (WiFi only) |
.auto |
Varies | System classifies based on domain |
// Explicit priority
try await batcher.enqueue(url: analyticsURL, body: data, priority: .deferrable)
// Auto-classification (checks domain against known lists)
try await batcher.enqueue(url: analyticsURL, body: data, priority: .auto)NetworkBatcher automatically classifies requests based on domain:
api.stripe.com- Paymentsapi.apple.com- Apple servicesappleid.apple.com- Authentication
app-measurement.com- Firebase Analyticsapi.amplitude.com- Amplitudeapi.mixpanel.com- Mixpanelapi.segment.io- Segmentsentry.io- Sentryapi.branch.io- Branchevents.appsflyer.com- AppsFlyerapp.adjust.com- Adjust- And many more...
// Add your own deferrable domains
config.deferrableDomains.insert("analytics.mycompany.com")
// Add domains that must be immediate
config.immediateDomains.insert("critical-api.mycompany.com")// Get batching statistics
let stats = try await NetworkBatcher.shared.statistics()
print("Batches sent: \(stats.transmissionStats.batchCount)")
print("Total requests: \(stats.transmissionStats.totalRequests)")
print("Radio wake-ups saved: \(stats.transmissionStats.estimatedWakeUpsSaved)")
print("Est. energy saved: \(stats.estimatedEnergySavedPercent)%")
// Debug logging
#if DEBUG
NetworkBatcher.shared.configuration.enableLogging = true
await NetworkBatcher.shared.debugPrintStatus()
#endif┌─────────────────────────────────────────────────────────────────┐
│ Your App │
├─────────────────────────────────────────────────────────────────┤
│ Analytics SDK │ Crash Reporter │ Telemetry │ Your Code │
│ ↓ ↓ ↓ ↓ │
├─────────────────────────────────────────────────────────────────┤
│ NetworkBatcher │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Priority Classifier (immediate vs deferrable) │ │
│ │ ↓ │ │
│ │ Request Queue (SQLite-backed, persists across launches) │ │
│ │ ↓ │ │
│ │ Device Monitor (WiFi, charging, battery) │ │
│ │ ↓ │ │
│ │ Batch Scheduler (optimal transmission timing) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
├─────────────────────────────────────────────────────────────────┤
│ URLSession │
└─────────────────────────────────────────────────────────────────┘
Yes, NetworkBatcher is App Store compatible.
It works as an SDK within your app, batching requests that originate from your app. It does NOT:
- Intercept traffic from other apps
- Require VPN profiles
- Use private APIs
- Modify system behavior
NetworkBatcher automatically handles app lifecycle:
// Requests are flushed when app enters background
// Uses UIApplication background tasks for completion
// Persists queue to SQLite - survives terminationFor periodic background flushing:
// In AppDelegate
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.networkbatcher.flush",
using: nil
) { task in
Task {
try? await NetworkBatcher.shared.flush(reason: "Background refresh")
task.setTaskCompleted(success: true)
}
}Batching provides an unexpected privacy benefit:
| Without Batching | With Batching |
|---|---|
| Server sees exact timestamp of each action | Server sees batch arrival time only |
| Easy to correlate user behavior patterns | Timing information is anonymized |
- Real-time requirements: Some features genuinely need instant updates
- Crash reporting: Critical crashes should be sent immediately (use
.immediatepriority) - SDK initialization: Some SDKs expect immediate network access at launch
- Debugging: Delayed telemetry can complicate debugging (disable batching in DEBUG)
Apple has the pieces (isDiscretionary, QoS classes, background sessions) but they're:
- Per-app and opt-in
- Not used by most SDKs
- No system-wide batching across apps
NetworkBatcher fills this gap at the app level.
- Apple Energy Efficiency Guide
- TN2277: Networking and Multitasking
- WWDC 2014: Writing Energy Efficient Code
- "The Tail at Scale" - Google paper on server-side batching
MIT License - See LICENSE for details.
Contributions welcome! Please read CONTRIBUTING.md first.