F# HTML Rendering Benchmarks - Giraffe ViewEngine

Date: 2023-12-11 | create | tech | fsharp | html | giraffe | performance |

Recently I've been experimenting with server-side HTML rendering with F#. In the process I've tried a few different paradigms to try and find one with a good balance between devx and performance.

Both of these approaches have their drawbacks:

  • Scriban templates lose build-time type safety as we cross the boundary between raw string templating and type-safe data population
  • Raw string templates are hard to compose in-line as F# doesn't allow nested verbatim strings

To solve for these, the F# community often reaches for HTML DSLs (domain-specific languages) like Giraffe ViewEngine which allows you to build HTML pages directly in F# through function compositions (read: without raw strings) thus solving for both type-safety and in-line composition concerns. But they're also notorious for being hard to read / maintain due to their abstraction from the underlying representation - HTML.

In this post we're going to explore one of these concerns - whether the Giraffe ViewEngine DSL exhibits performance issues rendering large HTML pages.

Q: How does HTML Rendering with the Giraffe ViewEngine DSL compare with raw string and Scriban templates?

Answer

Giraffe ViewEngine is astonishingly fast, out performing both raw string and Scriban templates in every scenario. For smaller pages (10-100 items) Giraffe ViewEngine renders ~1.5x faster and for larger pages (500+) it renders 2-3x faster.

My hypothesis for Giraffe ViewEngine's standout performance is its use of Fsharp native datastructures like lists to build up markup efficiently and its clever use of StringBuilder pools to convert that structure into a string, minimizing expensive string operations.

Result summaries:

  • 10 items - GiraffeView in 0.017 ms (1.4x faster)
  • 100 - GiraffeView in 0.104 ms (1.6x faster)
  • 200 - GiraffeView in 0.207 ms (1.6x faster)
  • 500 - GiraffeView in 0.503 ms (4.3x faster)
  • 800 - GiraffeView in 1.138 ms (2.8x faster)
  • 1000 - GiraffeView in 1.377 ms (3.2x faster)
  • 2000 - GiraffeView in 2.737 ms (2.7x faster)
  • 5000 - GiraffeView in 7.409 ms (2.8x faster)

Benchmark Setup

Benchmark Workload - Long, shallow pages

This benchmark's workload is to build an HTML table containing n Guids (randomized strings) and render it as an html string. This largely benchmarks a paradigm's ability to render long, shallowly-nested pages.

We are comparing 4 paradigms for HTML building:

  • RawParser - Scriban templates where the template is parsed every time and rendered over the input data
  • CachedParser - Scriban templates where the template has been pre-parsed (and saved) so we only have to render the input data every time
  • RawString - A paradigm where we build up the HTML manually using raw string concatenation
  • Giraffe ViewEngine - Using the DSL to build up a representation in memory then rendering that to an HTML string using Giraffe built-ins

These results were created by utilizing the BenchmarkDotnet library for running / tallying results on a Replit free instance (link available below).

The full benchmark source code (and Replit link) is available in the Benchmark Code section. That said, we will not go into detail explaining how each technology works - for that, look at my other guides:

Detailed Results

In this section I'll be sharing raw benchmark outputs for posterity / later reference.

10 Items - GiraffeView in 0.017 ms (1.4x faster)

  • CachedParser = 0.128 ms
  • RawParser = 0.186 ms
  • RawString = 0.024 ms
  • GiraffeView = 0.017 ms

10 Items Benchmark

100 Items - GiraffeView in 0.104 ms (1.6x faster)

  • CachedParser - 0.476 ms
  • RawParser 0.480 ms
  • RawString - 0.170 ms
  • GiraffeView - 0.104 ms

100 Items Benchmark

200 Items - GiraffeView in 0.207 ms (1.6x faster)

  • CachedParser - 0.926 ms
  • RawParser - 1.003 ms
  • RawString - 0.328 ms
  • GiraffeView - 0.207 ms

200 Items Benchmark

500 Items - GiraffeView in 0.503 ms (4.3x faster)

  • CachedParser - 2.789 ms
  • RawParser - 2.829 ms
  • RawString - 2.186 ms
  • GiraffeView - 0.503 ms

GiraffeView's results were so much faster that I assumed I was building its HTML pages wrong. I debugged and compared the outputs and they seemed to be outputting the same thing as the other paradigms so I think it really is just much faster.

500 Items Benchmark

800 Items - GiraffeView in 1.138 ms (2.8x faster)

  • CachedParser - 7.899 ms
  • RawParser - 8.840 ms
  • RawString 3.150 ms
  • GiraffeView - 1.138 ms

800 Items Benchmark

1000 Items - GiraffeView in 1.377 ms (3.2x faster)

  • RawString - 4.434 ms
  • GiraffeView - 1.377 ms

At this point Scriban templates fail to render due to Reflection limits (see Scriban Benchmarks for details). Previously this is where I stopped benchmarks both because this is an unreasonable amount of elements to render and because it seemed to be reaching the limits of rendering for these strategies. Yet for both RawString and GiraffeView we see both rendering quite quickly so I decided to keep going.

1000 Items Benchmark

2000 Items - GiraffeView in 2.737 ms (2.7x faster)

  • RawString 7.409 ms
  • GiraffeView - 2.737 ms

2000 Items Benchmark

5000 Items - GiraffeView in 7.409 ms (2.8x faster)

  • RawString - 20.597 ms
  • GiraffeView - 7.409 ms

