F# HTML Benchmarks - Deeply-Nested Pages

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

For the past several weeks I've been exploring server-side rendering with F#. In the process, I've tried several rendering paradigms and compared their relative devx and performance.

Long, Shallow HTML Page

In my previous performance benchmarks, we've largely been comparing rendering workloads consisting of what I call long, shallow pages. These are things like long lists / tables but relatively simple markup.

While this is a good data point to have, it's actually not that realistic for typical HTML rendering patterns. In most cases if you have a list you won't be rendering more than say 100 items at a time and usually there's only going to be one or two lists per page.

A much more common workload that this benchmark misses is the combining of multiple sub components to build a page. Essentially every page is built of multiple sub components - a nav bar, some main content, a footer, maybe some sidebar stuff, etc and further those may be composed of their own subcomponents like buttons, formatted section, link groups, etc. This means that a far more common workload than rendering a single large list is to combine multiple nested subcomponents together.

Short, Deep HTML Page

I'm calling this rendering workload short, deep pages. This is an important aspect to measure not just because it is far more common / prevalent in real-world apps but also because it forces our underlying frameworks to act in a different way. When you have a single large piece of data to render you can do all sorts of optimizations based on the landscape of that data. But when you have multiple sub components of which you do not control their output, you now have to be a lot more dynamic (and typically non-optimized) in how you handle them.

As we'll see in the rest of this post, this difference is significant.

Quick shout out to /udr_brr in r/fsharp for pointing out this common HTML rendering perf issue. If you're not in r/fsharp yet, join us =).

In this post we're going to explore:

Q: How do different HTML rendering paradigms handle deeply-nested pages?

Answer

Giraffe.ViewEngine absolutely slays in the deeply-nested category. This is likely due to its optimized string pipeline - in-memory build up using lists (fast) followed by only a single string translation using string builder pools (also fast). Compare this to raw string interpolations (or template bindings) which must use some form of dynamic string concatenation at every intermediate join and we can see how this approach leads to much more expensive string operations on each render.

For simple pages, this likely won't affect your decision. But if you're building complex pages with lots of nested components this may be something to keep in mind if performance starts to become a problem.

Results summary:

  • 10 Items - Giraffe.ViewEngine in 0.019 ms (2.5x faster)
  • 100 - GiraffeViewEngine in 0.120 ms (16.4x)
  • 200 - Giraffe.ViewEngine in 0.231 ms (33.5x faster)
  • 500 - Giraffe.ViewEngine in 0.540 ms (112.8x faster)
  • 800 - Giraffe.ViewEngine in 1.278 ms (166.8x faster)
  • 1000 - Giraffe.ViewEngine in 1.608 ms (217.6x faster)
  • 2000 - Giraffe.ViewEngine in 3.355 ms (740.9x faster)
  • 5000 - Giraffe.ViewEngine in 8.143 ms (1749.3x faster)

In the rest of this post, we'll dive into the benchmarks to explore further how we got these results.

  • Benchmark Setup
  • Detailed Results
  • Benchmark Code

Benchmark Setup

Similar to my previous benchmarks, we'll be building up example HTML pages using various paradigms. Here we are only comparing raw strings with Giraffe.ViewEngine because I couldn't think of a native Template paradigm I would use for nested components (typically I just use simple interpolation for them).

  • RawString - Building up HTML with raw strings. For sub components, we utilize string interpolation to combine the HTML.
  • Giraffe.ViewEngine - Using the HTML DSL which uses nested lists to build up a representation of the HTML before rendering it all to a string at the end.

For running and analyzing the results, we're using BenchmarkDotnet in a free Replit instance. The benchmark source code and link to Replit is available in the Benchmark Code section.

The workload we're comparing is building up deeply nested HTML pages. Similar to our previous benchmarks, we're creating a large list of randomized strings (using Guids) and then rendering them in a table. The difference here is that instead of rendering a true table (with rows), we're actually going to render everything as nested divs:

<div>
    Item 1
    <div>
        Item 2
        ...
    </div>
</div>

This gives us a very simple framework for rendering deeply nested components.

Detailed Results

In this section I'll share the BenchmarkDotnet outputs for each run of size n.

10 Items - Giraffe.ViewEngine in 0.019 ms (2.5x faster)

  • RawString - 0.048 ms
  • Giraffe.ViewEngine - 0.019 ms

10 Items

100 Items - GiraffeViewEngine in 0.120 ms (16.4x)

  • RawString - 1.972 ms
  • Giraffe.ViewEngine - 0.120 ms

100 Items

200 Items - Giraffe.ViewEngine in 0.231 ms (33.5x faster)

  • RawString - 7.741 ms
  • Giraffe.ViewEngine - 0.231 ms

200 Items

500 Items - Giraffe.ViewEngine in 0.540 ms (112.8x faster)

  • RawString - 60.895 ms
  • Giraffe.ViewEngine - 0.540 ms

500 Items

800 Items - Giraffe.ViewEngine in 1.278 ms (166.8x faster)

  • RawString - 213.132 ms
  • Giraffe.ViewEngine - 1.278 ms

800 Items

1000 Items - Giraffe.ViewEngine in 1.608 ms (217.6x faster)

  • RawString - 349.913 ms
  • Giraffe.ViewEngine - 1.608 ms

1000 Items

2000 Items - Giraffe.ViewEngine in 3.355 ms (740.9x faster)

  • RawString - 2485.829 ms
  • Giraffe.ViewEngine - 3.355 ms

2000 Items

5000 Items - Giraffe.ViewEngine in 8.143 ms (1749.3x faster)

  • RawString - 14245.689
  • Giraffe.ViewEngine - 8.143 ms

5000 Items

Benchmark Code

Now for the code.

Here's a copypasta of the benchmark at time of writing:

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

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

    let rec renderNestedDivsRaw
      (items : string list)
      : string 
      =
      let div = 
        match (List.tryHead items) with 
        | None -> "end"
        | Some x -> 
          $"""
          <div>
              <h1>{ x }</h1>
              {
                renderNestedDivsRaw
                    (
                        items
                        |> List.skip 1
                    )
              }
          </div>
          """

      div
    
    let renderRawStringTemplate1
        (props : TemplateProps) 
        : string 
        =  
    
        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>
                    { renderNestedDivsRaw props.Items }
                </table>
            </body>
            </html>
            """
          
        renderedTemplate

    let rec renderNestedDivsGiraffe
      (items : string list)
      : XmlNode 
      =
      let div = 
        match (List.tryHead items) with 
        | None -> Text "end"
        | Some x -> 
          div [] [
            h1 [] [ Text x ];
            renderNestedDivsGiraffe (
              items
              |> List.skip 1
            )
          ]
  
      div

    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"
                                      ];
                                ];
                            ];
                            [(
                                renderNestedDivsGiraffe props.Items
                            )]
                        ]);
                ];
            ]

        page
        |> RenderView.AsString.htmlDocument

    [<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

Giraffe.ViewEngine is hella fast. You probably won't be rendering 5000 nested components ever but 500 seems possible with a sufficiently mature web app and we're seeing significant 100x speed ups there.

Personally I've been pleasantly surprised with both the ergonomics and performance of Giraffe.ViewEngine and plan to continue playing with it in upcoming projects.

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.