Group Cards Flickering During a Scan — Three Layers of Nondeterminism and One Epoch Bin
A Flick postmortem: analysis streams in results while the Groups screen has cards appearing, vanishing, and reappearing in a loop. The culprit was three stacked layers of nondeterminism plus a window anchor that drifts over time. The fix: make the same set of photos compute a bit-identical grouping on every rebuild.
Flick’s similar-photo grouping streams in as analysis runs: Vision features are computed batch by batch, groups rebuild every 2 seconds, cards emerge progressively. Fine as a design — until a user reported something eerie: during a scan, group cards appeared, disappeared, and reappeared in a loop, like a broken neon sign.
This wasn’t one bug. It was three layers of nondeterminism stacked on top of each other, plus a drifting window anchor. Peeled apart layer by layer, they all serve one theme: the same set of already-analyzed photos should compute a bit-identical result on every rebuild. It didn’t, and SwiftUI faithfully rendered the differences as flicker.
Why “the result wobbles” becomes “the card blinks”
First, the amplifier. SwiftUI decides reuse-vs-rebuild by identity: change a group’s id in a ForEach and the framework treats it as a brand-new group — tears down the old card, builds a fresh one, and the new card’s cover image has to reload asynchronously, so you see a “placeholder → cover” blink.
So if a group’s id, or the group ordering, or the in-group photo order changes at all between two rebuilds — even when the underlying photos are the same — SwiftUI treats it as a change and repaints. The 2-second rebuild cadence amplifies that into continuous flicker.
Layer one: the group id was bound to member order
The group id was derived from “the first member of the cluster” (dup-<first photo's id>). The problem: cluster member order comes from UnionFind and dictionary iteration — no stable order. The same cluster might have “first” be A this rebuild and B the next, so the id flips from dup-A to dup-B. To SwiftUI those are two different groups: tear down, rebuild, blink.
The fix gives the id a source that doesn’t depend on iteration order — the minimum id in the cluster. The minimum stays the same as the cluster grows (unless a smaller id joins, a rare edge), so the id is stable:
private static func canonicalClusters(_ clusters: [[String]]) -> [[String]] {
clusters
.map { $0.sorted() } // sort members → .first is the stable minimum
.sorted { a, b in
if a.count != b.count { return a.count > b.count }
return (a.first ?? "") < (b.first ?? "") // ties ordered by smallest member
}
}
Layer two: equal-sized clusters had no defined order
The group list sorts by cluster size descending, then prefix(50) / prefix(30) truncates for display. But sorted { a.count > b.count } doesn’t guarantee order for equal-sized clusters — two 3-photo clusters shuffle unpredictably. The trouble surfaces at the truncation boundary: a cluster sitting right around rank 50 slides in and out of the prefix as they shuffle, genuinely disappearing and reappearing between rebuilds.
The fix is folded into that same canonicalClusters: on equal size, a secondary sort by “smallest member id” pins the truncation boundary.
Layer three: in-group photo order was iteration order
The cover is “the first photo in the group,” and the thumbnail strip lays out in group order. If that order also comes from iteration, then every rebuild reshuffles the cover and the strip, and even the BestShot (pick-the-best-one) tiebreak can flip. Same fix by analogy — give in-group photos a stable, meaningful order: chronological, with id as tiebreak.
private static func displayOrder(_ photos: [PhotoAsset]) -> [PhotoAsset] {
photos.sorted {
let l = $0.creationDate ?? .distantPast
let r = $1.creationDate ?? .distantPast
if l != r { return l < r }
return $0.id < $1.id
}
}
The real culprit: a drifting window anchor
With all three layers fixed, the cards still cycled. That’s when I dug to the root: visual-similarity bucketing used gap-anchored time windows — “start a new window when >5min from the window start” — and the anchors were derived from the subset of photos analyzed so far.
The problem is that the scan is still running, and “the subset analyzed so far” changes every 2 seconds. Every new batch of features shifted the anchors, re-bucketed the same photos into different windows, split or merged their clusters, and groups bounced across the count >= 3 filter line — cards cycling in and out. The three determinism fixes couldn’t reach this, because they stabilize “how to output a given bucketing” while the bucketing itself was drifting.
The fix swaps relative anchoring for absolute epoch-aligned bins: floor(creation / 300) drops each photo into a fixed 5-minute slot. Slot membership depends only on the photo’s own timestamp, not on “how much is analyzed” — so as analysis lands, membership grows monotonically and never reshuffles:
var bins: [Double: [String]] = [:]
for (id, creation) in timeline {
bins[(creation / windowSeconds).rounded(.down), default: []].append(id)
}
The tradeoff is documented in the code comment: a shooting session straddling a bin edge gets split into two groups (gap anchoring would have kept them together). But near-duplicates are usually seconds apart, so the straddle probability is low — trading that small chance of a split for a stable Groups screen is worth it.
A parting shot: rebuild races
One more sneaky flicker source: rebuildGroups can be triggered concurrently by several sources — the 2-second timer, launch bootstrap, pending-bin refresh. Two rebuilds read snapshots from different moments, and if applyGroups lands out of order, an older, smaller result overwrites a fresh complete one — a blink. The fix coalesces concurrent rebuild calls; if a race is ever lost, the next timer tick repairs it within seconds.
And retiring the “nuclear rescan”
Chasing this thread surfaced a related bad design: after ignoring a group, the user’s “rescan” took minutes. Because that action, clearCacheAndRestart, wiped the entire feature cache (DELETE + VACUUM) and re-extracted Vision features, blur, and dHash for every photo — and nothing legitimate needed that.
Every staleness case was already caught incrementally by the launch path: new photos aren’t in the analyzed set, edited photos fail the modTime check, algorithm changes migrate via schemaVersion. So it became a single unified incremental rebuild — reusing the same “re-cluster and analyze only what’s stale, reuse everything else” path used at launch and on external library changes: seconds, not minutes, and no destructive confirmation. Along the way, clearCacheAndRestart, FeatureStore.clear(), and 8 dead string keys across five languages went away.
Takeaways
- Progressive UI presupposes idempotent rebuilds. If “the same input” computes any difference in id / ordering / in-group order across two runs, SwiftUI renders that difference as tear-down-rebuild, i.e. flicker — the whole value of progressive reveal rests on “things that didn’t change actually don’t change”;
- Nondeterminism stacks in layers, and fixing one may not show results. After fixing id, ordering, and in-group order it still flickered — because the deepest layer (the bucketing anchor) was still drifting. Don’t stop at “fixed one, still broken”; dig until the input itself is stable;
- Anchor on absolute quantities, not “the data known so far.” Gap anchoring depends on the analyzed subset, and the subset is growing — any anchor that “changes as data arrives” is a wobble source. Absolute bucketing like epoch alignment trades a bit of edge-splitting for monotonicity;
- “Clear and redo” is almost always too heavy. If you can identify stale items incrementally, don’t nuke and recompute everything — it’s slow, and it tends to drag in destructive operations and a pile of bespoke UI.
Comments