My Daily Wallpaper Widget Stopped Updating — Measuring WidgetKit's Reload Budget and Rebuilding the Design
A widget that was supposed to rotate the wallpaper every day froze after a few days. Here is how I measured WidgetKit's timeline reload budget and extension memory limit, then rebuilt the design around a single daily timeline.
I added a feature to my wallpaper app that rotates the home-screen widget image every day. In the simulator it behaved beautifully. On a real device left running for a few days, the widget froze one morning — the same image, hour after hour, never changing. Opening the app fixed it. Closing it and waiting brought the freeze back.
My first guess was a caching bug or a bad date calculation. But the cause was not a coding mistake. As an indie developer running this widget for real, I was hitting WidgetKit's "reload budget" — a hard limit that appears nowhere on screen — every single day. It is a place you reach the moment you take widgets seriously, so I want to leave behind the numbers I measured and the way I rebuilt the design.
Widgets Are Not "Alive"
There is one misconception worth clearing up first. A widget is not a small app running continuously. What you see is a still frame the OS draws from a prepared "timeline."
A timeline is an array of TimelineEntry values, each one a declaration: "at this time, show this content." The system calls getTimeline(in:completion:), receives the array, and swaps the view when each entry's date arrives. Your code does not run on every swap. The moment you hand back the array, everything downstream belongs to the OS.
struct WallpaperProvider: TimelineProvider { func placeholder(in context: Context) -> WallpaperEntry { WallpaperEntry(date: Date(), assetID: "placeholder") } func getSnapshot(in context: Context, completion: @escaping (WallpaperEntry) -> Void) { completion(WallpaperEntry(date: Date(), assetID: currentAssetID())) } func getTimeline(in context: Context, completion: @escaping (Timeline<WallpaperEntry>) -> Void) { // Build the whole array here. The number of calls is what spends the budget. let entries = buildDailyEntries(from: Date()) completion(Timeline(entries: entries, policy: .atEnd)) }}
So if you translate "change the wallpaper daily" into "call getTimeline daily to fetch a new image," you have already taken a wrong turn. That is exactly the turn I took first.
The Invisible Limit Called Reload Budget
There are three main triggers that call getTimeline again: the timeline's reloadPolicy expiring, the app calling WidgetCenter.shared.reloadTimelines(ofKind:), and the system deciding on its own.
Background reloads among these draw from a per-widget daily budget. Apple does not publish the exact figure, but for frequently visible widgets a range of roughly 40–70 reloads per day shows up repeatedly, both in the developer community and in my own measurements. The budget replenishes gradually over the day.
My first implementation called reloadTimelines on every foreground and on a one-hour timer. The timeline itself was a short "one entry now plus .atEnd an hour later." The result was around 120 background reloads requested per day. I burned through the budget by late morning, and the OS quietly deferred reloads for the rest of the day. That was the real cause of "freezes one morning."
There is no API that tells you whether the budget is exhausted. No error fires. The image simply goes stale. The nastiest part of this trap is that the budget is generous on the simulator and on a device during active development, so it only surfaces after the app has been left alone for a while.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Switching from per-request reloads to a 24-hour timeline cut background reloads from roughly 120/day to 3/day in my own measurements
✦Fixing the ~30MB widget extension memory ceiling that killed full-resolution wallpapers, dropping decoded memory from ~38MB to 0.4MB with ImageIO
✦Instrumentation that indirectly surfaces the OS reload budget by counting getTimeline calls in an App Group container
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The core of the fix was moving from "reload as many times as I want to change" to "declare a full day of entries in one reload." If I want the wallpaper to switch at 6:00, 12:00, 18:00, and 22:00, I hand back all four entries in the first getTimeline. The switching itself is done by the OS reading each entry's date, so my code runs only a few times a day.
func buildDailyEntries(from now: Date) -> [WallpaperEntry] { let calendar = Calendar.current let switchHours = [6, 12, 18, 22] let playlist = WallpaperStore.shared.todaysPlaylist() // already fetched from App Group var entries: [WallpaperEntry] = [] for (index, hour) in switchHours.enumerated() { guard let date = calendar.date( bySettingHour: hour, minute: 0, second: 0, of: now ) else { continue } // Skip past times, keep only future switches if date < now && index < switchHours.count - 1 { continue } let asset = playlist[index % playlist.count] entries.append(WallpaperEntry(date: max(date, now), assetID: asset.id)) } return entries}
The key is that each entry carries a light reference like assetID, never the heavy image itself. The real image is read from a local file in the App Group container at draw time. Keeping entries light means a 24-hour array serializes without trouble.
After this change, background reloads dropped to around 3 per day in my measurements. With .atEnd, once the last entry (22:00) passes, the next day's batch is fetched — that is all. The budget always had headroom, and the afternoon freeze disappeared.
Choosing a TimelineReloadPolicy
The reloadPolicy choice is, in effect, how you spend the budget. Here are the three options framed for real operation.
Policy
Reload trigger
Fit for a wallpaper widget
.atEnd
When the last entry's date passes
The default choice. Stack a day and fetch the next batch at the tail
.after(date)
When the specified date passes
When you want updates pinned to a time like the next morning
.never
Only an explicit reload from the app
When you only update at a real event, like a new pack purchase
I settled on .atEnd for normal operation, firing a single reloadTimelines(ofKind:) from the app only when the user obtains a new wallpaper pack. I did not go with .never alone because, if the app stays unopened for a long stretch, the widget would grow stale forever. Building on .atEnd means it self-heals on at least a daily cycle.
What you want to avoid is firing a reload unconditionally on every foreground return. That is the sloppiest way to drain the budget, and it was my own typical production failure at first. Reloads should be limited to "when the content genuinely changed."
The Extension Memory Ceiling and Image Downsampling
Even after taming the reload count, another wall remained. A widget extension runs under a far stricter memory limit than the host app. It varies by device, but it sits around 30MB, and exceeding it kills the extension before it draws — the widget freezes on a blank or placeholder.
Wallpaper assets are full resolution. Reading a roughly 4000×2400 image straight with UIImage(contentsOfFile:) balloons to about 38MB once decoded, almost certainly past the ceiling. The simulator has plenty of memory, so once again this only shows on a device.
The fix is to downsample to the size you actually display at load time. Shrinking a UIImage after creating it is too late; you thin out the decode itself with ImageIO.
import ImageIOimport UIKitfunc downsampledImage(at url: URL, pointSize: CGSize, scale: CGFloat) -> UIImage? { let options = [kCGImageSourceShouldCache: false] as CFDictionary guard let source = CGImageSourceCreateWithURL(url as CFURL, options) else { return nil } let maxPixel = Int(max(pointSize.width, pointSize.height) * scale) let thumbOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxPixel ] as CFDictionary guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions) else { return nil } return UIImage(cgImage: cgImage)}
A widget's display area is at most a few hundred points square, even on the home screen. Matching kCGImageSourceThumbnailMaxPixelSize to that real size brought decoded memory down to around 0.4MB in my measurements. From about 38MB to 0.4MB — two orders of magnitude. The terminations stopped. Setting kCGImageSourceShouldCacheImmediately to true, pushing the memory peak to draw time, helped as well.
Supply Images From the App — App Group and Background Refresh
It is tempting to download new wallpapers inside the widget extension, but avoid it. The execution window given to getTimeline is very short, and if you take on network latency, the extension can time out before returning the array.
In my setup, fetching and swapping images is entirely the host app's responsibility. The app prepares the next day's playlist periodically with a BGAppRefreshTask and saves the images locally to the shared App Group container. The widget only reads those local files.
import BackgroundTasksfunc scheduleWallpaperRefresh() { let request = BGAppRefreshTaskRequest(identifier: "design.dolice.wallpaper.refresh") request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 60 * 60) try? BGTaskScheduler.shared.submit(request)}func handleRefresh(_ task: BGAppRefreshTask) { scheduleWallpaperRefresh() // always re-schedule the next run first let work = Task { await WallpaperStore.shared.stageTomorrowPlaylist() // save to the shared container WidgetCenter.shared.reloadTimelines(ofKind: "WallpaperWidget") } task.expirationHandler = { work.cancel() }}
BGAppRefreshTask launches at the OS's discretion too, so it will not reliably run every morning. That is exactly why I build the widget side to self-heal with .atEnd and treat background refresh as a helper that "makes it fresh sooner if it runs." If either side slips, the display does not stop completely — a redundancy. In production, this "neither failure is fatal" assumption mattered most.
Measure Before You Decide — Surfacing the Reload Count
None of these numbers are guesses; I decided after measuring. You cannot read the OS budget remaining, but you can count how often your own getTimeline is called. I incremented a counter in the App Group UserDefaults, bucketed by date.
func recordTimelineReload() { let defaults = UserDefaults(suiteName: "group.design.dolice.wallpaper")! let key = "reloadCount_" + ISO8601DateFormatter.dayString(from: Date()) let count = defaults.integer(forKey: key) + 1 defaults.set(count, forKey: key)}
I had Antigravity write this instrumentation and the per-day summary view in a hidden debug section, handing it the intent: "count getTimeline calls into the App Group container by date, and list them in a hidden settings section." I then watched that output for a few days and made the call to drop the foreground-triggered reloads. More than the implementation itself, surfacing the evidence to decide on was the valuable part this time.
The tallies changed like this.
Metric
Before rebuild
After rebuild
Background reloads (measured, daily average)
~120
~3
Afternoon freeze frequency
Almost daily
Not observed
Extension decoded memory
~38MB (terminated)
~0.4MB
The figures are measurements on my own device and assets, and they shift with device and widget size. But two facts hold in any environment: reloads can be counted, and memory can be cut by orders of magnitude. Those become the footing for your decisions.
Where I Landed in Practice
The final guidance converged on something plain. Treat reloads as a finite resource called budget, and declare every future you can in a single timeline. Downsample images at load time to keep extension memory low from the start. Push fetching to the app side and let the widget only read local files. And always measure what you changed before the next decision.
If you are building a similarly daily-updating widget, I recommend just one first step. Drop a counter at the top of getTimeline and simply watch it for a few days. Once you know how many reloads you request per day, the design conversation turns concrete fast. Do not trust the comfort of the simulator; leave a real device alone for a few days and take the numbers. From there, the right calls tend to reveal themselves.
I hope this helps anyone stuck in the same place.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.