Skip to content

v1.13.0 — Performance and Sync Reliability

Latest

Choose a tag to compare

@whyisjake whyisjake released this 11 Aug 23:52
· 6 commits to main since this release
aa792a4

Performance and sync reliability. No new features — this release makes the app stay responsive while syncing, keeps launch fast as your library grows, and fixes a bug that was silently disabling feeds.

Every figure below is measured, not estimated. Details and a re-runnable harness are in docs/performance-baseline.md; the plan is docs/plans/2026-08-11-003-fix-startup-and-sync-performance-plan.md.

Sync no longer blocks the UI

Article insertion, feed parsing and OPML sync were all running on the main thread. Measured with Thread.isMainThread probes:

Phase Before After
syncAllFeeds main background
parseAllFeedsInBackground main background
insertArticlesInChunks main background
context.save() main background

The previous fix for this had been silently undone: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor re-imposes main-actor isolation on every unannotated declaration. Marking things nonisolated did not help either — with SWIFT_APPROACHABLE_CONCURRENCY = YES, a nonisolated async function runs on the caller's executor. @concurrent is what forces the global executor, and debug assertions now guard it.

Roughly 1.35s of main-thread work removed per sync on a real device.

Launch scales with the window, not the library

Every read path fetched the entire Article table and filtered it in view code, faulting each article's feed relationship. All seven queries are now bounded by predicate and fetch limit, and TodayView's six independent full-array passes collapse into one.

A/B on the same 40,000-article store:

per render renders per launch
Before 28.6–33.9 ms 11
After 11.4 ms 1

At 10,358 articles it was 11.0 ms before; at 40,000 it's 11.4 ms after. Cost now tracks the visible window rather than the library.

One slow feed no longer stalls the rest

chunked(into: 5) made the fetch phase cost the sum of each batch's slowest feed. Replaced with a task group holding a fixed number of requests in flight, plus a 15s request timeout (was URLSession's 60s default) and cancellation support.

On a real 39-feed subscription: 148 seconds of total fetch work completed in 33.6 seconds — 88% of the theoretical floor for 5 concurrent slots.

Bug fixes

OPML feeds silently deactivating. addFeed upgrades http:// → https:// and stores the feed under the canonical URL, but OPML sync compared against the URL as listed. An http-listed feed never matched its stored https record, so every sync marked it removed — and re-adding it was a no-op that never reset isActive. Feeds went dark with no indication in the UI.

Confirmed on a real device: 15 feeds restored, active feed count 39 → 53. Normalisation now lives in one place (FeedURLNormalizer) that both sides use, and affected feeds reactivate automatically.

A total sync failure recorded itself as successful, so needsSync() refused to retry for two hours — exactly when it mattered. Now only recorded when at least one feed responds; a 304 counts.

Temporary redirects were treated as permanent and could overwrite a stored feed URL. The old code inferred it from the final URL differing, with a comment admitting it couldn't tell 301 from 302. A per-task delegate now reads the actual status codes.

Search on the Today screen was unreachable — collapsed until you pulled the list down, but the same gesture drove pull-to-refresh (#111).

Test infrastructure

TodayTests used explicit file references, so two test files existed on disk but were never target members — and one had never passed: it registered MockURLProtocol but never attached it to a session, so all 10 tests silently hit the live network.

Test suite 145 → 277, and there are now end-to-end sync tests for the first time (syncAllFeeds takes an injectable URLSession).

Known limitations

  • New SwiftData indexes apply to newly created stores only. Verified against sqlite_master: a fresh install gets all five, a migrated store gets none. Bounded queries are still far cheaper than the previous full-library scans, but a VersionedSchema migration is needed for the full win.
  • Downgrade to 1.12.0 is not supported. This release adds optional fields to Article and Feed; the older model cannot open the newer store, and TodayApp calls fatalError if the container fails to open.
  • FeedManager.addFeed still runs on the main actor during OPML sync. Only when a subscription gains a feed, not on a normal sync.

Full changelog: v1.12.0...v1.13.0