Scala gives you several ways to describe a program that talks to databases, calls HTTP services and runs things concurrently. The standard library offers Future. The Typelevel ecosystem offers Cats Effect and its IO type. The ZIO project offers ZIO, with typed errors and a built-in dependency model. Newer projects such as Ox and Kyo take different routes again. The choice quietly shapes how a team handles errors, wires dependencies and shuts down cleanly.

This article compares them on the semantics you will actually live with: when code runs, what an error is, how dependencies arrive, how resources are released, what cancellation means, and how each fits the libraries you need. Runtime internals such as work-stealing schedulers are covered in the dedicated pages on the Cats Effect runtime and the ZIO runtime; here the focus is choosing and using one well.

Advertisement

First principles: a description versus a running computation

An effect is anything a function does besides returning a value: reading a socket, writing a log, sleeping, failing. An effect system represents those actions as ordinary values that describe what should happen, and runs them only when you hand the description to a runtime at the edge of the program. That one choice, describing now and running later, is what makes the rest possible.

Future does the opposite. Constructing a Future starts it on an ExecutionContext immediately, and its result is memoised. That sounds harmless until you try to reuse or retry one. Consider a helper that retries a failing call three times by calling recoverWith on the same Future value: the retry just re-reads the memoised failure, and the network call happens once. With a lazy IO or ZIO value, the same helper genuinely reruns the call each time, because the value is a recipe, not a receipt.

Laziness also makes refactoring safe, since naming an effect never triggers it. And because the runtime owns execution, it can offer things a Future cannot: cancellation, timeouts that actually stop work, guaranteed finalisers, and fibers that cost a few hundred bytes rather than a thread.

One program, three ways to run it: eager Future versus lazy effect valuesscala.concurrent.Futureeager, memoised, no cancelCats Effect IO / F[_]lazy, Throwable errors, cancelZIO[R, E, A]lazy, typed E, environment RExecutionContextyou pick the poolIORuntimecompute + blocking poolsZIO Runtimeexecutor + blocking executorruns at creationunsafeRun at the edgeunsafe.run at the edgeShared by CE and ZIOfibers, structured cancellation, Ref, Deferred or Promise, Queue, Semaphore, safe resourcesWhere they differerror channel, dependency passing, resource scope, stdlib breadth, ecosystemDirect style on virtual threads (Ox) and algebraic effects (Kyo)different trade-off: plain return types or effect rows, newer ecosystemsThe question is never which is fastest. It is which failure, dependency and resource model your team can reason about.
Future runs when it is created. Cats Effect and ZIO build values that run only when the program's edge hands them to a runtime, which is what makes cancellation, retries and safe resources possible.

The contenders at a glance

Cats Effect and ZIO share the core machinery: fibers on a small pool, structured cancellation and concurrency primitives such as Ref, queues and semaphores. They differ in signatures and in how much is built in. The table summarises the axes this article walks through.

ConcernFutureCats EffectZIO
Evaluationeager, runs on construction, result memoisedlazy description, rerunnablelazy description, rerunnable
Error typeThrowable onlyThrowable (MonadError), plus a Canceled outcometyped E for failures, Throwable defects, interruption, all in Cause
Dependenciesconstructor argumentsconstructor arguments, often tagless final F[_]environment R assembled with ZLayer
Resourcestry/finally by handResource[F, A]Scope with acquireRelease
Cancellationnonecancel on a fiber; uncancelable with pollinterrupt on a fiber; uninterruptibleMask with restore
Child fibersno notionstart is unsupervised; background and Supervisor are scopedfork is supervised by the parent; forkDaemon is global
Retries and scheduleshand-writtencats-retry library or a small helperbuilt-in Schedule
Main ecosystemAkka/Pekko, PlayTypelevel: fs2, http4s, doobie, circezio-http, zio-streams, zio-json, zio-kafka
Advertisement

Error model: one Throwable channel versus typed failures

Cats Effect's IO fails with a Throwable. Errors raised with IO.raiseError and exceptions thrown inside IO.delay land in the same channel, and a fiber finishes with one of three outcomes: Succeeded, Errored or Canceled. To express domain errors precisely you either define exception subclasses, or return Either[DomainError, A] inside IO, often with the EitherT transformer.

ZIO puts the error type in the signature. ZIO[R, E, A] can fail only with an E, so ZIO[Any, Nothing, A] is a compile-time promise that no expected failure can happen. Unexpected throwables become defects, recorded separately from failures, and the full story of a failed fiber lives in a Cause that can combine failures, defects and interruption, including those of parallel children.

// Cats Effect: domain errors as exceptions in the single Throwable channel
final case class NotFound(id: Long) extends RuntimeException(s"user $id not found")

def findUser(id: Long): IO[User] =
  repo.find(id).flatMap {
    case Some(u) => IO.pure(u)
    case None    => IO.raiseError(NotFound(id))
  }

// ZIO: the failure type is part of the signature
sealed trait UserError
final case class Missing(id: Long)        extends UserError
final case class DbDown(cause: Throwable) extends UserError

