Performing optimistic updates

You might have noticed that "like" or "favorite" action in social media almost never makes you wait, although there is a network call in progress in the background. The users who "like" content are generally more likely to come back and engage with more content. This is because platforms choose to optimize flows that lead to user engagement and strive to make these flows as frictionless as possible.

Consider Twitter's like button. The heart icon immediately goes red when we tap it. This is good because we can simply like something and move on when the real work still happens in the background.

So how can we implement this? Let's first define what needs to happen from a user's perspective.

  • The UI immediately reacts as if the action was successful. (This is why we call it optimistic.)
  • We perform the network request in the background.
    • If it succeeds, happy days, we already performed the update.
    • If it fails, we need to revert the optimistic update.

Now, to achieve this in code, we need to know...

  • what is the actual task we want to perform and how we can perform this task
  • what is the optimistic update we want to perform while this task is in progress
  • and how we can revert the optimistic update if the task fails

If we have all this, we can even design a generic API that aids us when implementing optimistic updates. Let's see if we can do that.

Consider we have the following data model.

struct Post {
  var id: UUID
  var message: String
  var isLiked: Bool
}

Let's define the task and the update we want to perform on this model.

enum PostTask {
  case like(id: UUID)
}

enum PostUpdate {
  case isLiked(Bool)
}

Now we need to define how we can perform this task and apply the model updates.

class PostService {
  func perform(
    _ task: PostTask, 
    completion: (Result<Bool, Error>) -> Void
  ) {
    // perform the network call
    // report the result in the callback
  }
}

This service will only perform the task and report the result. Useful but not quite there. What we need is more like a function that receives both the task and the optimistic update.

let service = PostService()

func perform(
  _ task: PostTask, 
  optimisticUpdate: PostUpdate
) {
  // perform the optimistic update here!
  service.perform(task) { result in
    switch result {
    case .success:
      // task is successful. happy days!
    case let .failure(error):
      // task has failed. revert the optimistic update!
  }
}

OK, we got the shape but there are missing pieces.

  1. Generic models. If we want a generic API, we should not rely on PostTask or PostUpdate.
  2. Generic service. We need a way to inject a service that can perform the given task.
  3. Generic events. We need a way to broadcast important events so the updates can be performed.

This feels like we need a stateful object instead of just a function. So let's start with a generic class.

class OptimisticUpdater<Task, Update, Error> { ... }

We tackled the first problem. Now we need a generic function that can perform the task and report the result.

enum TaskResult<Update, Error: Swift.Error> {
  /// Task is successful. Optimistic update can be kept.
  case success
 
  /// Task has failed. Optimistic update should be reverted.
  case failure(Error, rollbackUpdate: Update)
}

typealias PerformTaskFunction<Task, Update, Error> = (
  _ task: Task,
  _ update: Update,
  _ finish: @escaping (TaskResult<Update, Error>) -> Void
) -> Void

Now that we have also defined a generic function signature for performing tasks, we can move on to the next problem. We need to publish optimistic update events so that the model can be updated and changes can be propagated to the UI.

We can start with a simple enum and deliver events using a block.

class OptimisticUpdater<Task, Update, Error> {
  enum Event {
    /// Optimistic update should be applied. Task is about to start executing.
    case optimisticUpdateRequested(Update, task: Task)

    /// Task is successful. Optimistic update should be kept.
    case taskSucceeded(Task, update: Update)
 
    /// Task has failed. Optimistic update should be reverted.
    case taskFailed(Task, error: Error, failedUpdate: Update, rollbackUpdate: Update)
  }
 
  var eventHandler: ((Event) -> Void)?
 }

As we also solved the last problem, if we glue everything together, we get an OptimisticUpdater implementation like this one.

Let's see how we can use it.

let service = PostService()
var post = Post(id: UUID(), message: "Test", isLiked: false)

// 1. Create an optimistic updater:
postUpdater = OptimisticUpdater<PostTask, PostUpdate, PostTaskError> { 
  task, optimisticUpdate, finish in
                                                                       
  service.perform(task) { (result) in
    switch result {
    case .success:
      finish(.success)
    case let .failure(error):
      finish(.failure(error, rollbackUpdate: optimisticUpdate.revert()))
    }
  }
}
 
// 2: Set a callback for updater events:
postUpdater.eventHandler = { event in
  switch event {
  case let .optimisticUpdateRequested(update, task: task):
    post.apply(update)
  case let .taskSucceeded(task, update: update):
    showConfirmation(task)
  case let .taskFailed(task, error: error, failedUpdate: failedUpdate, rollbackUpdate: rollbackUpdate):
    showError(error, for: task)
    post.apply(rollbackUpdate)
  }
}
 
// 3. Use the updater to like a post:
postUpdater.perform(.like(id: post.id), optimisticUpdate: .isLiked(true))

So what did we achieve here?

  • We completely separated real work from the optimistic updates.
  • We isolated the logic required to perform an optimistic update in one component, which can be used to update any model.
  • We don't have to think about when the update should happen and when it should be reverted every time we use it!
  • It is completely testable. See below:
var service = MockPostService()
var post = Post(id: UUID(), message: "Test", isLiked: false)
var postUpdater = OptimisticUpdater { /* use mock service here */ }
var success: Bool?
postUpdater.eventHandler = { event in
  switch event {
  case let .optimisticUpdateRequested(update, task: task):
    post.apply(update)
  case let .taskSucceeded(task, update: update):
    success = true
  case let .taskFailed(task, error: error, failedUpdate: failedUpdate, rollbackUpdate: rollbackUpdate):
    post.apply(rollbackUpdate)
    success = false
  }
}

// When the task is performed and the call is in progress
postUpdater.perform(.like(id: post.id), optimisticUpdate: .isLiked(true))

// Then expect optimistic model update post.isLiked=true
XCTAssertEqual(success, nil)
XCTAssertEqual(post.isLiked, true)

// When the response is received
service.resume()

// Then expect post.isLiked=true and success=true
XCTAssertEqual(success, true)
XCTAssertEqual(post.isLiked, true)

Thanks for reading! ❤️