What the problem actually is
You scroll a list quickly and one cell shows the wrong image for a split second.
Maybe it corrects itself. Maybe it stays wrong until another reload.
This is one of those bugs that looks cosmetic in screenshots but feels low quality in a real product.
The underlying problem is simple:
A cell got reused for new content while old async image work was still alive.
When the old work completed, it wrote into a view that no longer represented the same item.
Why it keeps happening
Teams often blame caching first.
Caching can make the bug more visible, but it is rarely the root cause.
The root cause is usually that image loading is keyed to the cell lifetime instead of the rendered item identity.
That leads to fragile patterns such as:
- loading inside
cellForItemAtwithout cancellation - comparing
indexPathinstead of stable item identity - leaving the previous image visible until a new one arrives
- keeping background tasks alive across
prepareForReuse()
When reuse is fast and the network is slow, stale binding wins.
The implementation boundary that matters
The important boundary is not “which URL is being loaded”.
It is “which item does this cell currently represent”.
The cell needs an explicit binding identity.
If the completed image request does not match that identity anymore, the result should be dropped.
That turns reuse from a visual guess into a concrete contract.
A concrete pattern to fix it
The pattern that works reliably is:
- Bind the cell to a stable item identifier.
- Cancel old image work in
prepareForReuse(). - Reset the image view to a known placeholder immediately.
- Apply the result only if the completed request still matches the current item identity.
This is simplified pseudocode, not production code.
import UIKit
final class FeedImageCell: UICollectionViewCell {
private var representedID: String?
private var imageTask: Task<Void, Never>?
override func prepareForReuse() {
super.prepareForReuse()
representedID = nil
imageTask?.cancel()
imageTask = nil
thumbnailView.image = UIImage(named: "placeholder")
}
func configure(with item: FeedItem, imageLoader: ImageLoader) {
representedID = item.id
thumbnailView.image = UIImage(named: "placeholder")
imageTask?.cancel()
imageTask = Task { [weak self] in
let image = try? await imageLoader.image(for: item.imageURL)
guard !Task.isCancelled else { return }
guard let self, self.representedID == item.id else { return }
self.thumbnailView.image = image
}
}
}
The placeholder reset is not optional decoration.
It removes the stale visual state immediately so the user never sees the previous item’s image pretending to belong to the new one.
How to verify the fix
This bug only proves itself under pressure.
Test it with:
- fast repeated scrolling up and down
- network throttling
- cache hit and cache miss mixes
- repeated URLs mapped to different cells
- diffable updates while images are still loading
Log the item identity at bind time and completion time if needed.
You want evidence that a stale completion cannot mutate the wrong visible cell anymore.
What still goes wrong in production
One common mistake is using indexPath as identity. That falls apart as soon as items move.
Another is hiding the bug behind fade animations. The stale image still arrives. It just arrives more gracefully.
A third is putting too much logic in a global image-view extension that nobody can reason about during reuse.
The safe rule is:
The cell owns visible binding. The loader owns retrieval. The result only applies if the cell still represents the same item.
That is what stops reused cells from showing the wrong image.