def findUserZ(id: Long): ZIO[UserRepo, UserError, User] =
  ZIO.serviceWithZIO[UserRepo](_.find(id))   // IO[DbDown, Option[User]]
    .someOrFail(Missing(id))

The trade-off is real in both directions. Typed errors make exhaustive handling checkable and keep the happy path clean, but they add a type parameter to every signature. A single Throwable channel is simpler to compose and matches the JVM, but the compiler cannot tell you which failures a function can produce.

Dependencies: parameters and tagless final versus ZLayer

In the Typelevel style, dependencies are constructor or function parameters. Services are often written against an abstract effect, as in UserRepo[F[_]] with a Sync or Async constraint, which is the tagless final pattern. Wiring happens in one place, usually inside a Resource that builds the connection pool, the HTTP client and the services in order.

ZIO puts required services in the R type parameter and builds them with ZLayer. A layer describes how to construct a service from other services, possibly with acquisition and release. The compiler checks that every requirement is satisfied, and ZLayer.make assembles the graph and reports missing or duplicated layers at compile time. The details are in ZIO layers.

// ZIO: declare the dependency graph once, let the compiler check it
final case class UserRepoLive(ds: DataSource) extends UserRepo:
  def find(id: Long): IO[DbDown, Option[User]] = ???

object UserRepoLive:
  val layer: ZLayer[DataSource, Nothing, UserRepo] =
    ZLayer.fromFunction(UserRepoLive(_))

val app: ZIO[UserRepo, UserError, User] = findUserZ(42)

val runnable = app.provide(UserRepoLive.layer, DataSourceLive.layer)

Parameters need no framework but can mean long constructor lists. Layers remove the plumbing and memoise shared services, at the cost of one more concept and long error messages when a graph does not line up.

Resource safety: Resource versus Scope

Both libraries guarantee that a release action runs exactly once, whether the use succeeded, failed or was cancelled. Cats Effect models this as a value, Resource[F, A], which composes with flatMap and releases in reverse order of acquisition. ZIO models it as an effect that requires a Scope: ZIO.acquireRelease registers the finaliser in the current scope, and ZIO.scoped closes the scope when the block finishes.

// Cats Effect
def connection(url: String): Resource[IO, Conn] =
  Resource.make(IO.blocking(Conn.open(url)))(c => IO.blocking(c.close()))

val program: IO[Unit] =
  connection(url).use(c => IO.blocking(c.execute("select 1")).void)

// ZIO
def connectionZ(url: String): ZIO[Scope, Throwable, Conn] =
  ZIO.acquireRelease(ZIO.attemptBlocking(Conn.open(url)))(c =>
    ZIO.succeedBlocking(c.close()))

val programZ: Task[Unit] =
  ZIO.scoped(connectionZ(url).flatMap(c => ZIO.attemptBlocking(c.execute("select 1")).unit))

Resource is an explicit value you pass around and combine. Scope shows up in the R type until something closes it, which makes it hard to forget, and it lets a layer own a resource for the lifetime of the application.

Cancellation and interruption

Future has no cancellation. A timeout built with Future only stops waiting; the underlying work keeps running and keeps holding its connection. Both effect systems can actually stop a fiber, and both treat this as cooperative: the runtime checks for cancellation between steps, never in the middle of a blocking JVM call.

In Cats Effect you mark regions with IO.uncancelable, which receives a poll function to re-enable cancellation for an inner part, typically the wait in an acquire step. In ZIO the equivalent is ZIO.uninterruptibleMask with a restore function. For blocking calls that can be interrupted by a thread interrupt, Cats Effect offers IO.interruptible and ZIO offers ZIO.attemptBlockingInterrupt; plain blocking calls are not interrupted, so a timeout on them returns control but leaves the thread busy until the call ends.

Child fibers differ in a way that bites during migrations. In ZIO, fork ties the child to its parent: when the parent finishes, the child is interrupted. forkDaemon opts out. In Cats Effect, start creates an unsupervised fiber that outlives its creator unless you join or cancel it; the scoped alternatives are background, which returns a Resource, and Supervisor. Migrations between the two often leak fibers here.

Blocking and thread pools

Both runtimes size a compute pool to the number of CPU cores and expect fibers on it never to block a thread. Blocking work such as JDBC, file I/O or legacy SDK calls must be marked: IO.blocking or ZIO.attemptBlocking shifts it to a separate, growable blocking pool. Forgetting this is the most common production problem: a few blocked compute threads stall every fiber. Cats Effect ships a starvation checker that logs a warning when the compute pool stops responding, which is the first thing to look for in the logs when latency spikes with low CPU. If you are coming from Future, the same discipline applies to execution contexts, as covered in Futures and ExecutionContext.

Worked example: the same call with timeout and retry

