Skip to content

perf(sync): fix iCloud-sync UI freezes - #61

Merged
bilipp merged 3 commits into
mainfrom
improve-icloud-sync-performance
Jun 15, 2026
Merged

bilipp merged 3 commits into
mainfrom
improve-icloud-sync-performance

Conversation

@bilipp

@bilipp bilipp commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Problem

iCloud sync caused heavy UI lag — even on the latest hardware the app felt completely stuck during sync, and sync seemed to fire at random / too often (on every foregrounding, on opening a movie detail, etc.).

Root cause

The work itself was already off-main, but two things hit the main thread:

  1. Redundant reconcile passes. Foregrounding fired a full reconcile every time; each reconcile that produced changes auto-merged into the main context, re-running every on-screen @Query. The "opening a movie detail" lag was actually TMDB/OMDB enrichment saving synchronously on the view's main context (scalar fields + a full cast replace) on every detail open.
  2. An unconditional full re-encode of the entire sync shadow baseline to UserDefaults JSON on every pass, even when nothing changed.

(At the transport layer CloudKit already syncs deltas; the reconcile scan is bounded by user-state size, not catalog size — so an incremental/persistent-history rewrite was deliberately not pursued: it would be dominated by catalog-sync churn and add fragility for no real gain.)

Changes

  • Gate foreground reconciles behind a 30s interval (CloudSyncCoordinator.minForegroundReconcileInterval). Quick app-switches no longer re-merge for no new data; genuine remote changes still pull immediately via the remote-change observer.
  • Move TMDB + OMDB enrichment off the main thread. ContentSyncManager now owns enrichMovie / enrichSeries / enrichMovieRatings / enrichSeriesRatings, which fetch + apply + save on a background context (auto-merging into the main context). All 5 detail views + Home hero-logo enrichment call these instead of applying on modelContext + a synchronous save. applyMovieDetails/applySeriesDetails/replaceCast and the TMDBTitleDetails/TMDBCastMember DTOs are now nonisolated.
  • Skip the shadow re-encode when unchanged. CloudSyncShadow tracks a dirty flag; persist() no-ops in steady state.

Testing

  • Builds clean (iOS 26.5 sim).
  • CloudSyncTests and ContentSyncTests pass (the one failing ContentSyncTests case is a pre-existing bulk-insert benchmark, unrelated — it touches none of this code).
  • swiftformat 0.61.1 + swiftlint 0.63.2 clean.

Note for review

This reverses the original code's deliberate "apply enrichment on the view context to avoid cross-context merge timing issues" choice. The awaited background save + auto-merge, followed by refreshToken = UUID(), is the safety net. Episodes still load on the view context on purpose (instant relationship display) and are unchanged. Worth a sanity check that enriched text/cast/logos still appear promptly on detail open.

bilipp added 3 commits June 15, 2026 09:36
The UI stalled during sync from two main-thread sources: redundant
reconcile passes merging into the main context (re-running on-screen
@query), and TMDB/OMDB detail enrichment saving synchronously on the
view's main context on every detail open.

- Gate foreground-triggered reconciles behind a 30s interval
  (CloudSyncCoordinator.minForegroundReconcileInterval). Quick
  app-switches no longer re-merge for no new data; genuine remote
  changes still pull immediately via the remote-change observer.
- Move TMDB + OMDB enrichment off the main thread: ContentSyncManager
  now owns enrichMovie/enrichSeries/enrichMovieRatings/enrichSeriesRatings
  which fetch + apply + save on a background context (auto-merges into
  the main context). Detail views and Home hero-logo enrichment call
  these instead of applying on modelContext + synchronous save.
- Make applyMovieDetails/applySeriesDetails/replaceCast and the
  TMDBTitleDetails/TMDBCastMember DTOs nonisolated so the background
  path can read them off the main actor.

Pre-commit hook bypassed: lefthook's SwiftFormat SPM plugin can't
resolve 0.61.1 offline; verified clean with standalone swiftformat
0.61.1 + swiftlint 0.63.2.
CloudSyncShadow.persist() rewrote the entire playlist + content baseline
to UserDefaults as JSON on every reconcile pass, even a steady-state pass
where every merge verdict was .noChange. Track a dirty flag set only when
a setter actually changes a value (or resetContent clears a non-empty
baseline); persist() now no-ops unless something changed.

(Pre-commit hook bypassed: lefthook SwiftFormat plugin can't resolve
0.61.1 offline; verified clean with standalone swiftformat 0.61.1 +
swiftlint 0.63.2.)
The foreground reconcile is a safety net — a change made on another device
while this one was backgrounded lands via CloudKit's own remote-change
notification on reconnect, not via the foreground pass. So reopening the
app no longer needs to kick off a sync. Stretch the foreground gate from
30s to 30 min so routine app re-activations stop triggering reconciles
(the "syncs too often" feel), while remote-change pulls and the background
flush stay prompt (capping those would delay cross-device sync or drop
local edits before suspension).
@bilipp
bilipp merged commit 2fee4a1 into main Jun 15, 2026
@bilipp
bilipp deleted the improve-icloud-sync-performance branch June 15, 2026 08:27
bilipp added a commit that referenced this pull request Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant