Offline-first remote config caching with TTL, stale fallback, and typed keys.
Features · Installation · Quick Start · When To Use · Good Fits · Weaker Fits · Read Policies · Freshness Model · Errors · Example App · Documentation · Testing · Example Scenarios
- memory and disk-backed cache layers
- TTL-based freshness
- optional stale fallback using
maxStaleAge - injected fetcher protocol for remote loading
- typed key access for primitive values
- actor-backed store implementation for serialized state access
The public API is intentionally centered on:
RemoteConfigStoreRemoteConfigFetcherRemoteConfigSnapshotRemoteConfigRefreshResultRemoteConfigKeyRemoteConfigValueReadPolicyRemoteConfigStoreErrorLogger
Add RemoteConfigStore to your Swift Package Manager dependencies:
dependencies: [
.package(url: "https://github.com/AltiAntonov/RemoteConfigStore.git", from: "0.4.0")
]Then add the product to your target:
.target(
name: "YourApp",
dependencies: [
.product(name: "RemoteConfigStore", package: "RemoteConfigStore")
]
)If you need to try unreleased changes, pin to a branch or revision explicitly.
import Foundation
import RemoteConfigStore
enum AppConfigKeys {
static let newUI = RemoteConfigKey<Bool>("new_ui", defaultValue: false)
}
struct AppFetcher: RemoteConfigFetcher {
func fetchSnapshot() async throws -> RemoteConfigSnapshot {
RemoteConfigSnapshot(values: [
"new_ui": .bool(true)
])
}
}
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let store = try RemoteConfigStore(
fetcher: AppFetcher(),
cacheDirectory: directory.appendingPathComponent("RemoteConfigStore"),
ttl: 300,
maxStaleAge: 3600
)
let enabled = try await store.value(for: AppConfigKeys.newUI, using: .immediate)If you need to know whether a refresh actually changed the payload, use refreshResult():
switch try await store.refreshResult() {
case .updated(let snapshot):
print("Config changed:", snapshot.values)
case .unchanged:
print("Config payload is unchanged.")
}You can also use the built-in HTTP path when your config comes from a JSON endpoint:
let store = try RemoteConfigStore(
request: HTTPRemoteConfigRequest(
url: URL(string: "https://example.com/remote-config.json")!,
headers: ["Authorization": "Bearer token"],
timeoutInterval: 8
),
cacheDirectory: directory.appendingPathComponent("RemoteConfigStore"),
ttl: 300
)When the built-in HTTP fetcher receives ETag or Last-Modified headers, the store persists that validation metadata and uses it for later conditional refreshes.
Use RemoteConfigStore when an app needs server-driven values but should still behave predictably when the network is slow, unavailable, or temporarily failing.
It is a strong fit for configuration that should be cached locally, refreshed deliberately, and read through a typed API instead of raw dictionaries spread across an app.
- Feature flags and staged rollout controls Example: Feature Flags scenario
- Runtime tuning values such as request timeouts, polling intervals, and rollout percentages Example: Feature Flags scenario
- Remote text or copy that should remain available offline Example: Feature Flags scenario
- Safety switches and operational config that benefit from stale fallback instead of hard failure Example soon
- Apps that care about startup speed and want cache-first or stale-while-revalidate reads Example: Feature Flags scenario
- Deeply nested or highly structured configuration documents Example soon
- Cases where config must always be fresh and stale data is never acceptable Example soon
- Projects that need a built-in HTTP client, ETag validation, or observer streams today Example: HTTP Cache Validation scenario
- Data that is really user content or secure secret material rather than app configuration Example soon
RemoteConfigStore currently supports three read policies:
| Policy | Fresh cache | Stale but usable cache | No usable cache | Best fit |
|---|---|---|---|---|
.immediate |
returns cache | returns cache | waits for refresh | fast UI reads |
.refreshBeforeReturning |
returns cache | tries refresh first | waits for refresh | freshest possible values |
.immediateWithBackgroundRefresh |
returns cache | returns cache and refreshes in background | waits for refresh | responsive reads with silent catch-up |
Return a usable cached snapshot immediately when one exists.
- fresh cache: returned immediately
- stale but still usable cache: returned immediately
- no usable cache: refresh is attempted and its result is returned
- refresh failure with no usable cache: throws
Use this when UI responsiveness matters more than eagerly refreshing stale data.
Prefer a refresh before returning stale data.
- fresh cache: returned immediately
- stale cache: refresh is attempted first
- refresh failure with still-usable stale cache: stale data is returned
- refresh failure with no usable cache: throws
Use this when you want the newest possible config before continuing, but still need an offline fallback.
Return usable cached data now and refresh asynchronously in the background.
- fresh or stale-but-usable cache: returned immediately
- a background refresh is scheduled
- no usable cache: refresh is awaited directly
Use this when you want instant reads while still nudging the cache toward freshness.
The store uses two time windows:
ttl: how long a fetched snapshot is considered freshmaxStaleAge: an optional extra window during which expired data may still be served
That creates three states:
-
freshThe cache is withinttland can be returned without qualification. -
stale but usableThe cache is older thanttl, but still withinmaxStaleAge. -
expired and unusableThe cache is older than both windows and must not be used as a fallback.
RemoteConfigStore keeps its store-specific error surface intentionally small.
RemoteConfigStoreError.noCachedSnapshot: returned when neither memory nor disk cache contains a snapshot
Other failures are propagated from the underlying component that failed:
refresh()andsnapshot(using:)can throw the injected fetcher's errorrefresh()can throw cache persistence or file-system errors from disk writescachedSnapshot()can throw file-system errors while loading from diskRemoteConfigStore.init(...)can throw when the cache directory cannot be prepared
The repository includes an Xcode example app in Example/RemoteConfigStore.
Current scenarios:
Feature FlagsCode: FeatureFlagsDemoView.swift Shows typed keys, all three read policies, manual refreshes, revision drift, and the difference between typed accessors and the raw cached payload.HTTP FetcherCode: HTTPFetcherDemoView.swift Shows the built-inURLSessionfetcher, URL-based store construction, typed-key reads, and refresh-result reporting.HTTP Cache ValidationCode: HTTPFetcherDemoView.swift Shows persisted ETag metadata, conditional requests,304 Not Modified, and cache freshness renewal without a payload change.
Planned scenarios:
Offline Fallback- Example soonObservers And Metrics- Example soon
The package now includes a DocC catalog in Sources/RemoteConfigStore/RemoteConfigStore.docc.
Once Swift Package Index processes the .spi.yml manifest and the DocC catalog, hosted documentation should appear on the package page automatically.
Current DocC coverage includes:
- getting started with injected fetchers
- read policy behavior
- built-in HTTP cache validation behavior
The example app is designed as one showcase application with multiple focused scenarios rather than many separate demo projects.
Implemented:
Feature FlagsCode: FeatureFlagsDemoView.swiftHTTP FetcherCode: HTTPFetcherDemoView.swiftHTTP Cache ValidationCode: HTTPFetcherDemoView.swift
Coming later:
Offline Fallback- Example soonStructured Payloads- Example soon
If you create a fresh example app manually in the future, recommended Xcode options are:
- template:
iOS App - interface:
SwiftUI - language:
Swift - testing:
Swift Testing - Core Data:
No - development team: unset unless signing is needed locally
Package tests now use Swift Testing, not XCTest.
Current structure:
Tests/RemoteConfigStoreTests/ModelsTests/RemoteConfigStoreTests/CacheTests/RemoteConfigStoreTests/StoreTests/RemoteConfigStoreTests/Support
Run the package tests with:
swift testSee CHANGELOG.md for released changes.