krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

205cf2687ce48f3d004792ff223422a86cc6bdbc

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-05-07T01:41:49Z

feat(cache): persist read-only API responses

Add a bounded stale-while-revalidate cache at the Sourcehut API boundary with
stable keys, centralized TTLs, request coalescing, payload hashing, and LRU disk
pruning.

Cache high-value read-only repo, build, ticket, project, profile, paste, and
Home/Work Queue data while keeping mutations network-only and invalidating
related prefixes after successful writes.

Add focused cache tests and implementation notes.
 Docs/API_CACHE.md                                  |  2 +-
 Hutch.xcodeproj/project.pbxproj                    | 16 ++--
 Hutch/Networking/APICache.swift                    | 30 ++++---
 Hutch/Networking/APICacheKeys.swift                | 43 ++++++++++
 Hutch/Networking/PasteService.swift                | 19 ++++-
 Hutch/Networking/ProjectService.swift              | 23 +++++-
 Hutch/Views/Builds/BuildListViewModel.swift        | 23 ++++--
 Hutch/Views/Home/HomeViewModel.swift               | 63 +++++++++++---
 Hutch/Views/Lookup/UserProfileViewModel.swift      | 20 +++--
 Hutch/Views/Pastes/PasteListViewModel.swift        |  2 +-
 .../Repositories/RepositoryListViewModel.swift     | 96 ++++++++++++++--------
 Hutch/Views/Tickets/TicketListViewModel.swift      | 43 ++++++++--
 Hutch/Views/Tickets/TrackerListViewModel.swift     | 27 +++++-
 13 files changed, 304 insertions(+), 103 deletions(-)

diff --git a/Docs/API_CACHE.md b/Docs/API_CACHE.md
index d15b75d..456beb2 100644
--- a/Docs/API_CACHE.md
+++ b/Docs/API_CACHE.md
@@ -8,4 +8,4 @@ TTLs live in `APICacheTTLs`. Active build data uses a very short TTL, mutable ti
 
 Invalidation is intentionally prefix-based. Successful ticket mutations remove ticket, ticket-list, tracker, and Home prefixes. Build retry/cancel/resubmit actions remove build detail, build-list, build-log, and Home prefixes. This avoids a dependency graph while keeping stale post-mutation data out of the high-risk paths.
 
