When we started building Skyscraper, our Bluesky client for iOS, one of the first decisions was choosing between SwiftUI and UIKit. Social media apps present unique challenges: infinite scrolling feeds, dynamic content with varying heights, embedded media, real-time updates, and the expectation of buttery-smooth 60fps performance. Here's what we learned.

The Social Media App Challenge

Before comparing frameworks, let's understand what makes social media apps technically demanding:

  • Infinite scrolling - Users expect to scroll through hundreds or thousands of posts
  • Variable cell heights - Posts with text, images, videos, embeds, and quotes all have different sizes
  • Rich media - Images need lazy loading, caching, and smooth transitions
  • Real-time updates - New posts, likes, and reposts should appear without jarring refreshes
  • Complex interactions - Likes, reposts, replies, and swipe gestures need to feel instant
  • Memory management - Can't keep thousands of posts in memory

SwiftUI: The Modern Choice

SwiftUI is Apple's declarative UI framework, introduced in 2019 and continuously improved since. Here's how it stacks up for social media apps:

SwiftUI Advantages

1. Declarative Syntax = Faster Development

SwiftUI's declarative approach means less code and faster iteration:

// SwiftUI: A post cell in ~30 lines
struct PostView: View {
    let post: Post
    @State private var isLiked = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Author header
            HStack {
                AsyncImage(url: post.author.avatarURL)
                    .frame(width: 44, height: 44)
                    .clipShape(Circle())

                VStack(alignment: .leading) {
                    Text(post.author.displayName)
                        .font(.headline)
                    Text("@\(post.author.handle)")
                        .foregroundColor(.secondary)
                }
            }

            // Post content
            Text(post.text)

            // Action buttons
            HStack {
                Button(action: { isLiked.toggle() }) {
                    Image(systemName: isLiked ? "heart.fill" : "heart")
                }
            }
        }
        .padding()
    }
}

2. Built-in State Management

SwiftUI's @State, @Binding, @ObservableObject, and the new @Observable macro make reactive UIs straightforward:

@Observable
class TimelineViewModel {
    var posts: [Post] = []
    var isLoading = false

    func loadMore() async {
        isLoading = true
        let newPosts = await api.getTimeline()
        posts.append(contentsOf: newPosts)
        isLoading = false
    }
}

3. Automatic Animations

Adding animations is trivial - great for like buttons, expanding content, and transitions:

Button(action: {
    withAnimation(.spring(response: 0.3)) {
        isLiked.toggle()
    }
}) {
    Image(systemName: isLiked ? "heart.fill" : "heart")
        .foregroundColor(isLiked ? .red : .gray)
        .scaleEffect(isLiked ? 1.2 : 1.0)
}

4. Preview Canvas

SwiftUI Previews dramatically speed up UI development - see changes instantly without rebuilding:

#Preview {
    PostView(post: .sample)
}

#Preview("Dark Mode") {
    PostView(post: .sample)
        .preferredColorScheme(.dark)
}

5. Cross-Platform Potential

SwiftUI code can run on iOS, iPadOS, macOS, watchOS, and tvOS with minimal changes. One codebase for your Bluesky client across all Apple platforms.

SwiftUI Disadvantages

1. List Performance Concerns

SwiftUI's List and LazyVStack have improved significantly, but can still struggle with complex cells:

// This can be slow with complex cells
LazyVStack {
    ForEach(posts) { post in
        PostView(post: post) // Complex view with images, embeds, etc.
    }
}

// Solutions:
// 1. Use List with explicit IDs
// 2. Simplify cell views
// 3. Use UIKit via UIViewRepresentable for the scroll view

2. Less Control Over Scroll Behavior

Fine-tuning scroll behavior (prefetching, maintaining scroll position during updates) is harder:

// UIKit gives you UIScrollViewDelegate with fine-grained control
// SwiftUI's ScrollView callbacks are more limited

ScrollView {
    LazyVStack {
        // Limited control over prefetching distance
        // Harder to maintain position when inserting items
    }
}

3. Memory and View Identity Issues

SwiftUI can recreate views unexpectedly, causing image reloads and state loss:

// Problem: Image might reload if view identity changes
AsyncImage(url: post.imageURL)

// Solution: Stable identity and caching
AsyncImage(url: post.imageURL)
    .id(post.id) // Stable identity
// Plus: Use a proper image caching library like Kingfisher or Nuke

4. Debugging Difficulty

When SwiftUI views don't update or perform poorly, diagnosing the issue can be challenging. The declarative nature that makes development fast can make debugging slow.

UIKit: The Battle-Tested Veteran

UIKit has powered iOS apps since 2008. It's imperative, verbose, but incredibly powerful.

UIKit Advantages

1. Proven Scrolling Performance

UITableView and UICollectionView are engineered for infinite scrolling:

class TimelineViewController: UIViewController {
    private let tableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.register(PostCell.self, forCellReuseIdentifier: "post")
        tableView.prefetchDataSource = self // Prefetch images before visible
        tableView.estimatedRowHeight = 200
        tableView.rowHeight = UITableView.automaticDimension
    }
}

// Prefetching for smooth scrolling
extension TimelineViewController: UITableViewDataSourcePrefetching {
    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            let post = posts[indexPath.row]
            ImageCache.shared.prefetch(post.imageURLs)
        }
    }
}

2. Fine-Grained Scroll Control

UIScrollViewDelegate gives you complete control:

