When an app calls a remote API from many places — a view appears, the user taps refresh, a background reload kicks in — you face two problems at once:
- Concurrency safety: multiple tasks touching the same cache can race.
- Redundant calls: the same coordinate gets fetched over and over.
In FishGo (a fishing-conditions app), the weather layer hits two Open-Meteo endpoints per location — a regular forecast and a marine forecast. We solved both problems with one tool: a Swift actor holding a cache.
Why an actor
An actor serializes access to its mutable state. Only one task touches the cache at a time, so we get data-race safety for free — no locks, no queues. Just mark the type actor:
actor WeatherService {
static let shared = WeatherService()
private var cache: [String: (data: WeatherData, timestamp: Date)] = [:]
private let cacheExpiry: TimeInterval = 3600 // 1 hour
}
cache is isolated to the actor. Any code calling fetchWeather from outside has to await, and the runtime guarantees serialized access.
The cache key: coordinate + day
Weather changes by location and by date, so the cache key combines both:
func fetchWeather(for coordinate: CLLocationCoordinate2D, date: Date = Date()) async throws -> WeatherData {
let dateKey = Self.dateKey(date)
let cacheKey = "\(coordinate.latitude),\(coordinate.longitude)_\(dateKey)"
if let cached = cache[cacheKey], Date().timeIntervalSince(cached.timestamp) < cacheExpiry {
return cached.data
}
// ... fetch, then store
}
The dateKey is a yyyy-MM-dd string in the Tokyo time zone, so "today's forecast for this spot" maps to one stable key regardless of the exact Date passed in:
private static func dateKey(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "Asia/Tokyo")
return f.string(from: date)
}
If a fresh entry exists (younger than an hour), we return it immediately and skip the network entirely.
Combining two endpoints
On a cache miss, we fetch the forecast and the marine data, plus compute astronomy locally, and merge everything into one WeatherData:
let forecast = try await fetchForecastData(coordinate: coordinate)
let marine = try await fetchMarineForecast(coordinate: coordinate)
let astronomyData = AstronomyCalculator.calculate(for: date, coordinate: coordinate)
Open-Meteo returns hourly arrays, so we pick the index for noon as the representative daytime value:
private func targetHourIndex(for date: Date, times: [String]) -> Int {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
let dateStr = formatter.string(from: date)
let target = "\(dateStr)T12:00"
if let index = times.firstIndex(of: target) { return index }
if let index = times.firstIndex(where: { $0.hasPrefix(dateStr) }) { return index }
return 0
}
A graceful fallback for the marine API
The marine endpoint is the flakier of the two (not every coordinate has marine coverage). Instead of letting a marine failure sink the whole request, we degrade gracefully:
private func fetchMarineForecast(coordinate: CLLocationCoordinate2D) async throws -> MarineForecastData {
do {
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(HourlyMarineResponse.self, from: data)
return MarineForecastData(
waveHeights: response.hourly.waveHeight,
seaTemperatures: response.hourly.seaSurfaceTemperature
)
} catch {
return MarineForecastData(waveHeights: [], seaTemperatures: []) // empty, not fatal
}
}
Downstream, missing wave/temperature data is handled with sensible defaults via indices.contains, so the app still produces a usable forecast.
Finally, store the merged result and return:
cache[cacheKey] = (data, Date())
return data
Pitfalls
-
Don't make the actor's methods
nonisolatedcarelessly — that throws away the isolation that's protecting your cache. -
Time zones bite. Keying by raw
Datewould scatter "the same day" across many keys. Normalize to ayyyy-MM-ddstring in a fixed zone. -
Hourly arrays need bounds checks. APIs can return shorter arrays than you expect; we guard every index access with
indices.contains. - Decide your failure granularity. A secondary data source (marine) failing shouldn't kill the primary result — catch and degrade.
Takeaways
- An
actorgives you a thread-safe cache with zero locking ceremony. - Key the cache by what actually varies (coordinate + normalized day), not by raw objects.
- Merge multiple endpoints behind one method so callers see a single
WeatherData. - Degrade, don't crash, when a secondary source fails.
FishGo is on the App Store: https://apps.apple.com/app/id6774428559
Top comments (0)