Cookbook
Code examples
Four short, complete programs, each built entirely out of
features documented in api.md — no
external libraries, no framework, nothing installed beyond the
festina compiler itself. Every snippet below is
the whole program (or close to it); compile and run any of
them with:
Three of the four are also benchmarked and measured for code length against real Go, Rust, and Bun implementations of the same program — see At a glance below for the summary, or each example's own "Benchmark" subsection for the full numbers.
$ festina compile example.f -o example && ./example
At a glance #
Each of the first three examples below (the reverse proxy, the SQLite-over-WebSocket server, and the CAPTCHA endpoint — all genuinely equivalent-scope programs) is also benchmarked against real Go, Rust, and Bun implementations, and measured for code length. For each of those three, and each of the three columns below, every language's own value is expressed as a percentage of that one benchmark's four-language average (Festina+Rust+Go+Bun, divided by 4) — then those three per-benchmark percentages are themselves averaged into the single number shown:
| Language | Speed (bigger is better) | Binary size (smaller is better) | Code size (smaller is better) |
|---|---|---|---|
| Festina | 149% | 5% | 90% |
| Rust | 63% | 12% | 123% |
| Go | 93% | 24% | 108% |
| Bun | 94% | 359% | 79% |
Read the direction of "better" per column (also marked in
each header above): for Speed, above 100%
means faster than the four-language average; for
Binary size and Code size,
below 100% means smaller — a language that were
exactly average on every benchmark would show 100% in every
cell. Festina's 149% speed average is driven by two real
outliers rather than a broad lead: 3.3x the competitor
average on the reverse proxy (an outbound-connection-reuse
fix landed after this table was first measured — see
that section) and a
large win on the SQLite example, where all four
implementations use the identical PRAGMA
journal_mode=WAL/synchronous=NORMAL
settings Festina defaults to (see
that section) —
without that, the comparison would mostly be measuring who
remembered to set two pragmas, not the languages themselves.
Bun's 359% binary size is bun build --compile
embedding its entire runtime into every standalone binary
(see below); no
language is a clean sweep on code size, each lands within
about a quarter of the four-way average in either direction.
See each section below for the actual measurements these
percentages come from, not just the ratio.
The fourth example, the desktop weather app, is deliberately left out of the table above: Festina's version is a full windowed app (it needs no extra library for that — see api.md's Graphics section), while none of Go, Rust, or Bun ship a built-in canvas, so a fair equivalent there is fetch-and-parse logic only, not a full app. That comparison — narrower in scope, and noted as such — is in its own section below.
Benchmark methodology #
Each language uses its own normal toolchain and a realistic,
idiomatic library choice for the job — matching
benchmark.md's own
rule — not a hand-rolled minimal server built purely to look
small: Go's net/http (stdlib), Rust's
tiny_http/axum depending on what the
example needs, and Bun's native Bun.serve(). HTTP
examples are load-tested with a small dedicated Go client (20
concurrent connections, 5 seconds, 1 second of untimed warmup
first); the WebSocket example uses an equivalent Python
asyncio client sending one message and awaiting
one reply per round trip. Binary sizes are release/stripped
builds (festina compile; Go's
-ldflags="-s -w"; Rust's
profile.release.strip = true; Bun's
bun build --compile, which embeds the whole Bun
runtime — see each section for what that means for its own
binary size). Every program's full source was actually
compiled and run to produce these numbers, the same
verify-before-publish rule every benchmark on this site
follows; none of this is estimated. As with every benchmark
on this site: not a claim that Festina beats Go, Rust, or Bun
in general — real-world performance depends on what's
actually being written — this exists to show what a genuinely
equivalent program costs in each language, in both code and
runtime, for these specific small, realistic tasks.
A reverse proxy #
openPort() and outbound
req.send() are the same
http value
on both ends, so forwarding one request into another is just
building a second http literal from the first
and sending it on. A single on request handler
services one connection at a time (see
benchmark.md),
and every request here blocks on its own outbound call to
the upstream — so this version spreads that wait across a
pool of worker threads
instead, each with its own private
HTTP context, fed by
on request use workers — sugar for handing off
every accepted connection to whichever worker is idle right
now via
giveRequest:
thread workers[4] { text UPSTREAM = 'http://127.0.0.1:9000' on request(req:http) { url u = parseURL(req.url) http upstream = { 'url': `${UPSTREAM}${u.pathname}`, 'method': req.method, 'headers': req.headers, 'body': req.toBlob() } try { upstream.send() req.send({ 'code': upstream.code, 'headers': upstream.headers, 'body': upstream.toBlob() }) } catch (error:text) { req.send({'code': 502, 'body': `bad gateway: ${error}`}) } } } on request use workers openPort(8080)
req.send() called with zero arguments is the
client form of the exact
method the server side uses to answer a request — it blocks
until the upstream responds, then overwrites
upstream.code/.headers/its own body
in place, which is what gets relayed back below. A genuine
network failure (upstream down, DNS failure, timeout) throws,
caught here and turned into a 502 instead of
hanging the client. on request use workers
desugars, at parse time, to exactly
on request(req:http?) { workers.giveRequest(req) }
— the bare (unindexed) form of giveRequest, which
picks whichever pool instance is idle right now rather than a
fixed rotation, falling back to round-robin only if every
worker is currently busy (see
api.md). This is still
deliberately minimal — no path allow-list, no rewriting the
Host header for virtual-hosting a different
upstream name — but every request really is proxied through,
headers and body included, now with up to 4 of them genuinely
in flight at once, each over its own reused keep-alive
connection to the upstream (see the benchmark below for what
that's worth).
Benchmark #
| Reverse proxy | Requests/sec | Avg latency | Binary size | Lines |
|---|---|---|---|---|
| Festina (4 worker threads) | 28,771 | 1.74 ms | 1.6 MB | 27 |
Go (net/http) | 7,173 | 6.97 ms | 6.0 MB | 17 |
Rust (tiny_http + ureq) | 4,547 | 10.98 ms | 2.8 MB | 26 |
Bun (Bun.serve + fetch) | 14,120 | 3.54 ms | 99.3 MB | 21 |
Festina now leads all three competitors here — 3.3x
the Go/Rust/Bun average — after a real runtime fix
landed for exactly this example: req.send() used
to open a brand-new TCP connection to the upstream on
every single proxied request; it now reuses a
keep-alive connection to the same host instead (plain HTTP;
confirmed with strace that connect()
calls dropped from matching the request count 1:1 down to
essentially one per worker for the whole run). A second,
correctness-only fix shipped alongside it: the copied
headers map this example forwards
('headers': req.headers/upstream.headers
above) used to duplicate Host/Content-Length/Connection
on the wire instead of using the runtime's own computed
values — harmless against a lenient upstream, but a hard
400 against a strict one (found by testing this
exact example against a real Go upstream). Neither fix
touched this page's own source code; both are simply how
req.send() behaves now. Nothing here is
Festina-specific in spirit — reusing a connection to a host
you keep talking to is exactly what Go's, Rust's, and Bun's
own HTTP clients already do by default.
4 worker threads over the single-threaded version above: +230% throughput (28,771 req/s vs. a clean 8,729 req/s measured the same way — see the code above) — a much bigger jump than threading alone would give, because each of the 4 workers now keeps its own warm, reused connection to the upstream, so four threads means four persistent connections doing real, overlapping work, not four threads still paying a fresh handshake every time. At 27 lines this is close to the middle of the pack now, not the longest — Go and Bun get their concurrency for free from the runtime, so nothing in their own source above changed at all, but Rust's more manual setup is still longer. Bun's 99.3 MB binary is almost entirely the bundled Bun runtime (see below) — its proxy logic itself is a handful of lines.
SQLite behind a WebSocket #
table already means "a struct that's also
backed by SQLite" (see
api.md), and
req.upgrade() turns an
HTTP connection into a
WebSocket one — put the two together and any WebSocket
client (a browser, another Festina program acting as a
plain client, a one-line script in whatever language) can
write to, and read back from, a database it never opens
directly:
struct Reading { sensor:text value:float } table Readings { sensor:text value:float } openPort(9090) on request(req:http) { if parseURL(req.url).pathname == '/db' { req.upgrade() return } req.ok() } on upgrade(s:socket) { log('data source connected') } on socketMessage(s:socket, msg:blob) { Reading r = msg.toText().toStruct(Reading) sqlite('INSERT INTO Readings (sensor, value) VALUES (?, ?)', [r.sensor, r.value]) arr[Readings] recent = sqlite('SELECT rowid, sensor, value FROM Readings ORDER BY rowid DESC LIMIT 10') s.send(recent) // arr[T] renders as JSON, the same as an http body } on socketClose(s:socket) { log('data source disconnected') }
A message in is expected to be a JSON object shaped like
{"sensor": "temp-1", "value": 21.4} —
msg.toText().toStruct(Reading) parses it straight
into that shape (see
.toStruct() / .toArr());
every unknown key is skipped, every missing one keeps its
zero value, so a slightly-off payload never crashes the
connection. req.send()'s
client form is Festina's
only outbound HTTP call — there's no outbound WebSocket
client here, so "another server" reaches this database by
connecting in, the same way a browser tab would.
Benchmark #
| SQLite over WebSocket | Requests/sec | Avg latency | Binary size | Lines |
|---|---|---|---|---|
| Festina | 21,934 | 0.91 ms | 1.6 MB | 33 |
Go (nhooyr.io/websocket + modernc.org/sqlite) | 9,914 | 2.01 ms | 10.2 MB | 69 |
Rust (axum + rusqlite) | 19,660 | 1.01 ms | 3.5 MB | 79 |
Bun (Bun.serve + bun:sqlite, both built in) | 21,089 | 0.94 ms | 99.3 MB | 32 |
All four use the identical PRAGMA
journal_mode=WAL/synchronous=NORMAL
settings. Every sqlite() database in
Festina opens this way automatically (see
api.md's "Query
performance" — measured there at 20,000 inserts dropping
from 16.7s to 0.3s from this exact change); none of the three
competitor libraries default to it, so the same two
PRAGMA statements were added to each of their
setup code by hand for this comparison — without them, this
table would mostly be measuring who remembered to set two
pragmas, not the languages themselves. With that controlled
for, three of the four land within about 10% of each other
(Festina, Rust, and Bun all comfortably clear 19,000 req/s);
Go is the outlier here, and not because of WAL — its
database/sql driver overhead plus
modernc.org/sqlite being a pure-Go
reimplementation of SQLite (no C library, no cgo, which is
exactly why it's easy to cross-compile with) trades raw speed
for that convenience. Bun's built-in bun:sqlite
needs no separate package at all, which is why it's the
shortest of the three competitors despite doing the same work
as the other two.
A CAPTCHA-style image endpoint #
blankImage() and the
per-image drawing calls work
with no window and no display (see
api.md's Graphics section) —
exactly what an HTTP handler needs, since it never has a
screen to put anything on in the first place:
text CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' int CHARS_LEN = CHARS.split('').length // text has no .length -- split() by code point does int CODE_LEN = 5 text func randomCode(n:int) { text code = '' for int i = 0, i < n, i++ { int idx = Math.floor(Math.random() * CHARS_LEN) code = `${code}${CHARS[idx]}` } return code } color bg = '#eef1f5' color ink = '#2b3542' font captchaFont = 'bold 28px sans-serif' openPort(8080) on request(req:http) { if parseURL(req.url).pathname != '/captcha' { req.ok() return } text code = randomCode(CODE_LEN) img canvas = blankImage(160, 60) canvas.drawRect(0, 0, 160, 60, bg) for int i = 0, i < 40, i++ { // speckled noise int shade = 180 + Math.floor(Math.random() * 40) fillStyle(shade, shade, shade) canvas.drawPixel(Math.floor(Math.random() * 160), Math.floor(Math.random() * 60)) } fillStyle(ink) changeFont(captchaFont) for int i = 0, i < CODE_LEN, i++ { // jittered glyphs int y = 40 + Math.round((Math.random() - 0.5) * 12) canvas.drawText(code[i], 15 + i * 26, y) } // A real deployment would stash `code` somewhere keyed to this // visitor -- a `table` row, or s.state on an upgraded socket -- // to check the answer against later. Omitted to keep this short. req.send({'headers': {'content-type': 'image/png'}, 'body': canvas}) }
Nothing here is Cairo or libpng called by hand —
drawRect/drawPixel/drawText
are the same three calls
every canvas program uses,
just aimed at a fresh off-screen img instead of
an on-screen one. Sending it back is one line:
an img is a legal http body (see
the http type), sent as its
own encoded bytes — PNG here, since it was built rather than
loaded from a file (see Images).
Math.random() is seeded from the clock, which is
fine for visual noise and glyph jitter — it is explicitly
not suitable for anything where the code
itself needs to be unguessable.
Benchmark #
| CAPTCHA endpoint | Requests/sec | Avg latency | Binary size | Lines |
|---|---|---|---|---|
| Festina | 1,367 | 14.61 ms | 1.7 MB | 46 |
Go (image/png + x/image/font/basicfont) | 2,013 | 9.93 ms | 5.9 MB | 62 |
Rust (tiny_http + image/imageproc + ab_glyph) | 569 | 35.02 ms | 6.3 MB | 54 |
Bun (Bun.serve + @napi-rs/canvas) | 738 | 27.00 ms | 163.6 MB | 44 |
Unlike the other two examples, this one is CPU-bound (pixel
fills plus glyph rasterization), not connection-handling-bound
— which is why Festina's single-threaded server doesn't cost
it here the way it did for the plain proxy above. Go's
basicfont is a tiny built-in bitmap font (no font
file, no antialiasing) — the fastest of the four but the
roughest-looking output; Rust's and Bun's routes both
rasterize a real antialiased TrueType font per glyph, closer
to what Festina's own drawText does, and pay
more per request for it. Bun's 163.6 MB binary is the Bun
runtime plus @napi-rs/canvas's own bundled native
image library.
A desktop weather app #
A window, a background HTTP request, and a redraw once it
answers — callback mode keeps the outbound
request from freezing the window while it waits (see
Making outbound requests),
and setInterval repeats it. Open-Meteo's forecast
API needs no key, so this is the whole program:
struct Current { temperature_2m:float wind_speed_10m:float } struct Weather { current:Current } float temp = 0.0 float wind = 0.0 bool loaded = false color bg = '#1e2a38' color ink = '#f1f6fb' font bigFont = 'bold 48px sans-serif' font smallFont = '16px sans-serif' void func draw() { fillStyle(bg) drawRect(0, 0, 320, 180) fillStyle(ink) changeFont(smallFont) drawText('Berlin', 20, 30) changeFont(bigFont) drawText(loaded ? `${Math.round(temp)}°C` : '…', 20, 100) changeFont(smallFont) drawText(loaded ? `wind ${Math.round(wind)} km/h` : '', 20, 150) render() } void func onWeather(r:http) { if r.code == null { log(`weather request failed: ${r.toText()}`) return } Weather w = r.toText().toStruct(Weather) temp = w.current.temperature_2m wind = w.current.wind_speed_10m loaded = true draw() } void func fetchWeather() { http {'url': 'https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=temperature_2m,wind_speed_10m', 'method': 'GET', 'callback': onWeather} } setClientWidth(320) setClientHeight(180) draw() fetchWeather() setInterval(fetchWeather, 600000) // refresh every 10 minutes
draw() runs once immediately (showing
"…" until the first response lands) and again
from inside onWeather, so the window is never
left blank. In callback mode a network failure never throws
— it leaves r.code null and
r.toText() holding the failure message instead,
which is what onWeather checks first. The
structs only need the fields this program
actually reads; every other field Open-Meteo's response
contains is silently ignored by
.toStruct().
One easy-to-miss detail: method defaults to an
empty text, not 'GET' — a
real server reads the resulting blank method off the request
line and rejects it, so an outbound GET always
needs 'method': 'GET' spelled out.
Benchmark #
Narrower in scope than the three benchmarks above, on purpose — excluded from the page's averages for it. Festina's row below is the full windowed app shown above, binary and all; none of Go, Rust, or Bun ship a canvas/window API the way Festina's runtime does (see api.md), so building one would mean pulling in each ecosystem's own separate GUI toolkit — not really about the language at that point. What every language does have built in is an HTTP client and a JSON parser, so that's what's measured here: fetch the same local mock endpoint and parse its response into the two numbers the app displays, timed as the minimum of three runs of 200 sequential calls each (matching benchmark.md's own "minimum of N runs" rule) — no concurrency, since this is a single desktop app making one request at a time, not a server answering many.
| Weather app | Time per fetch+parse | Binary size | Lines |
|---|---|---|---|
| Festina (full windowed app) | 0.300 ms | 1.7 MB | 50 |
Go (net/http + encoding/json, fetch+parse only) | 0.485 ms | 5.9 MB | 29 |
Rust (reqwest + serde_json, fetch+parse only) | 0.350 ms | 2.8 MB | 23 |
Bun (native fetch, fetch+parse only) | 0.446 ms | 99.3 MB | 8 |
Read the line counts with the scope difference above in mind: Bun's 8 lines are genuinely all it takes to fetch and parse JSON (both are language built-ins), while Festina's 50 include an entire window, a color scheme, two fonts, a draw function, and a callback — a full app, not a script. Festina is still the fastest of the four at the one piece of work being timed here, fetch-plus-parse, but that is the narrower and less interesting claim; the wider one — that Festina needed no extra dependency at all to put the result on screen — doesn't reduce to a single number.