United Kingdom: +44 (0)208 088 8978

Optimistic updates with SAFE Stack and F#

In this post Amir explores using Optimistic Updates as a way to enhance UX for SAFE stack apps.

We're hiring Software Developers

Click here to find out more

Imagine you're using a todo app and add a new task. You hit "Add," but nothing happens for a few seconds. Then, after a delay, the task appears. Frustrating, right? In this post I will explore the idea of how we can use Optimistic updates to solve this problem within SAFE stack.

Optimistic Updates - What are they?

Whenever we do a create, update or delete operation in our web app, we may need to perform some api call which is most likely asynchronous and could take an uncertain amount of time. Optimistic updates are where we immediately update the client side state to represent the successful outcome of our operation as soon as we initiate the API call. Therefore our model represents an updated version of our client side state while our the server is processing the operation. Depending on what the server comes back with we can do 2 things: Do nothing with a successful response or in the case of an error/failure, roll back our client side state to whatever it was prior to the update in order to stay consistent with the actual source of truth, such as a database or cache.

Some API calls may be instantaneous, but not all. Without optimistic updates, your application may feel unresponsive, forcing users to wait for an operation on the server to finish before seeing the expected result on the front end. By assuming the action will succeed, optimistic updates improve UX, making things feel smoother.

Visual Comparison

We will use a todo app written with SAFE to demonstrate these ideas.

❌ Optimistic updates

without-optimistic-updates
(Here the user waits few seconds before they see their newly created todo appear in the todo list due to some long waiting api call)

✅ Optimistic Updates

with-optimistic-updates
(Here the user instantly sees their newly created todo appear in the todo list and later on is updated to reflect the value coming back from the api call.)

Breaking it down

Before diving in, it may help to read about RemoteData and the ApiCall type which is a part of our SAFE.Meta package: Dealing with Remote Data Using SAFE Client.>

Let's create a type to represent our Optimistic value:

type Optimistic<'T> = ('T * 'T option) option

The key part is the tuple ('T * 'T option), which represents (currentValue * previousValue). Since Optimistic is wrapped in Some, currentValue will always be present. However, previousValue may be None when there's no previous state. If we do need to roll back, we shift previousValue into currentValue and set previousValue to None.

Here's what our model looks like when we plug in the Optimistic type into RemoteData

type Model = {
    Todos: RemoteData<Optimistic<Todo list>>
    Input: string
}

Let's assume we have an endpoint in our backend that deals with adding a new todo:

let addTodo todo =
    // Simulates a call to an async source
    System.Threading.Thread.Sleep(2000)

    let markedTodo = {todo with Description = todo.Description + ": server"}

    if Todo.isValid todo.Description then
        todos.Add markedTodo
        Ok()
    else
        Error "Invalid todo"

When the SaveTodo message is triggered in the Elmish loop, we immediately update the todo list with the newly entered todo — essentially "jumping the gun" before the API responds. Once the API call completes, the Finished case determines the final outcome: either confirming the update if successful or rolling back if it fails to keep our state consistent with the source of truth.

//...
| SaveTodo msg ->
    match msg with
    | Start todoText ->
        // Initiate the api call to add the todo to the database
        let saveTodoCmd =
            let todo = Todo.create todoText
            Cmd.OfAsync.perform todosApi.addTodo todo (Finished >> SaveTodo)

        // Preemptively update the todos list by appending it with a newly created Todo from the input
        // passed in
        let newTodos =
            model.Todos
            |> RemoteData.map (Optimistic.updateWith (fun currentTodos -> currentTodos @ [ Todo.create todoText ]) [])

        {
            model with
                Input = ""
                Todos = RemoteData.startLoading newTodos ()
        },
        saveTodoCmd
    | Finished todos ->
        // We now received the API's response
        {
            model with
                Todos =
                    match todos with
                    | Ok x -> model.Todos |> RemoteData.map (Optimistic.update x)
                    | Error e -> model.Todos |> RemoteData.map (Optimistic.rollback)
                    |> RemoteData.bind (fun x -> x |> RemoteData.Loaded)
        },
        Cmd.none
//...

What happens when there is a failed operation?

Let's introduce failure cases randomly using a coin flip in our backend handler. We simulate a delay to mimic real-world scenarios where a database or external source is involved. To help differentiate client and server values, we append ": server" to the todo's description. (In real applications, this isn't necessary, as a successful response means no further updates are required.)

let addTodo todo =
    // Simulate a call to an async source
    System.Threading.Thread.Sleep(2000)

    //++ random num gen
    let random = System.Random()

    let markedTodo = {todo with Description = todo.Description + ": server"}

    //++ flip of a coin error instigator
    if Todo.isValid todo.Description && random.Next(2) = 0 then
        todos.Add markedTodo
        Ok()
    else
        Error "Invalid todo"

We use the rollback helper function to move the previous value as the new current value

/// Rolls back to the previous value
let rollback (optimistic: Optimistic<'T>): Optimistic<'T> = 
    match optimistic with
    | None -> None
    | Some (_, Some pv) -> Some (pv , None)
    | Some (_, None) -> None

showcasing-rollback
(Here the user instantly sees their newly created todo appear in the todo list but vanishes after few seconds due to an invalid todo failure from the server)

Conclusion

Optimistic updates greatly enhance UX by making interactions feel instant while keeping the application responsive. Consider using them in your SAFE Stack apps!

Further investigation could include handling multiple back-to-back operations and how rollbacks should behave in such cases (perhaps using a queue mechanism?)

If you’d like to dive deeper and experiment with this implementation, check out the demo repo on GitHub.