-Known limitations: the first pass is integrated into build detail/logs, ticket detail, README lookup, and repository tree/blob/file reads. Some older list-level paths still use the legacy in-memory cache facade and should move to `APICacheKeys` plus `executeCached` next. Recommended next targets are repository lists, build lists, ticket lists, profile repositories/trackers, and Home/Work Queue sections.
+Known limitations: caching now covers the high-value detail paths plus repository/build/ticket/tracker/paste lists, profile repositories/trackers, projects, and Home dashboard fetches. Some services still perform background refresh one request at a time rather than streaming partial refreshed list pages into the UI, and Work Queue-specific surfaces should be reviewed as a follow-up if they grow beyond the Home dashboard data model.
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index e32ea73..a7ef936 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -517,7 +517,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 83;
+				CURRENT_PROJECT_VERSION = 84;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.3.0;
+				MARKETING_VERSION = 3.3.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 83;
+				CURRENT_PROJECT_VERSION = 84;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.3.0;
+				MARKETING_VERSION = 3.3.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 83;
+				CURRENT_PROJECT_VERSION = 84;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.3.0;
+				MARKETING_VERSION = 3.3.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 83;
+				CURRENT_PROJECT_VERSION = 84;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.3.0;
+				MARKETING_VERSION = 3.3.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Networking/APICache.swift b/Hutch/Networking/APICache.swift
index 9c1a262..05cf80e 100644
--- a/Hutch/Networking/APICache.swift
+++ b/Hutch/Networking/APICache.swift
@@ -35,7 +35,7 @@ struct CacheEntryMetadata: Codable, Sendable, Equatable {
     let schemaVersion: Int
     let payloadSize: Int
 
-    func isExpired(now: Date = Date()) -> Bool {
+    nonisolated func isExpired(now: Date = Date()) -> Bool {
         expiresAt <= now
     }
 }
@@ -83,7 +83,7 @@ struct APICacheConfiguration: Sendable {
     var memoryEntryLimit: Int
     var schemaVersion: Int
 
-    static func accountScoped(accountID: String) -> APICacheConfiguration {
+    nonisolated static func accountScoped(accountID: String) -> APICacheConfiguration {
         let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
             ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
         return APICacheConfiguration(
@@ -98,7 +98,7 @@ struct APICacheConfiguration: Sendable {
         )
     }
 
-    static func temporary(directory: URL) -> APICacheConfiguration {
+    nonisolated static func temporary(directory: URL) -> APICacheConfiguration {
         APICacheConfiguration(
             directory: directory,
             maxCacheSizeBytes: 4 * 1024 * 1024,
@@ -126,15 +126,13 @@ actor PersistentAPICache: APICache {
     }
 
     private let configuration: APICacheConfiguration
-    private let fileManager: FileManager
     private var memoryEntries: [String: APICacheEntry] = [:]
     private var memoryOrder: [String] = []
     private var knownMetadata: [String: CacheEntryMetadata] = [:]
     private var writeCountSincePrune = 0
 
-    init(configuration: APICacheConfiguration, fileManager: FileManager = .default) {
+    init(configuration: APICacheConfiguration) {
         self.configuration = configuration
-        self.fileManager = fileManager
     }
 
     func read(cacheKey: String) async throws -> APICacheEntry {
@@ -147,13 +145,13 @@ actor PersistentAPICache: APICache {
         }
 
         let url = fileURL(for: cacheKey)
-        guard fileManager.fileExists(atPath: url.path) else {
+        guard FileManager.default.fileExists(atPath: url.path) else {
             throw APICacheError.miss
         }
 
         var stored = try decodeEntry(from: url)
         guard stored.metadata.schemaVersion == configuration.schemaVersion else {
-            try? fileManager.removeItem(at: url)
+            try? FileManager.default.removeItem(at: url)
             throw APICacheError.miss
         }
 
@@ -223,7 +221,7 @@ actor PersistentAPICache: APICache {
         memoryEntries.removeValue(forKey: cacheKey)
         memoryOrder.removeAll { $0 == cacheKey }
         knownMetadata.removeValue(forKey: cacheKey)
-        try? fileManager.removeItem(at: fileURL(for: cacheKey))
+        try? FileManager.default.removeItem(at: fileURL(for: cacheKey))
     }
 
     func removeByPrefix(_ prefix: String) async {
@@ -237,7 +235,7 @@ actor PersistentAPICache: APICache {
         memoryEntries.removeAll()
         memoryOrder.removeAll()
         knownMetadata.removeAll()
-        try? fileManager.removeItem(at: configuration.directory)
+        try? FileManager.default.removeItem(at: configuration.directory)
     }
 
     func pruneExpired(now: Date = Date()) async {
@@ -289,7 +287,7 @@ actor PersistentAPICache: APICache {
 
     private func loadKnownMetadataIfNeeded() async {
         guard knownMetadata.isEmpty else { return }
-        guard let urls = try? fileManager.contentsOfDirectory(
+        guard let urls = try? FileManager.default.contentsOfDirectory(
             at: configuration.directory,
             includingPropertiesForKeys: nil
         ) else { return }
@@ -301,8 +299,8 @@ actor PersistentAPICache: APICache {
     }
 
     private func ensureDirectoryExists() throws {
-        if !fileManager.fileExists(atPath: configuration.directory.path) {
-            try fileManager.createDirectory(
+        if !FileManager.default.fileExists(atPath: configuration.directory.path) {
+            try FileManager.default.createDirectory(
                 at: configuration.directory,
                 withIntermediateDirectories: true
             )
@@ -315,13 +313,13 @@ actor PersistentAPICache: APICache {
             .appendingPathExtension("json")
     }
 
-    private static func payloadHash(_ data: Data) -> String {
+    nonisolated private static func payloadHash(_ data: Data) -> String {
         SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
     }
 }
 
 extension JSONEncoder {
-    static var srhtCache: JSONEncoder {
+    nonisolated static var srhtCache: JSONEncoder {
         let encoder = JSONEncoder()
         encoder.dateEncodingStrategy = .iso8601
         return encoder
@@ -329,7 +327,7 @@ extension JSONEncoder {
 }
 
 extension JSONDecoder {
-    static var srhtCache: JSONDecoder {
+    nonisolated static var srhtCache: JSONDecoder {
         let decoder = JSONDecoder()
         decoder.dateDecodingStrategy = .iso8601
         return decoder
diff --git a/Hutch/Networking/APICacheKeys.swift b/Hutch/Networking/APICacheKeys.swift
index 5e57a51..d0cffa6 100644
--- a/Hutch/Networking/APICacheKeys.swift
+++ b/Hutch/Networking/APICacheKeys.swift
@@ -51,6 +51,14 @@ enum APICacheKeys {
         make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"])
     }
 
+    static func trackers(cursor: String? = nil) -> String {
+        make([SRHTService.todo.rawValue, "trackers", cursor.map { "cursor:\($0)" }])
+    }
+
+    static func tickets(trackerRid: String, cursor: String? = nil) -> String {
+        make([SRHTService.todo.rawValue, "tickets", "tracker:\(trackerRid)", cursor.map { "cursor:\($0)" }])
+    }
+
     static func builds(cursor: String? = nil, filter: String? = nil) -> String {
         make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }])
     }
@@ -71,6 +79,37 @@ enum APICacheKeys {
         make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }])
     }
 
+    static func projects(cursor: String? = nil) -> String {
+        make([SRHTService.hub.rawValue, "projects", cursor.map { "cursor:\($0)" }])
+    }
+
+    static func projectDetail(rid: String, mailingListsCursor: String? = nil, sourcesCursor: String? = nil, trackersCursor: String? = nil) -> String {
+        make([
+            SRHTService.hub.rawValue,
+            "project",
+            "rid:\(rid)",
+            mailingListsCursor.map { "ml:\($0)" },
+            sourcesCursor.map { "src:\($0)" },
+            trackersCursor.map { "trk:\($0)" }
+        ])
+    }
+
+    static func homeJobs(actor: String) -> String {
+        make(["home", "jobs", normalize(actor)])
+    }
+
+    static func homeTrackerTickets(owner: String, tracker: String) -> String {
+        make(["home", "tickets", normalize(owner), normalize(tracker)])
+    }
+
+    static func inboxSubscriptions(cursor: String? = nil) -> String {
+        make([SRHTService.lists.rawValue, "subscriptions", cursor.map { "cursor:\($0)" }])
+    }
+
+    static func inboxThreads(listRid: String, cursor: String? = nil) -> String {
+        make([SRHTService.lists.rawValue, "threads", "list:\(listRid)", cursor.map { "cursor:\($0)" }])
+    }
+
     static func pasteList(cursor: String? = nil) -> String {
         make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }])
     }
@@ -102,4 +141,8 @@ enum APICacheTTLs {
     static let movingRefFileContent: TimeInterval = 10 * 60
     static let userProfile: TimeInterval = 30 * 60
     static let status: TimeInterval = 5 * 60
+    static let homeDashboard: TimeInterval = 2 * 60
+    static let inboxSummary: TimeInterval = 2 * 60
+    static let projectList: TimeInterval = 10 * 60
+    static let projectDetail: TimeInterval = 10 * 60
 }
diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift
index b9aa530..92a618e 100644
--- a/Hutch/Networking/PasteService.swift
+++ b/Hutch/Networking/PasteService.swift
@@ -109,13 +109,17 @@ final class PasteService: Sendable {
         let variables = cursor.map { ["cursor": $0 as any Sendable] }
         let result: PasteListResponse
         if useCache, cursor == nil {
-            result = try await client.executeAndCache(
+            let cached = try await client.executeCached(
                 service: .paste,
                 query: Self.listQuery,
                 variables: variables,
                 responseType: PasteListResponse.self,
-                cacheKey: Self.cacheKey
+                cacheKey: APICacheKeys.pasteList(cursor: cursor),
+                resourceType: .pasteList,
+                ttl: APICacheTTLs.ticketList,
+                policy: .cacheFirstThenRefresh
             )
+            result = cached.value
         } else {
             result = try await client.execute(
                 service: .paste,
@@ -127,8 +131,8 @@ final class PasteService: Sendable {
         return result.pastes ?? PasteListPage(results: [], cursor: nil)
     }
 
-    func loadCachedPastes() -> PasteListPage? {
-        guard let data = client.responseCache.get(forKey: Self.cacheKey) else {
+    func loadCachedPastes() async -> PasteListPage? {
+        guard let data = await client.cachedPayload(forKey: APICacheKeys.pasteList()) ?? client.responseCache.get(forKey: Self.cacheKey) else {
             return nil
         }
 
@@ -171,6 +175,7 @@ final class PasteService: Sendable {
             },
             responseType: CreatePasteResponse.self
         )
+        await invalidatePasteCaches()
         return result.create
     }
 
@@ -181,6 +186,7 @@ final class PasteService: Sendable {
             variables: ["id": id, "visibility": visibility.rawValue],
             responseType: UpdatePasteResponse.self
         )
+        await invalidatePasteCaches()
         return result.update
     }
 
@@ -191,6 +197,7 @@ final class PasteService: Sendable {
             variables: ["id": id],
             responseType: DeletePasteResponse.self
         )
+        await invalidatePasteCaches()
         return result.delete
     }
 
@@ -208,6 +215,10 @@ final class PasteService: Sendable {
             return (draft.filename, data)
         }
     }
+
+    private func invalidatePasteCaches() async {
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.paste.rawValue, "pastes"))
+    }
 }
 
 private struct PasteListResponse: Decodable, Sendable {
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 5e0e061..44320ca 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -287,12 +287,17 @@ struct ProjectService: Sendable {
                 variables["cursor"] = cursor
             }
 
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .hub,
                 query: Self.projectsQuery,
                 variables: variables.isEmpty ? nil : variables,
-                responseType: ProjectPageResponse.self
+                responseType: ProjectPageResponse.self,
+                cacheKey: APICacheKeys.projects(cursor: cursor),
+                resourceType: .userProfile,
+                ttl: APICacheTTLs.projectList,
+                policy: .cacheFirstThenRefresh
             )
+            let response = cached.value
 
             results.append(contentsOf: response.me.projects.results)
             guard let nextCursor = response.me.projects.cursor else {
@@ -324,12 +329,22 @@ struct ProjectService: Sendable {
                 variables["trackersCursor"] = trackersCursor
             }
 
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .hub,
                 query: Self.projectDetailQuery,
                 variables: variables,
-                responseType: ProjectDetailResponse.self
+                responseType: ProjectDetailResponse.self,
+                cacheKey: APICacheKeys.projectDetail(
+                    rid: rid,
+                    mailingListsCursor: mailingListsCursor,
+                    sourcesCursor: sourcesCursor,
+                    trackersCursor: trackersCursor
+                ),
+                resourceType: .userProfile,
+                ttl: APICacheTTLs.projectDetail,
+                policy: .cacheFirstThenRefresh
             )
+            let response = cached.value
 
             guard let project = response.project else {
                 throw SRHTError.decodingError(
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index 14f50fd..42500a8 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -188,7 +188,7 @@ final class BuildListViewModel {
     func loadJobs() async {
         // Show cached data immediately on first load (may populate `jobs` from cache).
         if jobs.isEmpty {
-            loadFromCache()
+            await loadFromCache()
         }
 
         let treatAsInitialLoad = jobs.isEmpty
@@ -279,6 +279,7 @@ final class BuildListViewModel {
                 variables: variables,
                 responseType: SubmitJobResponse.self
             )
+            await invalidateBuildListCache()
             await loadJobs()
             return result.submit.id
         } catch {
@@ -297,6 +298,7 @@ final class BuildListViewModel {
                 variables: ["id": job.id],
                 responseType: CancelResponse.self
             )
+            await invalidateBuildListCache()
             if let index = jobs.firstIndex(where: { $0.id == job.id }) {
                 let updated = JobSummary(
                     id: job.id,
@@ -345,14 +347,17 @@ final class BuildListViewModel {
         }
 
         if useCache && cursor == nil {
-            let result = try await client.executeAndCache(
+            let cached = try await client.executeCached(
                 service: .builds,
                 query: Self.query,
                 variables: variables.isEmpty ? nil : variables,
                 responseType: JobsResponse.self,
-                cacheKey: Self.cacheKey
+                cacheKey: APICacheKeys.builds(cursor: cursor),
+                resourceType: .buildList,
+                ttl: APICacheTTLs.activeBuild,
+                policy: .cacheFirstThenRefresh
             )
-            return result.jobs
+            return cached.value.jobs
         } else {
             let result = try await client.execute(
                 service: .builds,
@@ -364,8 +369,14 @@ final class BuildListViewModel {
         }
     }
 
-    private func loadFromCache() {
-        guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
+    private func invalidateBuildListCache() async {
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+    }
+
+    private func loadFromCache() async {
+        guard let data = await client.cachedPayload(forKey: APICacheKeys.builds()) ?? client.responseCache.get(forKey: Self.cacheKey) else { return }
         let decoder = JSONDecoder()
         decoder.dateDecodingStrategy = .srhtFlexible
         if let response = try? decoder.decode(
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 142eee4..2cd4869 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -578,6 +578,7 @@ final class HomeViewModel {
                 ],
                 responseType: UnassignResponse.self
             )
+            await invalidateHomeRelatedCaches()
             assignedTickets.removeAll { $0.id == ticket.id }
             persistNeedsAttentionSnapshot()
         } catch {
@@ -595,6 +596,7 @@ final class HomeViewModel {
                 variables: ["id": build.job.id],
                 responseType: CancelBuildResponse.self
             )
+            await invalidateHomeRelatedCaches()
             if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
                 let updatedJob = JobSummary(
                     id: build.job.id,
@@ -684,12 +686,16 @@ final class HomeViewModel {
 
     private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
         do {
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .builds,
                 query: Self.jobsQuery,
-                responseType: HomeJobsResponse.self
+                responseType: HomeJobsResponse.self,
+                cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName),
+                resourceType: .buildList,
+                ttl: APICacheTTLs.homeDashboard,
+                policy: .cacheFirstThenRefresh
             )
-            return .success(response.jobs.results)
+            return .success(cached.value.jobs.results)
         } catch {
             return .failure(error)
         }
@@ -780,12 +786,17 @@ final class HomeViewModel {
                 variables["cursor"] = cursor
             }
 
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .lists,
                 query: Self.inboxSubscriptionsQuery,
                 variables: variables.isEmpty ? nil : variables,
-                responseType: HomeInboxSubscriptionsResponse.self
+                responseType: HomeInboxSubscriptionsResponse.self,
+                cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor),
+                resourceType: .ticketList,
+                ttl: APICacheTTLs.inboxSummary,
+                policy: .cacheFirstThenRefresh
             )
+            let response = cached.value
 
             subscriptions.append(contentsOf: response.subscriptions.results)
             guard let nextCursor = response.subscriptions.cursor else {
@@ -809,12 +820,17 @@ final class HomeViewModel {
                 variables["cursor"] = cursor
             }
 
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .lists,
                 query: Self.inboxListThreadsQuery,
                 variables: variables,
-                responseType: HomeInboxListThreadsResponse.self
+                responseType: HomeInboxListThreadsResponse.self,
+                cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor),
+                resourceType: .ticketList,
+                ttl: APICacheTTLs.inboxSummary,
+                policy: .cacheFirstThenRefresh
             )
+            let response = cached.value
 
             let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
                 let summary = InboxThreadSummary(
@@ -876,12 +892,17 @@ final class HomeViewModel {
                 variables["cursor"] = cursor
             }
 
-            let response = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .todo,
                 query: Self.trackersQuery,
                 variables: variables.isEmpty ? nil : variables,
-                responseType: HomeTrackersResponse.self
+                responseType: HomeTrackersResponse.self,
+                cacheKey: APICacheKeys.trackers(cursor: cursor),
+                resourceType: .ticketList,
+                ttl: APICacheTTLs.ticketList,
+                policy: .cacheFirstThenRefresh
             )
+            let response = cached.value
 
             allTrackers.append(contentsOf: response.trackers.results)
             guard let nextCursor = response.trackers.cursor else {
@@ -925,7 +946,7 @@ final class HomeViewModel {
     }
 
     private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
-        let response = try await client.execute(
+        let cached = try await client.executeCached(
             service: .todo,
             query: Self.trackerTicketsQuery,
             variables: [
@@ -934,8 +955,18 @@ final class HomeViewModel {
                     : tracker.owner.canonicalName,
                 "tracker": tracker.name
             ],
-            responseType: HomeTrackerTicketsResponse.self
+            responseType: HomeTrackerTicketsResponse.self,
+            cacheKey: APICacheKeys.homeTrackerTickets(
+                owner: tracker.owner.canonicalName.hasPrefix("~")
+                    ? String(tracker.owner.canonicalName.dropFirst())
+                    : tracker.owner.canonicalName,
+                tracker: tracker.name
+            ),
+            resourceType: .ticketList,
+            ttl: APICacheTTLs.ticketList,
+            policy: .cacheFirstThenRefresh
         )
+        let response = cached.value
 
         return response.user.tracker.tickets.results.compactMap { payload in
             guard payload.status.isOpen else {
@@ -970,6 +1001,7 @@ final class HomeViewModel {
                 ],
                 responseType: StatusEventResponse.self
             )
+            await invalidateHomeRelatedCaches()
             assignedTickets.removeAll { $0.id == ticket.id }
             persistNeedsAttentionSnapshot()
         } catch {
@@ -977,6 +1009,15 @@ final class HomeViewModel {
         }
     }
 
+    private func invalidateHomeRelatedCaches() async {
+        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
+    }
+
     private func persistNeedsAttentionSnapshot() {
         let failedBuildCount = recentFailedBuilds().count
         NeedsAttentionSnapshotStore.save(
diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift
index 0e503ce..c482d6f 100644
--- a/Hutch/Views/Lookup/UserProfileViewModel.swift
+++ b/Hutch/Views/Lookup/UserProfileViewModel.swift
@@ -49,13 +49,17 @@ final class UserProfileViewModel {
         defer { isLoadingRepositories = false }
 
         do {
-            let result = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .git,
                 query: Self.repositoriesQuery,
                 variables: ["owner": ownerUsername],
-                responseType: UserRepositoriesResponse.self
+                responseType: UserRepositoriesResponse.self,
+                cacheKey: APICacheKeys.userRepositories(owner: ownerUsername),
+                resourceType: .userProfile,
+                ttl: APICacheTTLs.userProfile,
+                policy: .cacheFirstThenRefresh
             )
-            repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) }
+            repositories = cached.value.user.repositories.results.map { $0.repositorySummary(service: .git) }
         } catch {
             repositoriesError = error.userFacingMessage
         }
@@ -78,13 +82,17 @@ final class UserProfileViewModel {
         defer { isLoadingTrackers = false }
 
         do {
-            let result = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .todo,
                 query: Self.trackersQuery,
                 variables: ["owner": ownerUsername],
-                responseType: UserTrackersResponse.self
+                responseType: UserTrackersResponse.self,
+                cacheKey: APICacheKeys.userTrackers(owner: ownerUsername),
+                resourceType: .userProfile,
+                ttl: APICacheTTLs.userProfile,
+                policy: .cacheFirstThenRefresh
             )
-            trackers = result.user.trackers.results
+            trackers = cached.value.user.trackers.results
         } catch {
             trackersError = error.userFacingMessage
         }
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
index 640e31b..059dfa0 100644
--- a/Hutch/Views/Pastes/PasteListViewModel.swift
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -43,7 +43,7 @@ final class PasteListViewModel {
     }
 
     func loadPastes() async {
-        if pastes.isEmpty, let cached = service.loadCachedPastes() {
+        if pastes.isEmpty, let cached = await service.loadCachedPastes() {
             pastes = cached.results
             cursor = cached.cursor
             hasMore = cached.cursor != nil
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 61053a0..695abf8 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -155,7 +155,7 @@ final class RepositoryListViewModel {
 
         // Only use cache for non-search, initial loads
         if !isSearch, repositories.isEmpty {
-            loadFromCache()
+            await loadFromCache()
         }
 
         // During search, never show the full-screen loading overlay (which
@@ -282,6 +282,8 @@ final class RepositoryListViewModel {
                 )
                 repository = result.createRepository.repositorySummary(service: .hg)
             }
+            await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories"))
+            await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
             repositories.insert(repository, at: 0)
             insertIntoSearchIndex(repository)
             scheduleBuildStatusRefresh()
@@ -469,13 +471,17 @@ final class RepositoryListViewModel {
         if useCache && cursor == nil {
             if service == .hg {
                 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
-                let result = try await client.executeAndCache(
+                let cached = try await client.executeCached(
                     service: service,
                     query: Self.hgQuery,
                     variables: hgVariables,
                     responseType: HGRepositoriesResponse.self,
-                    cacheKey: cacheKey(for: service)
+                    cacheKey: cacheKey(for: service),
+                    resourceType: .repositoryList,
+                    ttl: APICacheTTLs.repositoryList,
+                    policy: .cacheFirstThenRefresh
                 )
+                let result = cached.value
                 return Page(
                     results: result.repositories?.results.map {
                         RepositoryPayload(
@@ -492,14 +498,17 @@ final class RepositoryListViewModel {
                     cursor: result.repositories?.cursor
                 )
             }
-            let result = try await client.executeAndCache(
+            let cached = try await client.executeCached(
                 service: service,
                 query: Self.gitQuery,
                 variables: variables.isEmpty ? nil : variables,
                 responseType: RepositoriesResponse.self,
-                cacheKey: cacheKey(for: service)
+                cacheKey: cacheKey(for: service),
+                resourceType: .repositoryList,
+                ttl: APICacheTTLs.repositoryList,
+                policy: .cacheFirstThenRefresh
             )
-            return result.repositories ?? Self.emptyPage
+            return cached.value.repositories ?? Self.emptyPage
         } else {
             if service == .hg {
                 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
@@ -535,31 +544,14 @@ final class RepositoryListViewModel {
         }
     }
 
-    private func loadFromCache() {
-        let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
-            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
-            let decoder = JSONDecoder()
-            decoder.dateDecodingStrategy = .srhtFlexible
-            switch service {
-            case .git:
-                if let response = try? decoder.decode(
-                    GraphQLResponse<RepositoriesResponse>.self,
-                    from: data
-                ), let repos = response.data?.repositories {
-                    return repos.results.map { $0.repositorySummary(service: service) }
-                }
-            case .hg:
-                if let response = try? decoder.decode(
-                    GraphQLResponse<HGRepositoriesResponse>.self,
-                    from: data
-                ), let repos = response.data?.repositories {
-                    return repos.results.map { $0.repositorySummary(service: service) }
-                }
-            default:
-                break
+    private func loadFromCache() async {
+        var persistedRepositories: [RepositorySummary] = []
+        for service in [SRHTService.git, .hg] {
+            if let data = await client.cachedPayload(forKey: cacheKey(for: service)) {
+                persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service))
             }
-            return []
         }
+        let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories
         if !cachedRepositories.isEmpty {
             let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
             repositories = sortedRepositories
@@ -568,6 +560,37 @@ final class RepositoryListViewModel {
         }
     }
 
+    private func legacyCachedRepositories() -> [RepositorySummary] {
+        [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
+            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
+            return Self.decodeCachedRepositories(data, service: service)
+        }
+    }
+
+    private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] {
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .srhtFlexible
+        switch service {
+        case .git:
+            if let response = try? decoder.decode(
+                GraphQLResponse<RepositoriesResponse>.self,
+                from: data
+            ), let repos = response.data?.repositories {
+                return repos.results.map { $0.repositorySummary(service: service) }
+            }
+        case .hg:
+            if let response = try? decoder.decode(
+                GraphQLResponse<HGRepositoriesResponse>.self,
+                from: data
+            ), let repos = response.data?.repositories {
+                return repos.results.map { $0.repositorySummary(service: service) }
+            }
+        default:
+            break
+        }
+        return []
+    }
+
     private func scheduleBuildStatusRefresh(force: Bool = false) {
         // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
         // passes force: true to bypass this check.
@@ -640,14 +663,17 @@ final class RepositoryListViewModel {
         }
 
         if useCache && cursor == nil {
-            let result = try await client.executeAndCache(
+            let cached = try await client.executeCached(
                 service: .builds,
                 query: Self.buildsQuery,
                 variables: variables.isEmpty ? nil : variables,
                 responseType: BuildJobsResponse.self,
-                cacheKey: Self.buildsCacheKey
+                cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"),
+                resourceType: .buildList,
+                ttl: APICacheTTLs.activeBuild,
+                policy: .cacheFirstThenRefresh
             )
-            return result.jobs
+            return cached.value.jobs
         }
 
         let result = try await client.execute(
@@ -681,11 +707,11 @@ final class RepositoryListViewModel {
     private func cacheKey(for service: SRHTService) -> String {
         switch service {
         case .git:
-            Self.gitCacheKey
+            APICacheKeys.repositories(service: .git)
         case .hg:
-            Self.hgCacheKey
+            APICacheKeys.repositories(service: .hg)
         default:
-            "\(service.rawValue).repositories"
+            APICacheKeys.repositories(service: service)
         }
     }
 
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 18a0a5b..1f44993 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -299,6 +299,13 @@ final class TicketListViewModel {
         hasMore = true
 
         do {
+            if tickets.isEmpty, let cachedPage = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
+                tickets = cachedPage.results
+                cursor = cachedPage.cursor
+                hasMore = cachedPage.cursor != nil
+                reconcileSelectionWithLoadedTickets()
+                isLoading = false
+            }
             // todo.sr.ht exposes `tickets(cursor:)` only (see Docs/API/todo.json) — no server-side
             // status filter. The Open tab filters client-side, so we paginate until the cursor is
             // exhausted; otherwise older open tickets never appear in the first page (25 items).
@@ -405,6 +412,7 @@ final class TicketListViewModel {
                 responseType: SubmitTicketResponse.self
             )
             let ticket = result.submitTicket
+            await invalidateTicketCaches()
             tickets.insert(ticket, at: 0)
             return ticket
         } catch {
@@ -460,6 +468,7 @@ final class TicketListViewModel {
                 ],
                 responseType: AssignmentMutationResponse.self
             )
+            await invalidateTicketCaches()
         } catch {
             tickets = original
             self.error = error.userFacingMessage
@@ -502,6 +511,7 @@ final class TicketListViewModel {
                 ],
                 responseType: AssignmentMutationResponse.self
             )
+            await invalidateTicketCaches()
         } catch {
             tickets = original
             self.error = error.userFacingMessage
@@ -512,13 +522,17 @@ final class TicketListViewModel {
 
     func loadTrackerLabels() async {
         do {
-            let result = try await client.execute(
+            let cached = try await client.executeCached(
                 service: .todo,
                 query: Self.trackerLabelsQuery,
                 variables: ["rid": trackerRid],
-                responseType: TrackerLabelsResponse.self
+                responseType: TrackerLabelsResponse.self,
+                cacheKey: APICacheKeys.trackerLabels(trackerRid: trackerRid),
+                resourceType: .ticketList,
+                ttl: APICacheTTLs.ticketList,
+                policy: .cacheFirstThenRefresh
             )
-            syncTrackerLabels(result.tracker.labels.results)
+            syncTrackerLabels(cached.value.tracker.labels.results)
         } catch {
             self.error = error.userFacingMessage
         }
@@ -652,6 +666,7 @@ final class TicketListViewModel {
                 ],
                 responseType: LabelMutationResponse.self
             )
+            await invalidateTicketCaches()
         } catch {
             tickets = original
             self.error = error.userFacingMessage
@@ -692,6 +707,7 @@ final class TicketListViewModel {
                 ],
                 responseType: LabelMutationResponse.self
             )
+            await invalidateTicketCaches()
         } catch {
             tickets = original
             self.error = error.userFacingMessage
@@ -790,6 +806,7 @@ final class TicketListViewModel {
                 responseType: UpdateStatusResponse.self
             )
             _ = result.updateTicketStatus
+            await invalidateTicketCaches()
             if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
                 tickets[index] = updatedTicket(from: ticket, input: input)
             }
@@ -800,18 +817,22 @@ final class TicketListViewModel {
         isPerformingAction = false
     }
 
-    private func fetchPage(cursor: String?) async throws -> TicketsPage {
+    private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TicketsPage {
         var variables: [String: any Sendable] = ["rid": trackerRid]
         if let cursor {
             variables["cursor"] = cursor
         }
-        let result = try await client.execute(
+        let cached = try await client.executeCached(
             service: .todo,
             query: Self.query,
             variables: variables,
-            responseType: TrackerTicketsResponse.self
+            responseType: TrackerTicketsResponse.self,
+            cacheKey: APICacheKeys.tickets(trackerRid: trackerRid, cursor: cursor),
+            resourceType: .ticketList,
+            ttl: APICacheTTLs.ticketList,
+            policy: policy
         )
-        return result.tracker.tickets
+        return cached.value.tracker.tickets
     }
 
     private struct SubmitTicketResponse: Decodable, Sendable {
@@ -919,6 +940,7 @@ final class TicketListViewModel {
         )
 
         if failedTicketIDs.isEmpty {
+            await invalidateTicketCaches()
             clearTicketSelection()
             isSelectionMode = false
         } else {
@@ -960,6 +982,13 @@ final class TicketListViewModel {
         )
     }
 
+    private func invalidateTicketCaches() async {
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+    }
+
     private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
         [
             "status": TicketStatus.resolved.rawValue,
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
index f18bb7b..e4db23c 100644
--- a/Hutch/Views/Tickets/TrackerListViewModel.swift
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -118,6 +118,12 @@ final class TrackerListViewModel {
         hasMore = true
 
         do {
+            if trackers.isEmpty, let cached = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
+                trackers = cached.results
+                cursor = cached.cursor
+                hasMore = cached.cursor != nil
+                isLoading = false
+            }
             let page = try await fetchPage(cursor: nil)
             trackers = page.results
             cursor = page.cursor
@@ -181,6 +187,7 @@ final class TrackerListViewModel {
                 responseType: CreateTrackerResponse.self
             )
             let tracker = result.createTracker
+            await invalidateTrackerCaches()
             trackers.insert(tracker, at: 0)
             return tracker
         } catch {
@@ -224,6 +231,7 @@ final class TrackerListViewModel {
                 ],
                 responseType: UpdateTrackerResponse.self
             )
+            await invalidateTrackerCaches()
             applyTrackerUpdate(result.updateTracker)
             return result.updateTracker
         } catch {
@@ -246,6 +254,7 @@ final class TrackerListViewModel {
                 variables: ["id": tracker.id],
                 responseType: DeleteTrackerResponse.self
             )
+            await invalidateTrackerCaches()
             trackers.removeAll { $0.id == tracker.id }
             await loadTrackers()
             return true
@@ -269,18 +278,22 @@ final class TrackerListViewModel {
 
     // MARK: - Private
 
-    private func fetchPage(cursor: String?) async throws -> TrackersPage {
+    private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TrackersPage {
         var variables: [String: any Sendable] = [:]
         if let cursor {
             variables["cursor"] = cursor
         }
-        let result = try await client.execute(
+        let cached = try await client.executeCached(
             service: .todo,
             query: Self.query,
             variables: variables.isEmpty ? nil : variables,
-            responseType: TrackersResponse.self
+            responseType: TrackersResponse.self,
+            cacheKey: APICacheKeys.trackers(cursor: cursor),
+            resourceType: .ticketList,
+            ttl: APICacheTTLs.ticketList,
+            policy: policy
         )
-        return result.trackers
+        return cached.value.trackers
     }
 
     private struct CreateTrackerResponse: Decodable, Sendable {
@@ -290,4 +303,10 @@ final class TrackerListViewModel {
     private func trackerCreationErrorMessage(for error: Error) -> String {
         "Couldn’t create the tracker. \(error.userFacingMessage)"
     }
+
+    private func invalidateTrackerCaches() async {
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
+        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+    }
 }