This is where GiraffeView really starts to astonish. At 5000 items in less than 10ms, we're actually seeing render times fast enough that you could reasonably do this in production. I'm not saying you necessarily should but it definitely seems you reasonably could.

5000 Items Benchmark

Benchmark Code

As usual the code is also available as a Replit so you can run / investigate these benchmarks yourself.

For posterity, I'm copy / pasting the code as-is at time of writing below:

open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
open Giraffe.ViewEngine
open Scriban
open System

(*
* ToRun: dotnet run -c Release 
* Results: https://hamy.xyz/labs/2023-12-html-rendering-benchmarks-scriban-templates-raw-fsharp
*)

type TemplateProps = 
    {
        Items : string list
    }

type ScribanBenchmarks() =

    let itemCount = 5000

    let testItems = 
        seq {0 .. itemCount}
        |> Seq.map 
            (fun _ -> Guid.NewGuid().ToString())
        |> Seq.toList
  
    // HAMY: A simple html doc
    let testTemplate1 = 
        """
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <title>Sentinels Example</title>
        </head>
        <body>
            <main>
                <h1>Sentinels Table</h1>  
            </main>
            <table>
                <tr>
                    <th>item</th>
                </tr>
                {{ for item in Items }}
                    <tr>
                        <td>{{ item }}</td>
                    </tr>
                {{ end }}
            </table>
        </body>
        </html>
        """
  
    let cachedParser 
        (template : string)
        = 
        let compiledTemplate = Scriban.Template.Parse(template)
    
        fun (props : TemplateProps) ->
            compiledTemplate.Render(
              props,
              // HAMY: Overload memberRenamer so var names are the same
              // Source: https://github.com/scriban/scriban/blob/master/doc/runtime.md#member-renamer
              memberRenamer = fun m -> m.Name)
  
    let rawParser 
        (template : string) 
        (props : TemplateProps) 
        =
        let compiledTemplate = Scriban.Template.Parse(template)
        compiledTemplate.Render(
          props,
          // HAMY: Overload memberRenamer so var names are the same
          // Source: https://github.com/scriban/scriban/blob/master/doc/runtime.md#member-renamer
          memberRenamer = fun m -> m.Name)

    // Super simple string encode
    // This is what's used by Scriban - https://github.com/scriban/scriban/blob/master/src/Scriban/Functions/HtmlFunctions.cs#L65
    let htmlEncode 
        (input : string)
        : string 
        =
        System.Net.WebUtility.HtmlEncode(input)

    let htmlForEach 
        (items : 'a list)
        (renderer : 'a -> string)
        (joinString : string)
        : string 
        =
        items
        |> List.map (
            fun i ->
                renderer i
        )
        |> String.concat joinString

    let renderRawStringTemplate1
        (props : TemplateProps) 
        : string 
        =  
        let sentinelTableItems =
            htmlForEach
                props.Items
                (fun i ->
                  $"""
                  <tr>
                      <td>{ i }</td>
                  </tr>
                  """
                )
                ""
    
        let renderedTemplate = 
            $"""
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <title>Sentinels Example</title>
            </head>
            <body>
                <main>
                    <h1>Sentinels Table</h1>  
                </main>
                <table>
                    <tr>
                        <th>item</th>
                    </tr>
                    { sentinelTableItems }
                </table>
            </body>
            </html>
            """
          
        renderedTemplate

    let renderGiraffeView 
        (props : TemplateProps)
        : string 
        =
        let page = 
            html [] [
                head [] [
                    meta [ _charset "UTF-8" ]
                ];
                body [] [
                    main [] [
                        h1 [] [
                          Text "Sentinels Table"
                        ];
                    ];
                    table [] 
                        (List.concat [
                            [
                                tr [] [
                                      th [] [
                                          Text "item"
                                      ];
                                ];
                            ];
                            (
                                props.Items
                                |> List.map(
                                    fun i ->
                                        tr [] [
                                            td [] [
                                                Text i
                                            ];
                                        ];
                                )
                            )
                        ]);
                ];
            ]

        page
        |> RenderView.AsString.htmlDocument

    let cachedParserTest1 = cachedParser testTemplate1

    let rawParserTest1 = rawParser testTemplate1

    [<Benchmark>]
    member __.RunCachedParser() = 
        cachedParserTest1 { Items = testItems }

    [<Benchmark>]
    member __.RunRawParser() =  
        rawParserTest1 { Items = testItems }

    [<Benchmark>]
    member __.RunRawString() =  
        renderRawStringTemplate1 { Items = testItems }

    [<Benchmark>]
    member __.RunGiraffeView() =  
        renderGiraffeView { Items = testItems }

[<EntryPoint>]
let main argv =
    printfn "Hello World from F#!"

    let summary = BenchmarkRunner.Run<ScribanBenchmarks>();

    // Debug: Why is Giraffe so fast???
    // let benchmark = ScribanBenchmarks()
    // printfn "%A" (benchmark.RunGiraffeView())
    
    0 // return an integer exit code

Next

I've been pleasantly surprised by Giraffe ViewEngine wrt both its devx and performance. Often there's a tradeoff between these two but here Giraffe seems to win out in both. To be fair I'm still a little hesitant to go with a DSL because it's not easily portable but I've come around a bit after using it for some experiments and think the short-term wins are likely worth any portability issues later on.

If you liked this post you might also like:

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.