Scala is a rich and expressive language, and one of its strengths is the flexibility it offers in how you write your code. Whether you lean towards functional purity, pragmatic scripting, or modern direct-style syntax, there’s likely a Scala flavor that suits your workflow.

In this post, I’ll walk through a simple script I use to help publish my “news of the month” blog series. What starts as a basic task — reading a list of URLs and printing each page’s title — becomes an opportunity to explore different Scala styles, from idiomatic scripting and parallelism with Future, to effect systems like cats-effect, ZIO, and even the direct-style Ox library.

The goal? To showcase how Scala’s ecosystem lets you solve the same problem in multiple elegant ways.

Context

Each month, I publish a “news of the month” post, a curated list of interesting articles. To do so, throughout the month, I collect interesting URLs in a text file. In order to automate a bit the creation of the markdown for the post, I wrote a script that reads this file, fetches the title for each URL, and prints it to the standard output.

Requirements:

  • Do not crash in case of errors (e.g. invalid URLs).
  • Do not leave the file open in case of errors. In this simple case, it is more for educational purposes (Source.fromFile creates an open file handle. If we don’t call explicitly .close(), it causes a file descriptor leak. The OS has a limit on the number of concurrently open files, you can check it with ulimit -n (default is often 256 on macOS).

Getting Started with scala-cli

A simple yet powerful tool has emerged: scala-cli that allows to compile and run scala scripts with an automatic setup (no build files). The dependencies are declared using a directive in the script file itself. So, it’s incredibly easy to experiment with different libraries.

I personally installed it with sdkman sdk install scala-cli, and I update it with sdk upgrade scala-cli

Here is how to setup IntelliJ. When a new script is added, a pictogram suggests syncing BSP changes (otherwise, the script won’t be recognized and you won’t have completions).

Style 1: Idiomatic scala

scala-cli supports scripts that end with .sc.

//> using dep org.jsoup:jsoup:1.20.1

import org.jsoup.Jsoup
import org.jsoup.nodes.Document

import scala.io.Source
import scala.util._

Using(
  Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source => source.getLines.toList.foreach(fetchTitle) }

def fetchTitle(url: String): Unit = {
  Try {
    val document = Jsoup.connect(url).get()
    val title = document.title()
    s"[$title]($url)"
  } match {
    case Success(result) => println(result)
    case Failure(e)      => println(s"ERROR: $url ($e)")
  }
}

Since Scala 2.13, we can use the Using class, which provides support for automatic resource management in Scala.

It’s very lightweight (around 20 lines of code) to write this kind of tasks in Scala (I personnaly prefer it over writing a bash script)

scala-cli run fetch-titles-idiomatic.sc

Style 2: Parallelism with Future

It is the standard library concurrency.

//> using dep org.jsoup:jsoup:1.20.1

import org.jsoup.Jsoup
import org.jsoup.nodes.Document

import scala.io.Source
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*
import scala.util.{Failure, Success, Try, Using}

Using(
  Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source =>
  val futures = source.getLines.map(url => fetchTitle(url))
  Await.result(Future.sequence(futures), 5.minutes)
}

def fetchTitle(url: String): Future[Unit] = {
  Future {
    Try {
      val document = Jsoup.connect(url).get()
      val title = document.title()
      s"[$title]($url)"
    } match {
      case Success(result) => println(result)
      case Failure(e)      => println(s"ERROR: $url ($e)")
    }
  }
}

The method fetchTitle now returns a Future.

scala-cli run fetch-titles-future.sc

Comparison of execution time (measured with time)

  user system cpu total
Style 1 3.64s 0.38s 38% 10.78
Style 2 4.88s 0.58s 73% 7.44

As expected, the wall-clock time is lower with Style 2.

Style 3: Effect library - cats effect

For cats-effect, a scala file must be used (see cats-effect - faq#scala-cli)

//> using dep org.typelevel::cats-effect:3.6.1
//> using dep org.jsoup:jsoup:1.20.1

import cats.effect.{IO, IOApp, Resource}
import cats.syntax.all.*
import org.jsoup.Jsoup

import scala.io.Source
import scala.language.postfixOps

// https://typelevel.org/cats-effect/docs/faq#scala-cli
object Main extends IOApp.Simple {

  def fetchTitle(url: String): IO[Unit] = IO
    .blocking {
      val document = Jsoup.connect(url).get()
      val title = document.title()
      println(s"[$title]($url)")
    }
    .handleErrorWith(e => IO.println(s"ERROR: $url (${e.getMessage})"))

  def run: IO[Unit] = {
    Resource
      .fromAutoCloseable(
        IO.blocking(
          Source
            .fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
            .getLines()
            .toList
        )
      )
      .use(lines => lines.parTraverse(fetchTitle))
  }
}

The .get() method performs a synchronous HTTP request. This blocks the current thread until the request completes or fails. So, this call should be wrapped in a blocking-safe context : IO.blocking(…). This ensures the blocking operation is run on a dedicated thread pool meant for blocking operations, avoiding starvation of compute threads.

Using is replaced by Resource.fromAutoCloseable

The method fetchTitle now returns an IO.

We no longer need to use Try, errors are captured on the error channel of IO.

Note : As opposed to previous implementation, the file is read fully into memory.

scala-cli run fetch-titles-cats-effect.scala

Style 4: Effect library - ZIO

//> using dep "dev.zio::zio:2.1.17"
//> using dep "org.jsoup:jsoup:1.20.1"

import zio._
import org.jsoup.Jsoup
import scala.io.Source

object Main extends ZIOAppDefault {

  def fetchTitle(url: String): Task[Unit] =
    ZIO
      .attemptBlocking {
        val doc = Jsoup.connect(url).get()
        println(s"[${doc.title()}]($url)")
      }
      .catchAll(e => ZIO.succeed(println(s"ERROR: $url - ${e.getMessage}")))

  def run: ZIO[Scope, Throwable, Unit] = {

    for {
      source <- ZIO.fromAutoCloseable(
        ZIO.attemptBlocking(
          Source.fromFile(
            "/Users/yamo/projects/perso/blog/news-raw/2025-04.txt"
          )
        )
      )
      lines <- ZIO.attemptBlocking(source.getLines().toList)
      _ <- ZIO.foreachParDiscard(lines)(fetchTitle)
    } yield ()
  }
}

On this case, ZIO is quite similar to cats-effect. The same concepts exist in both library.

Here is a summary of the syntax differences

cats-effect ZIO
IO.blocking ZIO.attemptBlocking
Resource.fromAutocloseable ZIO.fromAutoCloseable
lines.parTraverse ZIO.foreachParDiscard(lines)

Style 5: Direct-style with Ox and high-level concurrency

In a nutshell, direct-style means you don’t have to deal with nested flatMap/map.

//> using dep com.softwaremill.ox::core:0.5.13
//> using dep org.jsoup:jsoup:1.20.1

import org.jsoup.Jsoup

import scala.io.Source
import scala.util.{Failure, Success, Try, Using}
import ox.*

Using(
  Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source =>
  source.getLines.toList
    .foreachPar(4)(fetchTitle)
}

def fetchTitle(url: String): Unit = {
  Try {
    val document = Jsoup.connect(url).get()
    val title = document.title()
    s"[$title]($url)"
  } match {
    case Success(result) => println(result)
    case Failure(e)      => println(s"ERROR: $url ($e)")
  }
}

The method fetchTitle remains exactly the same as in the first version. The only change is the call to foreachPar.

Ox is based on project Loom, which requires JDK 21. Thankfully, once again, it’s super easy to get up and running.

scala-cli run fetch-titles-cats-effect.sc --jvm temurin:21

Style 6: Direct-style with Ox and structured-concurrency

what-is-structured-concurrency

Structured concurrency is an approach where the lifetime of a thread is determined by the syntactic structure of the code.

//> using dep com.softwaremill.ox::core:0.5.13
//> using dep org.jsoup:jsoup:1.20.1

import org.jsoup.Jsoup

import scala.io.Source
import scala.util.{Failure, Success, Try, Using}
import ox.*

supervised {
  val source = useCloseableInScope(Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt"))
  source.getLines.toList.map(url => fork(fetchTitle(url)).join())
}

def fetchTitle(url: String): Unit = {
  Try {
    val document = Jsoup.connect(url).get()
    val title = document.title()
    s"[$title]($url)"
  } match {
    case Success(result) => println(result)
    case Failure(e)      => println(s"ERROR: $url ($e)")
  }
}

Ox provides methods to manage resources.

scala-cli run fetch-titles-cats-effect.sc --jvm temurin:21

Other styles

Other alternatives that I have not tested :

  • gears, another direct-style async library for Scala 3.
  • kyo, a toolkit based on algebraic effect.

Conclusion

This tiny script turned out to be a fun playground to explore the diversity of styles and libraries available in modern Scala. Whether you prefer the simplicity of standard futures, the power of type-safe effects with cats-effect or ZIO, or the readability of direct-style with Ox, you can choose the right tool for your needs — all within the same language.

Having options is empowering. It allows you to write code that matches your mindset, project constraints, and performance goals. If you’ve tried a different approach or library for similar scripting tasks, I’d love to hear about it — feel free to reach out or leave a comment!