f Festina / Docs

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:

LanguageSpeed
(bigger is better)
Binary size
(smaller is better)
Code size
(smaller is better)
Festina149%5%90%
Rust63%12%123%
Go93%24%108%
Bun94%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 proxyRequests/secAvg latencyBinary sizeLines
Festina (4 worker threads)28,7711.74 ms1.6 MB27
Go (net/http)7,1736.97 ms6.0 MB17
Rust (tiny_http + ureq)4,54710.98 ms2.8 MB26
Bun (Bun.serve + fetch)14,1203.54 ms99.3 MB21

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 WebSocketRequests/secAvg latencyBinary sizeLines
Festina21,9340.91 ms1.6 MB33
Go (nhooyr.io/websocket + modernc.org/sqlite)9,9142.01 ms10.2 MB69
Rust (axum + rusqlite)19,6601.01 ms3.5 MB79
Bun (Bun.serve + bun:sqlite, both built in)21,0890.94 ms99.3 MB32

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 endpointRequests/secAvg latencyBinary sizeLines
Festina1,36714.61 ms1.7 MB46
Go (image/png + x/image/font/basicfont)2,0139.93 ms5.9 MB62
Rust (tiny_http + image/imageproc + ab_glyph)56935.02 ms6.3 MB54
Bun (Bun.serve + @napi-rs/canvas)73827.00 ms163.6 MB44

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&current=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 appTime per fetch+parseBinary sizeLines
Festina (full windowed app)0.300 ms1.7 MB50
Go (net/http + encoding/json, fetch+parse only)0.485 ms5.9 MB29
Rust (reqwest + serde_json, fetch+parse only)0.350 ms2.8 MB23
Bun (native fetch, fetch+parse only)0.446 ms99.3 MB8

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.