I've written articles on this blog about using Spock, Scotty, and Twain, in that order. That order mostly represents my timeline of moving from one web library to the next.

Recently I've been using Twain for all of my newer web apps instead of Scotty and previously Spock, and I wanted to share why.

What they have in common

The three web libraries mentioned above have a lot in common, the most obvious among them is the layer on which they are built - wai. WAI, or Web Application Interface, can be considered an abstraction between web applications and web servers, or a specification for web applications if you will.

A WAI Application, which is defined as:

type Application =
   Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived

encapsulates the essense of what a web application is - it takes a request description, formulates a response (which might require us to do IO, like hitting a database), and calls a function that can send that response back to the user. That way we can built our web applications without handling low-level details like sockets and connections - we leave that to web servers, such as warp, and instead only deal with requests and responses.

Spock, Scotty, and Twain, are abstractions over WAI - they make it easier for us to handle requests and emit responses in compositional ways, using sometimes similar and sometimes different techniques.

Examples

Here's an example of the same simple web app written in each library:

hello-http.cabal
cabal-version: 3.0
name: hello-http
version: 0.1.0.0

executable hello-spock
    main-is: hello-spock.hs
    build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, Spock ^>= 0.14.0.0, warp ^>= 3.3.28
    hs-source-dirs: .
    default-language: Haskell2010
    ghc-options: -O -Wall -threaded -with-rtsopts=-N

executable hello-scotty
    main-is: hello-scotty.hs
    build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, scotty ^>= 0.12.1, warp ^>= 3.3.28
    hs-source-dirs: .
    default-language: Haskell2010
    ghc-options: -O -Wall -threaded -with-rtsopts=-N

executable hello-twain
    main-is: hello-twain.hs
    build-depends: base ^>=4.18.0.0, text ^>= 2.0.2, aeson ^>= 2.1.2.1, twain ^>= 2.1.2.0, warp ^>= 3.3.28
    hs-source-dirs: .
    default-language: Haskell2010
    ghc-options: -O -Wall -threaded -with-rtsopts=-N
cabal.project
packages: hello-http.cabal

source-repository-package
  type: git
  location: https://github.com/agrafix/Spock.git
  tag: 40d028bfea0e94ca7096c719cd024ca47a46e559
  subdir: Spock-core
