Swift
UITableView
Image Loading
Async Programming
iOS Development

Async image loading from url inside a UITableView cell - image changes to wrong image while scrolling

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Wrong images appearing in reused table view cells is a classic UIKit issue. The root cause is asynchronous network completion arriving after the cell has been reused for a different row. The fix combines request cancellation, identity checks, and optional caching.

Why Cell Reuse Causes Image Mismatch

UITableView reuses cell instances as you scroll. If an image request started for old row data completes later, it can set an image on a cell now representing a different item. Without guardrails, fast scrolling makes this bug frequent.

A robust design gives each cell a model identifier and cancels outdated tasks in prepareForReuse.

swift
1import UIKit
2
3final class AvatarCell: UITableViewCell {
4    @IBOutlet private weak var avatarView: UIImageView!
5    private var task: URLSessionDataTask?
6    private var currentID: String?
7
8    override func prepareForReuse() {
9        super.prepareForReuse()
10        task?.cancel()
11        task = nil
12        currentID = nil
13        avatarView.image = UIImage(systemName: "person.crop.circle")
14    }
15
16    func configure(id: String, imageURL: URL, loader: ImageLoader) {
17        currentID = id
18        avatarView.image = UIImage(systemName: "person.crop.circle")
19
20        task = loader.load(url: imageURL) { [weak self] image in
21            guard let self = self else { return }
22            guard self.currentID == id else { return }
23            self.avatarView.image = image
24        }
25    }
26}

The currentID check ensures stale callbacks are ignored.

Centralized Image Loader with Cache

A loader service prevents duplicated requests and improves scroll performance through caching.

swift
1import UIKit
2
3final class ImageLoader {
4    private let cache = NSCache<NSURL, UIImage>()
5
6    @discardableResult
7    func load(url: URL, completion: @escaping (UIImage?) -> Void) -> URLSessionDataTask? {
8        if let cached = cache.object(forKey: url as NSURL) {
9            completion(cached)
10            return nil
11        }
12
13        let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
14            guard let data = data, let image = UIImage(data: data) else {
15                DispatchQueue.main.async { completion(nil) }
16                return
17            }
18            self?.cache.setObject(image, forKey: url as NSURL)
19            DispatchQueue.main.async { completion(image) }
20        }
21        task.resume()
22        return task
23    }
24}

Inject one loader instance into your table view controller so all cells share cache state.

Data Source Integration Pattern

Always bind cells from immutable view-model data keyed by stable IDs. Avoid using index path as identity because rows can move after inserts or deletes.

swift
1struct UserRow {
2    let id: String
3    let name: String
4    let avatarURL: URL
5}

In cellForRowAt, pass row.id and row.avatarURL into configure. This keeps asynchronous completion aligned with logical row identity.

Debugging and Verification

To verify the fix, simulate slow network conditions and perform rapid scroll tests. Log both requested ID and applied ID inside completion handlers. If logs ever differ, stale write protection is missing.

You can also profile cache hit rates and request counts. After optimization, repeated scroll passes should show fewer network fetches and fewer visible image flashes.

Prefetching and Cancellation at Scale

For long lists, adopt UITableViewDataSourcePrefetching so image requests start before cells appear. Pair prefetch with request deduplication in your loader to avoid multiple downloads of the same URL. If a prefetched row scrolls away, cancel low-priority requests unless another visible cell still needs them.

swift
1final class UsersController: UITableViewController, UITableViewDataSourcePrefetching {
2    var rows: [UserRow] = []
3    let loader = ImageLoader()
4
5    override func viewDidLoad() {
6        super.viewDidLoad()
7        tableView.prefetchDataSource = self
8    }
9
10    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
11        for ip in indexPaths {
12            _ = loader.load(url: rows[ip.row].avatarURL) { _ in }
13        }
14    }
15}

This architecture improves perceived smoothness and reduces visible image pop-in. It also prepares your code for offline cache layers if product requirements later include low-connectivity support.

Common Pitfalls

  • Setting images in completion blocks without checking cell identity.
  • Forgetting to cancel previous network tasks in prepareForReuse.
  • Using index paths as long-lived identity keys.
  • Creating one loader per cell and defeating cache reuse.
  • Updating UI from background threads after image decode.

Summary

  • Cell reuse plus async network calls causes wrong-image bugs.
  • Cancel old tasks and track a stable model identifier per cell.
  • Ignore stale callbacks by comparing identifier at completion time.
  • Centralize loading and caching for consistency and speed.
  • Test under slow-network and fast-scroll scenarios before release.

Course illustration
Course illustration

All Rights Reserved.