Essay - Published: 2023.12.06 | create | fsharp | html | performance | tech |
DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)
Recently I've been playing around with server-side HTML rendering in F#. In the process, I've experimented with several approaches:
I found the raw string HTML to have a nice balance of readability and type-safety. It's all F# so we get the full power of its type systems. But I was worried about the performance - string operations are notoriously expensive so I wanted to make sure I wasn't accidentally shooting myself in the foot (especially considering Scriban HTML templates are quite fast).
Q: How does raw string HTML rendering performance compare to using Scriban HTML Templates in F#?
Scriban HTML templates remain incredibly fast, rendering 100s of elements in just a few milliseconds (800 in 4.6 ms). Raw string HTML is faster for small templates by about 5.6x (0.1 ms faster) and remains faster for larger templates by about 1.2x (0.7 ms faster). Both are viable approaches for fast server-side HTML rendering in F#.
My hypothesis is that there are two major factors causing Scriban templates to be slower than raw strings:
Here's a summary of the results:
In the rest of this post we'll explore these benchmarks in more detail.
The Benchmark aims to compare 3 HTML rendering paradigms:
The workload we're comparing is rendering a simple HTML table for a dynamic, randomized list of items of varying sizes. Each item is a randomized Guid string (using Guid).
This approach is similar to how we previously benchmarked Scriban parsed template caching.
We then execute the benchmarks using BenchmarkDotnet.
10 Items
rawString wins in 0.024 ms (0.11 ms (5.6x) faster than CachedParser)

100 Items
rawString wins in 0.181 ms (0.255 ms (2.4x) faster than cachedParser)

200 Items
rawString wins in 0.373 ms (0.622 ms (2.7x) faster than cachedParser)

500 Items
rawString wins in 2.363 ms (0.842 ms (1.4x) faster than cachedParser)

800 Items
rawString wins in 3.863 ms (0.715 ms (1.2x) faster than cachedParser)

1000 Items
rawString wins in 4.670 ms as the only benchmark to complete.
Scriban again ran into Reflection limits at the 1000 item mark with error System.Reflection.TargetInvocationException. My previous hypothesis still stands that this is a default Reflection limit that could be configured if you want a higher limit but typically you're not going to be rendering 1000 items directly anyway so it won't matter for most usecases.

You can find the full source code and run the benchmark yourself in the ScribanVsRawStringTemplating Replit.
Here's the full source code copypasta for posterity:
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
open Scriban
open System
(*
* ToRun: dotnet run -c Release
*)
type TemplateProps =
{
Items : string list
}
type ScribanBenchmarks() =
let itemCount = 1000
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 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 }
[<EntryPoint>]
let main argv =
printfn "Hello World from F#!"
let summary = BenchmarkRunner.Run<ScribanBenchmarks>();
0 // return an integer exit code
Given both Scriban templating and raw string rendering perform incredibly well, I don't think performance should be a major factor for choosing between approaches. Personally I'm still playing around with which paradigm provides the best devx - on one hand raw strings allow me to lean into the F# type system more but on the other templating seems to provide more readable markup.
If you're doing server side rendering, let me know what approach you're using and why.
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.