A service loads a user from a flaky upstream. Each attempt must finish within 2 seconds; transient failures are retried up to three times with exponential backoff starting at 100 milliseconds; a missing user is not retried. The Cats Effect version uses a small recursive helper; in production many teams use the cats-retry library. ZIO expresses the policy with its built-in Schedule.

// Cats Effect
def retrying[A](io: IO[A], left: Int, delay: FiniteDuration): IO[A] =
  io.handleErrorWith {
    case e: NotFound         => IO.raiseError(e)            // never retry a real answer
    case e if left > 0       => IO.sleep(delay) >> retrying(io, left - 1, delay * 2)
    case e                   => IO.raiseError(e)
  }

val loadCE: IO[User] = retrying(findUser(42).timeout(2.seconds), 3, 100.millis)

// ZIO
val policy = Schedule.exponential(100.millis) && Schedule.recurs(3)

val loadZ: ZIO[UserRepo, UserError, User] =
  findUserZ(42)
    .timeoutFail(DbDown(new TimeoutException("2s")))(2.seconds)
    .retry(policy && Schedule.recurWhile[UserError] {
      case DbDown(_) => true
      case Missing(_) => false
    })

Notice where each library puts the knowledge. In Cats Effect the retry decision pattern-matches on exception classes, and nothing stops a caller from forgetting that NotFound exists. In ZIO the error type lists both cases, so the compiler warns if the retry predicate misses one. Both timeouts cancel the in-flight attempt, which Future cannot do.

Ecosystem and interop

Library choice often decides the effect system. Cats Effect sits under fs2 for streaming, http4s for HTTP, doobie and skunk for databases, and many other Typelevel projects, all written against the Cats Effect typeclasses so they run with any compatible effect. ZIO has its own stack: zio-http, zio-streams, zio-json, zio-kafka and ZIO Quill integration. Tapir and sttp support both.

The zio-interop-cats module provides Cats Effect typeclass instances for ZIO, so http4s or doobie can run inside a ZIO application. It works, but errors must be Throwable at the boundary. Treat interop as a bridge for a few libraries, not a license to mix the two freely across a codebase.

The direct-style newcomers: Ox and Kyo

Two newer projects question the monadic style itself. Ox, from SoftwareMill, is a direct-style library for JVM 21 and later: code returns plain values, blocking is cheap because it runs on virtual threads, and concurrency is structured through supervised scopes that guarantee forked work finishes or is cancelled before the scope exits. It trades rerunnable effect values for code and stack traces that look like ordinary Scala.

Kyo takes the opposite direction and generalises effects further, tracking the set of pending effects a computation needs in its type and letting handlers discharge them one by one. Neither project yet has the library breadth of Cats Effect or ZIO, so check that the drivers and clients you need exist before committing.

Failure modes seen in real migrations

  • Blocking on the compute pool. JDBC or SDK calls wrapped in IO.delay or ZIO.attempt instead of the blocking variants. Symptom: latency climbs while CPU stays low.
  • Running effects in the middle. Calling unsafeRunSync or Unsafe.unsafe inside library code to escape the effect type. It breaks cancellation and can deadlock the pool; run only at the program edge, or use Dispatcher for callback APIs.
  • Leaking fibers. Cats Effect start without join or cancel, or ZIO forkDaemon used as a habit. Prefer background, Supervisor or supervised fork.
  • Future interop without laziness. IO.fromFuture(IO.pure(f)) starts nothing lazily, because f is already running. Use IO.fromFuture(IO(makeFuture())) so every run creates a new Future.

How to choose

Choose Cats Effect when you want the Typelevel libraries, prefer explicit parameters and typeclass-polymorphic code, and value a small, law-checked core. Choose ZIO when typed errors and compiler-checked dependency graphs are worth an extra type parameter to your team, and when a large batteries-included standard library, including STM and schedules, reduces what you must assemble. Keep Future only where a framework dictates it. Whatever you pick, pick one per service: the cost of an effect system is paid in conventions, and two sets of conventions cost twice.

What to do next

  1. List the libraries your service needs, for HTTP, database, Kafka and cloud SDKs, and check which effect system each supports natively.
  2. Write the same small endpoint, with a timeout, retry and resource, in Cats Effect and in ZIO, and have the team review both.
  3. Decide the error convention, either exceptions in the Throwable channel or typed errors, and write it down.
  4. Mark every blocking call with the blocking variant and turn on logging for the starvation checker or equivalent runtime metrics.
  5. Ban unsafe run calls outside the main entry point with a linter rule or code review checklist.
  6. Review every fiber fork for supervision: who joins, cancels or outlives it.
  7. If you must mix ecosystems, confine interop modules to one adapter package.
Key takeaway: Future is eager and cannot cancel; Cats Effect and ZIO describe effects lazily and run them on fiber runtimes with safe resources and real cancellation. Cats Effect keeps a single Throwable error channel, explicit parameters and a law-checked core under the Typelevel ecosystem. ZIO adds typed errors, a compiler-checked environment and a broad standard library. Choose by the libraries you need and the error and dependency model your team will maintain, then enforce one set of conventions.