hello-spock.hs
{-# LANGUAGE OverloadedStrings #-}
{-# language ScopedTypeVariables #-}

import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text (Text)
import qualified Web.Spock as Spock
import qualified Web.Spock.Config as Spock

main :: IO ()
main = do
  config <- mkConfig
  Spock.runSpock 3000 $
    Spock.spock config routes

mkConfig :: IO (Spock.SpockCfg () () ())
mkConfig = Spock.defaultSpockCfg () Spock.PCNoDatabase ()

routes :: Spock.SpockM () () () ()
routes = do
  Spock.get "/" $
    Spock.text "hi"

  Spock.get ("/id/" Spock.<//> Spock.var) $ \(id' :: Int) -> do
    (name :: Text) <- Spock.param' "name"
    Spock.setHeader "x-powered-by" "benchmark"
    Spock.text (fromString (show id') <> " " <> name)

  Spock.post "/json" $ do
    (value :: Value) <- Spock.jsonBody'
    Spock.json value
hello-scotty.hs
{-# LANGUAGE OverloadedStrings #-}
{-# language ScopedTypeVariables #-}

import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text.Lazy (Text)
import qualified Web.Scotty as Scotty

main :: IO ()
main =
  Scotty.Scotty 3000 routes

routes :: Scotty.ScottyM ()
routes = do
  Scotty.get "/" $
    Scotty.text "hi"

  Scotty.get "/id/:id" $ do
    (id' :: Int) <- Scotty.param "id"
    (name :: Text) <- Scotty.param "name"
    Scotty.setHeader "x-powered-by" "benchmark"
    Scotty.text (fromString (show id') <> " " <> name)

  Scotty.post "/json" $ do
    (value :: Value) <- Scotty.jsonData
    Scotty.json value
hello-twain.hs
{-# language OverloadedStrings #-}
{-# language ScopedTypeVariables #-}

module Main where

import Data.Aeson (Value)
import Data.String (fromString)
import Data.Text (Text)
import qualified Web.Twain as Twain
import Network.Wai.Handler.Warp (run, Port)

main :: IO ()
main = runServer 3000

runServer :: Port -> IO ()
runServer port = do
  putStrLn $ unwords
    [ "Running twain app at"
    , "http://localhost:" <> show port
    , "(ctrl-c to quit)"
    ]
  run port mkApp

mkApp :: Twain.Application
mkApp =
  foldr ($)
    (Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
    routes

routes :: [Twain.Middleware]
routes =
  [ Twain.get "/" $
    Twain.send $ Twain.text "hi"

  , Twain.get "/id/:id" $ do
    (id' :: Int) <- Twain.param "id"
    (name :: Text) <- Twain.queryParam "name"
    Twain.send
      $ Twain.withHeader ("x-powered-by", "benchmark")
      $ Twain.text
      $ fromString (show id') <> " " <> name

  , Twain.post "/json" $ do
    (value :: Value) <- Twain.fromBody
    Twain.send
      $ Twain.json
      $ value
  ]

Deeper dive

Let's look at the three libraries in more detail. In particularily, we will look at three important operations:

  • Routing - decide which request is handled using which handler
  • Handling requests - such as getting request information to be used to compose a response
  • Composing and sending a response

Spock

Routing

Let's look at this routes code from our example.

routes :: Spock.SpockM () () () ()
routes = do
  Spock.get "/" $
    Spock.text "hi"

  Spock.get ("/id/" Spock.<//> Spock.var) $ \(id' :: Int) -> do
    (name :: Text) <- Spock.param' "name"
  ...

As we can see, Spock uses a monadic interface to compose routes. Each route is built using an http verb that takes a Path and an Action to run if the route matches. If it does not, the next route is tried.

The paramater we defined in the /id/ route is passes as an argument to the Action.

Note that we've never used a value returned from each route in a following route, so maybe the power of monads is unnecessary for composing routes?

The value level ergonomics looks pretty good to me, let's look at some of the types!

type SpockM conn sess st = SpockCtxM () conn sess st
type SpockCtxM ctx conn sess st = SpockCtxT ctx (WebStateM conn sess st)
get :: HasRep xs => RouteSpec xs ps ctx conn sess st
type RouteSpec xs ps ctx conn sess st = Path xs ps -> HVectElim xs (SpockActionCtx ctx conn sess st ()) -> SpockCtxM ctx conn sess st ()
data Path (as :: [Type]) (pathState :: PathState)
var :: (Typeable a, FromHttpApiData a) => Path '[a] 'Open
(<//>) :: forall (as :: [Type]) (bs :: [Type]) (ps :: PathState). Path as 'Open -> Path bs ps -> Path (Append as bs) ps

Spock uses type-level vectors to represent a route, and the types are quite verbose. I'm not going to explain what's going on here, but if you squint real hard it kind of makes sense. It's pretty impressive that although the types are quite complex, that doesn't really get in the way of value level programming.

Handling requests

Once we've matched a request with a handler (SpockAction), we start handling the request. Often this will include fetching more information about the request, such as the body, headers, cookies, files, and so on. While in the Action context, we can fetch the entire wai Request using request :: MonadIO m => ActionCtxT ctx m Request, or with other dedicated functions.

We can also perform IO actions, like hitting a database, inside an Action.

This part is quite similar between the three libraries.

Composing a response

Composing a response is quite a stateful process in Spock. Instead of building a response object, we use mutating functions inside the context of an Action to set headers and cookies, and when we want to send the response we call a function such as text :: MonadIO m => Text -> ActionCtxT ctx m a.

This approach kind of flips the continuation-passing style of WAI - we no longer see the function Response -> IO ResponseReceived in

type Application =
  Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived

as it is being taken care of by the framework.

Next, let's look at Scotty's way of doing things.

Scotty

Routing

routes :: Scotty.ScottyM ()
routes = do
  Scotty.get "/" $
    Scotty.text "hi"

  Scotty.get "/id/:id" $ do
    (id' :: Int) <- Scotty.param "id"
  ...

The value level is quite similar to Spock. We also use a monadic interface to compose routes, and use http verbs which take a route pattern and an Action to build a route.

Note than the parameter in the /id/ route is fetched inside the Action and is not passed as a function argument like in Spock.

Fair enough, let's look at the types!

type ScottyM = ScottyT Text IO
newtype ScottyT e m a = ScottyT { runS :: State (ScottyState e m) a }
get :: RoutePattern -> ActionM () -> ScottyM ()
data RoutePattern

(RoutPattern is an opaque data type which can be instantiated with the these functions)

Handling requests

Similarily to Spock, we also have an Action with monadic interface that can run IO action. Getting more information about the request while in the Action context can be done with using request :: ActionM Request, or with other dedicated functions.

The one important difference is that while path parameters are passed as function arguments in Spock, they need to be retrieved with param :: Parsable a => Text -> ActionM a in Scotty. And in both cases we use typeclasses and sometimes type annotations to declare what kind of data are we looking for and how to parse it.

Composing a response

With Scotty we still need to mutate the context to form a response, and we use the same pattern to send response.

Versus Spock

All in all, Scotty is somewhat similar but also a lot simpler than Spock. Spock tries to do more work and provide more features than Scotty, like supports sessions, database pooling and csrf protection as part of Spock, which is probably why it includes some of this extra complexity.

Next, we'll look at something a bit different with Twain.

Twain

mkApp :: Twain.Application
mkApp =
  foldr ($)
    (Twain.notFound $ Twain.send $ Twain.text "Error: not found.")
    routes

routes :: [Twain.Middleware]
routes =
  [ Twain.get "/" $
    Twain.send $ Twain.text "hi"

  , Twain.get "/id/:id" $ do
    (id' :: Int) <- Twain.param "id"
  ...
  ]

Instead of using a monadic interface like Spock and Scotty, Twain uses function composition. Similar to the two previous libraries, each http verb takes a path pattern and a responder, and builds a route. We then compose the routes together by folding over the list and include the final route, the notFound response, as the base case where all unmatched request go.

The parameter in the /id/ route is fetched inside the Responder like in Scotty, and is not passes as a function argument like in Spock.

It's types time!

type Middleware = Application -> Application

Middleware is a wai type that lets us modify an existing wai application.

get :: PathPattern -> ResponderM a -> Middleware
data PathPattern = MatchPath (Request -> Maybe [Param])

Handling requests

Similarily to Spock and Scotty, Twain also provides functions to get request information (or the whole request) inside a Responder context. And like Scotty it doesn't take path parameters as arguments to responders and instead uses a param function.

Composing a response

Unlike Spock and Scotty, in Twain we build a wai Response object with functions like text :: Text -> Response, and transform it with functions like status :: Status -> Response -> Response and withHeader :: Header -> Response -> Response.

Once we've built the response of our dreams, we can send it back to the client using the send :: Response -> ResponderM a function. More on responses in this section.

Versus Spock and Scotty

Personally, I think Twain takes a nicer approach than Spock and Scotty:

  1. For routing - it required the least amount of concepts (no monadic interface, monad transformers, or type-level vectors), and I've found the types to be nicer than Spock or Scotty.
  2. For composing responses - I prefer to build a response object explicitly and then send it rather than using mutable state.

We've covered the interface section. But there are other parameters by which we evaluate libraries. Let's have a look at a few.

Other stuff

Popularity

Popularity matters.

If I were to rate the popularity of these libraries it would probably be Scotty, then Spock, then in the far far far back, Twain. I think almost no one uses Twain and that's a shame, and why I'm writing this blog post!

This means that you'll find less examples, tutorials, or help if you try to write a web application using Twain than if you were to write one using Scotty or Spock.

However, I did write a Twain tutorial (Building a bulletin board using Twain and friends) and have made a few simple web apps (such as jot) that can be used as examples.

Maintainance

Are these libraries well maintained? Could you maintain them yourself if you needed to?

Unfortunately I don't think any of these libraries are being actively developed. While for Twain there isn't really a request for new features or bugs to solve, both Scotty and Spock have quite a list of older issues, some which stem from fairly old design decisions which are not so easy to change.

In the Haskell world it is sometimes important to be able to maintain the libraries you use yourself. I think it is easier to do for Twain than Scotty or Spock. Here's why:

Codebase size

First, there's a lot less code to maintain! If I only count source code lines, I get the following results:

| Framework          | LoC  | Visualized              |
| ------------------ | ---- | ----------------------- |
| Spock + Spock-core | 2301 | ####################### |
| Scotty             |  906 | #########               |
| Twain              |  545 | #####                   |

Dependency footprint

Looking through cabal-plan at the dependency footprint of Twain, Scotty, and Spock, it's clear that Twain has fewer dependencies than Scotty, though not by much. Spock however has more dependencies that the other libraries, pulling libraries such as unliftio, crypton, fast-logger and more.

This means less breaking changes and less maintainance work.

Knowledge requirements / Complexity

We've covered this briefly before, but Twain is the simpler library of the three in terms of implementation complexity. It doesn't use monads or exceptions to handle control flow, and does not include type-level programming or complex state handling. It's really just a thin layer on top of wai. Just look at the code!

It is true that Spock provides some things out of the box like session management, hooks, and database pooling, and that can be important for some use cases, but I think for many use cases these are not required, or can be handled more elegantly outside the web framework, and for these cases it is worth looking at simpler libraries.

Performance

I tried to benchmark and compare Spock, Scotty, and Twain against bun-http-framework-benchmark which uses bombardier.

This benchmark, of course, is very simplistic. But I think this benchmark can still give us some indication regarding which library is faster.

You can find reproduction instructions and try them yourself in this repo.

| Library | Get (/)    |  Params, query & header | Post JSON  |
| ------- | ---------- | ----------------------- | ---------- |
| Spock   |  31,321.19 |               25,015.61 |  28,924.38 |
| Scotty  | 269,021.44 |              186,814.18 | 194,448.40 |
| Twain   | 306,501.25 |              230,075.55 | 227,636.17 |

This is a very surprising result! Since the three libraries all use the same low-level library and server implementation under the wood, I expected them to perform somewhat similarily. What gives?

As it turns out there seems to be a memory leak in Spock.

I haven't tried to have a look why Scotty is slower than Twain though. Maybe you'd like to have a look?

Btw, these numbers are from my computer, rest assured that the top performers here still beat the JS libraries.
| Framework              | Get (/)    | Params, query & header | Post JSON |
| ---------------------- | ---------- | ---------------------- | --------- |
| vixeny (bun)           | 122,374.39 |              102,359.3 | 89,576.88 |
| elysia (bun)           | 121,397.09 |              95,221.77 | 92,058.81 |
| bun (bun)              | 121,319.35 |              90,183.81 | 94,314.47 |
| nhttp (bun)            | 113,231.44 |              86,465.28 | 82,933.61 |
| stricjs (bun)          |  93,808.43 |              92,610.55 | 92,361.88 |
| abc (deno)             |  93,515.77 |              92,291.91 | 92,614.05 |
| hyper-express (node)   |  91,727.23 |              92,990.93 | 92,826.39 |
| uws (node)             |  92,418.94 |              92,620.22 | 92,328.87 |
| acorn (deno)           |  92,701.53 |              93,086.80 | 91,561.94 |
| cheetah (deno)         |  92,664.57 |              92,102.08 | 92,392.08 |
| hono (bun)             | 112,057.33 |              81,816.17 | 83,005.40 |
| fast (deno)            |  92,933.12 |              91,761.82 | 91,642.67 |
| oak (deno)             |  91,960.36 |              91,050.27 | 91,080.37 |
| bun-web-standard (bun) | 107,298.37 |              79,059.06 | 87,252.11 |
| adonis (node)          |  91,624.29 |              90,389.19 | 90,897.86 |
| bun-bakery (bun)       | 103,169.90 |              76,660.08 | 72,180.55 |
| hyperbun (bun)         |  93,970.69 |              74,578.36 | 60,052.84 |
| baojs (bun)            |  80,123.36 |              71,482.00 | 70,757.79 |
| nbit (bun)             |  78,780.23 |              68,524.39 | 65,443.93 |
| fastify (node)         |  47,631.96 |              42,963.56 | 30,199.74 |
| h3 (node)              |  46,715.71 |              36,573.97 | 34,191.50 |
| koa (bun)              |  39,475.29 |              35,897.86 | 18,362.88 |
| koa (node)             |  27,051.64 |              23,878.42 | 16,617.84 |
| express (bun)          |  18,977.15 |              17,122.74 | 10,816.04 |
| hapi (node)            |  23,912.21 |               8,433.24 | 13,479.71 |
| express (node)         |   9,231.05 |               8,755.90 |  7,020.60 |
| nest (node)            |   8,615.43 |               7,899.08 |  6,459.58 |

Bugs

I've run into bugs and issues with Scotty and Spock that I was unsure how to solve, and haven't run into similar issues with Twain. But I've worked on more complex applications with Scotty and Spock than with Twain. I'm still confident that even if such issues will arise, I'll be able to fix them myself if needed.

But what about...?

You might ask "what about snap, happstack, IHP, servant, yesod, okapi, ...?", and the truth is that while I have tried some or even most of these libraries, For my usage, I don't need the additional value that they seem to provide, and I don't want to pay for the complexity they bring. But ymmv.

Conclusion

Twain is a tiny, reasonably fast, and easy to use web library. I feel it found the sweet spot between complexity and usefulness for a micro-framework, which is why I currently prefer it over other Haskell web frameworks.