DEV Community

Yoshiaki Hirokawa
Yoshiaki Hirokawa

Posted on

An actor + 1-hour cache: calling weather APIs safely from Swift Concurrency

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:

  1. Concurrency safety: multiple tasks touching the same cache can race.
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pitfalls

  • Don't make the actor's methods nonisolated carelessly — that throws away the isolation that's protecting your cache.
  • Time zones bite. Keying by raw Date would scatter "the same day" across many keys. Normalize to a yyyy-MM-dd string 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 actor gives 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)