Optimizing iOS Widget network calls with temporary caching

Jaeho Yoo
5 min readDec 24, 2023

--

My feeling about iOS Widgets…
I recommend you to read this post on a desktop.
There's an issue with GIFs not fully playing in the Medium app.

Have you ever developed an iOS Widget?

Not a simple widget using shared UserDefaults, but one that communicates with a server and displays the response every time it reloads. Such a widget can be engaging.

However, you might be surprised to see how many network requests your widgets are firing. In this article, I’ll show you how to make Widget-Server Networking more efficient.

Let me use five different widget families.

// Widget Configuration
.supportedFamilies(
[
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryRectangular,
.accessoryCircular
]
)

Using the Numbers API, I implemented a feature where each widget fetches random number fact every time it reloads as follows:

TimelineProvider

Each time the app is sent to the background, it triggers reloadAllTimelines as follows:

iOS Hosting App

To verify network activity, I use Proxyman.

No Widget

Without adding any widgets, no network requests are made.

OK. Let’s add a small widget and reload it again.

One Widget

As you can see, when I add a small widget from the widget gallery, an initial network request is made. Then, when I launch the app and send it to the background, the second request is made and the widget reloads.

So far, these results are as expected.

Please Note: For the test, I’ve hardcoded “getSnapshot” to only display text in the widget gallery. However, the getSnapshot function could be made to send network requests as well.

I’ll test again with three different home widgets:

  • systemSmall
  • systemMedium
  • systemLarge
Three Widgets

After reloading, three network requests are made, and you can see that each of the three widgets shows a different number fact.

Let’s keep pushing forward, adding two lock screen widgets.

  • accessoryRectangular
  • accessoryCircular
Five Widgets

Similarly, you can see that five network requests are made. If this is the intended behavior of your widget, that’s fine.

However, what if I want to create widgets that all share ‘single response’ and show it in different widget families? In other words, I want these five widgets to fire just one request and share the response. If so, it would greatly reduce the server load.

Let’s delve into.

First off, from what I’ve researched, there’s no way to make the getTimeline function be called only once when you added multiple widgets. getTimeline function is called for each widget family.

Please Note: If there are multiple widgets of the same family, they are treated as one.

To prevent excessive network requests, you should use caching. The logic is straightforward.

  1. Check if a cached data is exists and if it was cached ‘recently.’
  2. If there is data that meets #1, immediately return the cached data.
  3. If not, fire a new request.

Let’s take a look at the code.

final class NetworkManager {

/// Singleton instance of NetworkManager
static let shared = NetworkManager()

private init() { }

/// last fetched number fact
private var cachedNumberFact: String? {
didSet {
lastCachedDate = .now
}
}

/// Date when the last number fact was cached
private var lastCachedDate: Date?
}

Create a singleton named NetworkManager. This singleton requires two properties: cachedNumberFact and lastCachedDate.

Note the didSet block. This is a trick to record the date every time the cache is updated.

This allows developers to operate the cache for the duration they want. Personally, I recommend about a minute, but this can vary depending on your app’s business logic.

Next, add the methods.

/// Checks if the cached data was fetched within the last 60 seconds
private var hasCacheUnder60seconds: Bool {
if cachedNumberFact != nil, let lastCachedDate, Date.now.timeIntervalSince(lastCachedDate) < 60 {
return true
} else {
return false
}
}

/// Fetches a new number fact
/// returning cached data if available and fetched within the last 60 seconds
func fetchNumberFact() async -> String {
if let cachedNumberFact, hasCacheUnder60seconds {
return cachedNumberFact // return cached fact
}

let fact = await _fetchNumberFact()
self.cachedNumberFact = fact // update cache
return fact
}

/// Internal method to asynchronously fetch a new number fact from the numbers API
private func _fetchNumberFact() async -> String {
let url = URL(string: "http://numbersapi.com/random/trivia")!
let (data, _) = try! await URLSession.shared.data(from: url)
return String(decoding: data, as: UTF8.self)
}

Now, in TimelineProvider, you can call it as follows.

Thanks to the encapsulated caching functionality, a single line of code does the job.

getTimeline function with caching

Let’s retest the widgets.

Five Widgets, Single Request

When all the widgets are reloaded, if you check Proxyman, you can see only one network request is made.

Instead of a forced reload, let’s wait for the timeline to reload, which is set for every 5 minutes.

Five Widgets, with timeline reload

Great! The five widgets all share a single response. This way, we can create widgets that reduce the server load.

You can find the entire project used in this article in the repository below.

I’ve separated the commits into three steps, so feel free to test on your own. 😄

Want to know more about Widgets?

In my next article, I’ll show you how to detect the addition and removal of Widgets for logging.

--

--