The Cats Effect library replaces
FuturewithIO[A]— a description of an async computation that you run on demand. Referentially transparent, cancellable, resource-safe.
In this chapter:
- Why
IOoverFuture - Building and running an
IO - Sequencing with for-comprehensions
- Concurrency: fibers,
parMapN,race,parTraverse - Resource safety with
Resource - Cancellation
IOAppfor entry points- A brief mention of ZIO
Future is eager: the moment you write Future { work() }, work has started. This breaks referential transparency:
val f1 = Future { println("running"); 42 }
val f2 = Future { println("running"); 42 }
// "running" prints twice — they're different futures
val f3 = { println("running"); Future.successful(42) }
val combined = (f3, f3).mapN(_ + _)
// "running" prints ONCE — f3 is a valueTwo textually identical expressions don't behave the same. That's a non-trivial cognitive cost.
IO[A] is lazy: it's a description of work. Equality of descriptions is straightforward. Building an IO doesn't run anything; you must call an explicit "run" at the edge of your program.
import cats.effect.IO
val io: IO[Int] = IO {
println("running")
42
}
// nothing prints — io is just a value
io.unsafeRunSync() // NOW it runs
io.unsafeRunSync() // runs againThat referential transparency makes refactoring safe and reasoning local — you can replace any IO value with another value of the same description without changing meaning.
Setup:
// build.sbt
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.4"import cats.effect.IO
val pure: IO[Int] = IO.pure(42) // already-known value, no work
val delayed: IO[Int] = IO.delay { 1 + 1 } // suspended sync work
val sleeping: IO[Unit] = IO.sleep(1.second) // sleep without blocking a thread
val async: IO[String] = IO.async_[String] { cb =>
// callback-based bridge: call cb(Right(...)) or cb(Left(...))
}Run at the edge:
io.unsafeRunSync() // blocks until done, returns the result
io.unsafeToFuture() // returns a Scala Future
io.unsafeRunAsync(callback)The unsafe* prefix is intentional — these break referential transparency and should appear only at your application's entry point.
IO is a Monad. For-comprehensions work as you'd expect:
val program: IO[Unit] = for {
_ <- IO.println("hello")
n <- IO(42)
_ <- IO.println(s"got $n")
_ <- IO.println("bye")
} yield ()
program.unsafeRunSync()
// hello
// got 42
// byeEach step runs in sequence. flatMap and map work; the for-comp desugars normally.
You can compose IOs into bigger programs from small ones:
def fetch(url: String): IO[String] = ???
def parse(s: String): IO[Json] = ???
def save(j: Json): IO[Unit] = ???
val workflow: IO[Unit] = for {
body <- fetch("/api/data")
json <- parse(body)
_ <- save(json)
} yield ()A fiber is a lightweight thread — Cats Effect runs millions on a small JVM thread pool. Forking is cheap.
import cats.implicits._
val fa: IO[Int] = IO { Thread.sleep(100); 1 }
val fb: IO[Int] = IO { Thread.sleep(100); 2 }
val fc: IO[Int] = IO { Thread.sleep(100); 3 }
val parallel: IO[Int] = (fa, fb, fc).parMapN(_ + _ + _)
// total time ≈ 100ms, not 300msCompared to (fa, fb, fc).mapN(_ + _ + _) which would be sequential, parMapN runs them on separate fibers.
val first: IO[Either[Int, String]] =
IO.race(IO.sleep(1.second).as(42), IO.sleep(2.seconds).as("late"))Either[A, B] — Left if the first IO won, Right if the second. The losing IO is automatically cancelled, freeing resources. (Compare: Future.firstCompletedOf keeps losers running.)
val urls = List("/a", "/b", "/c")
def fetch(url: String): IO[String] = ???
val responses: IO[List[String]] = urls.parTraverse(fetch)Or with bounded parallelism:
urls.parTraverseN(8)(fetch) // at most 8 in flightThis is one of the most useful patterns in Cats Effect.
If you need fine control:
val fork: IO[Unit] = for {
fiber <- IO.println("background...").start // spawn a fiber
_ <- IO.println("foreground")
_ <- fiber.join // wait for it
// or fiber.cancel
} yield ()For most code, parMapN / parTraverse are enough. Drop to start only when you need to fire-and-forget or store fiber refs.
Resource[F, A] represents an acquire/release pair. Use it for anything you'd traditionally try { ... } finally { close() }:
import cats.effect.Resource
def fileResource(path: String): Resource[IO, java.io.BufferedReader] =
Resource.make(
IO(new java.io.BufferedReader(new java.io.FileReader(path)))
)(reader => IO(reader.close()))
val readFirstLine: IO[String] =
fileResource("/etc/hosts").use { reader =>
IO(reader.readLine())
}
// the file is automatically closed afterward, even on errorResources compose:
def db: Resource[IO, Connection] = ???
def kafka: Resource[IO, Producer] = ???
val both: Resource[IO, (Connection, Producer)] =
for {
c <- db
p <- kafka
} yield (c, p)
both.use { case (conn, prod) =>
// both are open; both close when use exits
???
}This is the correct way to manage thread pools, DB connections, HTTP clients, file handles. It's leak-proof.
Unlike Future, an IO can be cancelled. Built-in operations like IO.sleep are interruptible. Long-running computations should periodically check for cancellation:
val long: IO[Unit] = IO.tailRecM(0) { n =>
if (n >= 1_000_000) IO.pure(Right(()))
else IO.cede *> IO.pure(Left(n + 1)) // IO.cede is a cancellation point
}
val raced = IO.race(long, IO.sleep(100.millis))IO.cede yields the fiber, giving the runtime a chance to deliver cancellation. Most libraries do this transparently.
Cancellation is cooperative — the running fiber must check. But all of Cats Effect's built-in async operations check, so for normal code you get cancellation for free.
The clean way to launch an IO program:
import cats.effect.{IO, IOApp, ExitCode}
object Main extends IOApp.Simple {
val run: IO[Unit] =
for {
_ <- IO.println("hello, IO world")
_ <- IO.sleep(1.second)
_ <- IO.println("bye")
} yield ()
}Or the version that takes args and returns an ExitCode:
object Main extends IOApp {
def run(args: List[String]): IO[ExitCode] =
if (args.isEmpty) IO.println("missing arg").as(ExitCode.Error)
else IO.println(s"hi ${args.head}").as(ExitCode.Success)
}IOApp handles graceful shutdown, fiber cleanup, and signal handling for you.
ZIO is the other major effect system in Scala. Same goals as Cats Effect; different API and ergonomics.
The headline difference: ZIO's effect type is ZIO[R, E, A] — three parameters:
R— the requirements (dependencies / environment).E— the typed error.A— the result.
This typed environment is ZIO's signature feature; Cats Effect models the same thing differently (typically with Reader or Kleisli patterns).
If you start a new project, pick one based on the team's preference and stick with it. Mixing both is a recipe for fragmentation. Both are excellent.
- Why
IOis referentially transparent andFutureisn't — and why that matters. - How to build and run
IOs, including theunsafe*boundary. - For-comprehensions over
IOfor sequential composition. parMapN,race,parTraversefor parallelism with automatic cancellation.Resourcefor leak-proof acquire/release.- Cooperative cancellation via
IO.cedeand built-in async ops. IOAppas the standard entry point.- That ZIO exists as an alternative.
The next chapter (Chapter 20 — Actors and Streams) is a brief tour of the actor model and Akka Streams.
← Previous: Chapter 18 — Futures | Back to README | Next: Chapter 20 — Actors and Streams →