UITableView
performance
cellForRowAtIndexPath
images
optimization

TableView slow when adding images to cellForRowAtIndex

Master System Design with Codemia

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

Introduction

UITableView scrolling becomes sluggish when cellForRowAt does too much work on the main thread. Image loading is a common cause because decoding, resizing, disk access, and network fetches are expensive compared with the lightweight view configuration that table view cells are supposed to do.

The fix is not to stop using images. The fix is to separate image retrieval from cell configuration and let reuse, caching, and asynchronous loading work together.

Why cellForRowAt Gets Slow

cellForRowAt is called repeatedly while the user scrolls. That means any slow work inside it is multiplied across many rows. Common causes include:

  • downloading images synchronously
  • decoding large image files on the main thread
  • resizing full-resolution images for tiny thumbnails
  • failing to reuse cached results

A slow version often looks like this:

swift
1func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
2    let cell = tableView.dequeueReusableCell(withIdentifier: "PhotoCell", for: indexPath)
3    let url = imageURLs[indexPath.row]
4
5    if let data = try? Data(contentsOf: url),
6       let image = UIImage(data: data) {
7        cell.imageView?.image = image
8    }
9
10    return cell
11}

Data(contentsOf:) blocks the current thread. If this runs on the main thread during scrolling, frame drops are almost guaranteed.

What Good Cell Configuration Looks Like

Cell configuration should be fast and deterministic:

  • set labels
  • set placeholder images
  • kick off image loading if needed
  • return immediately

A better pattern is to use asynchronous loading plus caching:

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

And then use it in the table view:

swift
1func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
2    let cell = tableView.dequeueReusableCell(withIdentifier: "PhotoCell", for: indexPath)
3    let url = imageURLs[indexPath.row]
4
5    cell.textLabel?.text = titles[indexPath.row]
6    cell.imageView?.image = UIImage(named: "placeholder")
7
8    ImageLoader.shared.loadImage(from: url) { image in
9        guard let visibleCell = tableView.cellForRow(at: indexPath) else { return }
10        visibleCell.imageView?.image = image
11        visibleCell.setNeedsLayout()
12    }
13
14    return cell
15}

This keeps the table responsive because the cell is returned immediately.

Cell Reuse and Wrong Images

Because cells are reused, asynchronous loading introduces another issue: by the time the image arrives, the cell may represent a different row. One way to handle that is to move the image-loading logic into a custom cell and track the active URL.

swift
1class PhotoCell: UITableViewCell {
2    var representedURL: URL?
3
4    override func prepareForReuse() {
5        super.prepareForReuse()
6        representedURL = nil
7        imageView?.image = UIImage(named: "placeholder")
8    }
9}

Then check the URL before assigning the image:

swift
1cell.representedURL = url
2
3ImageLoader.shared.loadImage(from: url) { image in
4    if cell.representedURL == url {
5        cell.imageView?.image = image
6        cell.setNeedsLayout()
7    }
8}

That prevents flickering and mismatched thumbnails.

Resize Images for Display Size

Even if loading is asynchronous, using full-size photos as thumbnails wastes memory and CPU. If a cell shows a 60x60 image, storing and decoding a multi-megabyte photo for every row is expensive.

A better approach is to:

  • fetch a smaller image from the server when possible
  • preprocess thumbnails in the background
  • cache the resized result, not only the original

Performance problems are often more about image size than about the table view itself.

Common Pitfalls

The biggest pitfall is doing synchronous work in cellForRowAt, especially network or disk-heavy image loading.

Another issue is ignoring cell reuse. Without reuse-aware checks, asynchronous callbacks can apply the wrong image to a recycled cell.

Large image decoding is also a hidden cost. Even cached images can hurt scrolling if they are much larger than the display size.

Finally, avoid reloading the whole table view every time a single image finishes. Update only the visible cell or the affected row to keep scrolling smooth.

Summary

  • 'cellForRowAt should configure cells quickly and return immediately.'
  • Load images asynchronously and cache the results.
  • Use placeholders and reuse-aware checks to avoid wrong-image flicker.
  • Resize or request smaller thumbnails instead of decoding full-size images for table cells.
  • Most UITableView image lag comes from main-thread work, not from the table view itself.

Course illustration
Course illustration

All Rights Reserved.