extension TimelineViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // Load more when near bottom
        let offsetY = scrollView.contentOffset.y
        let contentHeight = scrollView.contentSize.height

        if offsetY > contentHeight - scrollView.frame.height * 2 {
            loadMorePosts()
        }
    }

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        // Pause video playback while scrolling
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        // Resume video playback for visible cells
    }
}

3. Mature Cell Reuse System

UITableView's cell reuse is battle-tested and efficient:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "post", for: indexPath) as! PostCell

    let post = posts[indexPath.row]
    cell.configure(with: post)

    return cell
}

class PostCell: UITableViewCell {
    override func prepareForReuse() {
        super.prepareForReuse()
        // Cancel image loads, reset state
        imageView?.image = nil
        contentLabel.text = nil
    }
}

4. Diffable Data Sources

Modern UIKit has excellent tools for animating data changes:

var dataSource: UITableViewDiffableDataSource!

func updatePosts(_ newPosts: [Post]) {
    var snapshot = NSDiffableDataSourceSnapshot()
    snapshot.appendSections([.main])
    snapshot.appendItems(newPosts)

    dataSource.apply(snapshot, animatingDifferences: true)
}

UIKit Disadvantages

1. Verbose Boilerplate

UIKit requires significantly more code for the same UI:

// UIKit: Setting up a simple label
let label = UILabel()
label.text = "Hello"
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .label
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)

NSLayoutConstraint.activate([
    label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
    label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
    label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16)
])

// SwiftUI equivalent:
Text("Hello")
    .font(.headline)
    .padding()

2. Manual State Management

You're responsible for keeping UI in sync with data:

// Must manually update UI when data changes
var isLiked: Bool = false {
    didSet {
        updateLikeButton()
    }
}

func updateLikeButton() {
    let imageName = isLiked ? "heart.fill" : "heart"
    likeButton.setImage(UIImage(systemName: imageName), for: .normal)
    likeButton.tintColor = isLiked ? .red : .gray
}

3. No Live Previews

You need to build and run to see UI changes (though Xcode Previews can work with UIKit via PreviewProvider, it's not as seamless).

4. Legacy Feel

UIKit's imperative patterns can feel dated compared to modern declarative frameworks.

The Hybrid Approach: Best of Both Worlds

Many successful apps use both frameworks. Here's how:

SwiftUI Shell, UIKit Lists

// Main app structure in SwiftUI
struct ContentView: View {
    var body: some View {
        TabView {
            TimelineView()
                .tabItem { Label("Home", systemImage: "house") }

            DiscoverView()
                .tabItem { Label("Discover", systemImage: "magnifyingglass") }
        }
    }
}

// High-performance timeline using UIKit
struct TimelineView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> TimelineViewController {
        TimelineViewController()
    }

    func updateUIViewController(_ uiViewController: TimelineViewController, context: Context) {
        // Update if needed
    }
}

UIKit Cells in SwiftUI Lists

// Use UIKit cells for complex content
struct PostCell: UIViewRepresentable {
    let post: Post

    func makeUIView(context: Context) -> PostUIView {
        PostUIView()
    }

    func updateUIView(_ uiView: PostUIView, context: Context) {
        uiView.configure(with: post)
    }
}

Performance Tips for Either Framework

Regardless of your choice, these tips will help:

1. Image Caching is Critical

// Use a proper image caching library
// Kingfisher, Nuke, or SDWebImage

// Don't do this:
AsyncImage(url: url) // No caching!

// Do this:
KFImage(url)
    .resizable()
    .placeholder { ProgressView() }
    .fade(duration: 0.25)

2. Lazy Loading Everything

// Only load what's visible
LazyVStack { ... } // SwiftUI
tableView.prefetchDataSource = self // UIKit

3. Measure, Don't Guess

// Use Instruments to profile:
// - Time Profiler for CPU usage
// - Allocations for memory
// - Core Animation for rendering

// Add signposts for custom measurements
import os.signpost

let log = OSLog(subsystem: "com.app", category: "performance")

os_signpost(.begin, log: log, name: "Load Timeline")
// ... load timeline
os_signpost(.end, log: log, name: "Load Timeline")

4. Avoid Expensive Operations on Main Thread

// Bad: Parsing on main thread
let posts = try JSONDecoder().decode([Post].self, from: data)

// Good: Parse on background thread
Task.detached {
    let posts = try JSONDecoder().decode([Post].self, from: data)
    await MainActor.run {
        self.posts = posts
    }
}

Our Recommendation for Bluesky Clients

After building Skyscraper, here's our take:

  • New projects in 2025: Start with SwiftUI. The developer experience is vastly better, and performance has improved significantly. You can always drop to UIKit for specific components.
  • Performance-critical feeds: Consider UIViewRepresentable with UITableView/UICollectionView for the main timeline if you hit SwiftUI limits.
  • Everything else: SwiftUI for settings, profiles, compose screens, and navigation. The productivity gains are worth it.
  • Target iOS 17+: This unlocks the best SwiftUI performance and features like @Observable.

Building Your Bluesky Client

Whether you choose SwiftUI, UIKit, or a hybrid approach, the key is shipping something users love. A fast, responsive client that helps users grow their Bluesky presence and engage with the community will succeed regardless of the underlying framework.

Want to see what's trending while you build? Check out our tools:

Happy coding, and may your scroll views always be smooth!