1. Introduction

Cats Effect is one of the most popular functional programming libraries in Scala. It has some methods that look similar but have completely different functionalities. In this tutorial, let’s explore the differences between three seemingly similar methods – delay, defer, and deferred.

2. Setup

Let’s first add the cats-effect dependency to the build.sbt:

libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.0"

3. Usage of delay

The delay method allows us to create an IO effect. We can invoke delay on IO to suspend a side effect operation. Let’s look at it with an example:

val delayedIO: IO[Unit] = IO.delay(println("Creating a delayed effect"))

This will suspend the println statement until the IO is executed explicitly. When we create an IO using the apply method, it delegates the creation to the delay method. That means IO(“test”) is the same as IO.delay(“test”). Cats Effect also provides a method delayBy on an existing IO to delay its execution by the given duration:

val io = IO(println("Hello World"))
val delayedDurationIO = io.delayBy(3.seconds)

When we execute delayedDurationIO, it will print the Hello World after a three-second delay.

4. Usage of defer

The defer method is similar to delay, except that defer will suspend the side effect producing IO in another IO:

val deferIO: IO[Unit] = IO.defer(IO(println("IO in defer")))

This is similar to using the delay method with flatten. Let’s rewrite the above code as:

val deferIO: IO[Unit] = IO.delay(IO(println("IO in defer"))).flatten

The method defer helps to write stack-safe operations using IO. Let’s look at it with a simple recursive method:

def neverEnding(io: IO[Int]): IO[Unit] = {
  io *> neverEnding(io)
}

When executed, this method will fail with a StackOverflowException. We can rewrite the above example using defer to make it stack-safe:

def neverEndingV2(io: IO[Int]): IO[Unit] = {
  io *> IO.defer(neverEndingV2(io))
}

As a result, when we execute this code, it will continuously run forever without throwing any StackOverflowException. The method defer will ensure that the recursive call is lazily evaluated and, hence, avoid causing multiple stack allocations.

5. Usage of Deferred Instances and the deferred Method

Deferred is a purely functional synchronization primitive that represents a value that is not yet available. This is similar to Promise, which is available in many other programming languages and libraries. We can create a Deferred instance using the method deferred on IO:

val aDeferredInstance: IO[Deferred[IO, Int]] = IO.deferred[Int]

At the point of creation, the value will be empty within a Deferred instance. The method get on the Deferred instance will block the Cats Effect fiber (semantically) until the value becomes available within the Deferred instance:

def deferredOperation(deferred: Deferred[IO, Int]) = {
  for {
    _ <- IO.println("deferred instance manipulation")
    _ = println("Waiting for the value to be available")
    _ <- deferred.get // blocks the thread until value becomes available
    _ = println("deferred instance is complete")
  } yield ()
}

We can then complete the Deferred instance at a later point in time by another fiber using the complete method:

def deferredCompletion(deferred: Deferred[IO, Int]) = {
  IO.sleep(3.second) *> deferred.complete(100)
}

Let’s combine both the parts using a for-comprehension:

val pgm = for {
  inst <- aDeferredInstance
  fib1 <- deferredOperation(inst).start
  fib2 <- deferredCompletion(inst).start
  _ <- fib1.join
  _ <- fib2.join
} yield ()

Now, the last println statement in the deferredOperation() will get executed after three seconds since we invoked the complete method. Once a deferred value is completed, we can not make it empty again. In the same way, we can complete a deferred instance only once.

6. Conclusion

In this article, we looked at the difference between delay, defer, and deferred methods in Cats Effect IO. As always, the code samples used in this tutorial are available over on GitHub.