Essay - Published: 2023.12.11 | create | fsharp | giraffe | html | performance | tech |
DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)
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:
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?
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:

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:
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:
In this section I'll be sharing raw benchmark outputs for posterity / later reference.
10 Items - GiraffeView in 0.017 ms (1.4x faster)

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

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

500 Items - GiraffeView in 0.503 ms (4.3x faster)
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.

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

1000 Items - GiraffeView in 1.377 ms (3.2x faster)
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.

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

5000 Items - GiraffeView in 7.409 ms (2.8x faster)
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.

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/blog/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
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:
The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.