HTML Rendering Benchmarks - Raw strings vs Scriban Templates in F#
Date: 2023-12-06 | create | tech | fsharp | html | performance |
Recently I've been playing around with server-side HTML rendering in F#. In the process, I've experimented with several approaches:
- HTML Templates with Scriban - similar to popular templating paradigms like Mustache, Liquid, or Svelte.
- Raw string HTML - Building HTML markup with raw strings.
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#?
Answer
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:
- Template Parsing - A ~fixed cost operation that Scriban must perform to translate the template string into a representation it can understand (and later populate with data). This is more pronounced for smaller data sizes as the parsing:rendering work ratio is higher than it is for larger data sizes where the rendering work drowns out the parsing overhead.
- Rendering - A dynamic cost based on the amount and topology of data being rendered into the template. Scriban uses some form of Reflection internally (as we found out in the previous benchmark) which is typically slower than the "normal" string operations we use in the raw string HTML.
Here's a summary of the results:
- 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, only one to finish
In the rest of this post we'll explore these benchmarks in more detail.
- Benchmark Setup
- Benchmark Result Details
- Benchmark Code
Benchmark Setup
The Benchmark aims to compare 3 HTML rendering paradigms:
- rawString - Building HTML using loops and string interpolation
- rawParser - Using Scriban to parse a template and render HTML
- cachedParser - same as rawParser but caching the parsed template between renders
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.
Benchmark Result Details
10 Items
rawString wins in 0.024 ms (0.11 ms (5.6x) faster than CachedParser)
- RawString - 0.024 ms
- CachedParser - 0.135 ms
- RawParser - 0.214 ms
100 Items
rawString wins in 0.181 ms (0.255 ms (2.4x) faster than cachedParser)
- rawString - 0.181 ms
- cachedParser - 0.436 ms
- rawParser - 0.516 ms
200 Items
rawString wins in 0.373 ms (0.622 ms (2.7x) faster than cachedParser)
- rawString - 0.373 ms
- cachedParser - 0.995 ms
- rawParser - 1.248 ms
500 Items
rawString wins in 2.363 ms (0.842 ms (1.4x) faster than cachedParser)
- rawString - 2.363 ms
- cachedParser - 3.205 ms
- rawParser - 3.450 ms
800 Items
rawString wins in 3.863 ms (0.715 ms (1.2x) faster than cachedParser)
- rawString - 3.863 ms
- cachedParser - 4.578 ms
- rawParser - 4.783 ms
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.
Benchmark Code
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
Next
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:
Want more like this?
The best / easiest way to support my work is by subscribing for future updates and sharing with your network.