One TodoMVC, nine languages, one platform

We built the same small app nine times, once in each of nine language setups, and put every one on Carloku, the hosted CARLOS Platform. The app is TodoMVC: a to-do list. Here it's a page the server builds, which works with JavaScript switched off, plus a little JavaScript on top so it feels like the familiar TodoMVC.

The point is to show that CARLOS apps, and the platform they run on, aren't tied to Go. So this page compares the nine; it doesn't rank them.

One AI model, Opus 5.5, built every app from the same brief. A second, Astra, reviewed each one. A third, Fable, scored how readable the code is without being told which app it was reading. All nine apps are live, and their code is public at amadan.net/carlos/todomvc-bakeoff.

Read the write-up: what the numbers mean and what we conclude

What stood out

At a glance

SetupTests (JS on · off)First byteWakePeak reads/sBinaryCold build / one-line changeLinesDepsCostReadability
Go 35/35 · 32/32 95.9 ms1267 ms349 12.3 MB10.3 s / 0.1 s 8801$3.698/10
Go + Rastrillo 35/35 · 32/32 56.6 ms1369 ms336 14.1 MB10.4 s / 0.1 s 7973$4.678/10
Rust 35/35 · 32/32 93.2 ms1245 ms904 3.2 MB49.6 s / 5.8 s 8157$2.928/10
Zig 35/35 · 32/32 50.2 ms1238 ms1,382 1.9 MB2.5 min / 5.4 s 10581$5.719/10
OCaml 35/35 · 32/32 51.7 ms1286 ms992 8.2 MB18.3 min / 3.3 s 6979$7.658/10
Haskell 35/35 · 32/32 99.3 ms1364 ms400 28.7 MB19.1 min / 26.6 s 79810$5.068/10
Swift 35/35 · 32/32 58.7 ms1445 ms537 22.0 MB81.4 s / 3.9 s 7253$4.608/10
Nim 35/35 · 32/32 50.6 ms1299 ms1,491 1.8 MB33.9 s / 0.9 s 10062$4.848/10
Spinel 35/35 · 32/32 94.6 ms1285 ms722 8.7 MB4.8 min / 104.0 s 5662$10.707/10

Time to first byte, empty list

Median of 30 page loads from our test machine, each on a new connection, so it includes the network.

GoGo: 95.9 ms95.9 msGo + RastrilloGo + Rastrillo: 56.6 ms56.6 msRustRust: 93.2 ms93.2 msZigZig: 50.2 ms50.2 msOCamlOCaml: 51.7 ms51.7 msHaskellHaskell: 99.3 ms99.3 msSwiftSwift: 58.7 ms58.7 msNimNim: 50.6 ms50.6 msSpinelSpinel: 94.6 ms94.6 ms

Time to first byte, 50 items

The same, with 50 todos on the list.

GoGo: 97.0 ms97.0 msGo + RastrilloGo + Rastrillo: 54.1 ms54.1 msRustRust: 95.8 ms95.8 msZigZig: 54.1 ms54.1 msOCamlOCaml: 52.9 ms52.9 msHaskellHaskell: 98.5 ms98.5 msSwiftSwift: 56.3 ms56.3 msNimNim: 50.8 ms50.8 msSpinelSpinel: 92.8 ms92.8 ms

Wake from sleep

Time to the first byte when the app had gone to sleep after 15 idle minutes. Median of three.

GoGo: 1267 ms1267 msGo + RastrilloGo + Rastrillo: 1369 ms1369 msRustRust: 1245 ms1245 msZigZig: 1238 ms1238 msOCamlOCaml: 1286 ms1286 msHaskellHaskell: 1364 ms1364 msSwiftSwift: 1445 ms1445 msNimNim: 1299 ms1299 msSpinelSpinel: 1285 ms1285 ms

Peak page loads per second

Loading a 50-item list, starting with one connection and doubling the number of simultaneous connections every 10 seconds.

GoGo: 349349Go + RastrilloGo + Rastrillo: 336336RustRust: 904904ZigZig: 1,3821,382OCamlOCaml: 992992HaskellHaskell: 400400SwiftSwift: 537537NimNim: 1,4911,491SpinelSpinel: 722722

Page loads: where it slows down

The fewest simultaneous connections from which adding more never raised throughput by 10% or more.

GoGo: 8 connections8 connectionsGo + RastrilloGo + Rastrillo: 16 connections16 connectionsRustRust: 64 connections64 connections · Carloku turned some requests away from 128ZigZig: 32 connections32 connectionsOCamlOCaml: 64 connections64 connections · Carloku turned some requests away from 256HaskellHaskell: 32 connections32 connectionsSwiftSwift: 64 connections64 connections · requests started failing from 512NimNim: 32 connections32 connectionsSpinelSpinel: 64 connections64 connections · Carloku turned some requests away from 128

Peak toggles per second

Ticking one todo on and off, ramping the same way.

GoGo: 810810Go + RastrilloGo + Rastrillo: 309309RustRust: 1,0221,022ZigZig: 3,1133,113OCamlOCaml: 1,0581,058HaskellHaskell: 862862SwiftSwift: 1,4531,453NimNim: 3,5613,561SpinelSpinel: 2,4512,451

Toggles: where it slows down

The same rule as for page loads.

GoGo: 32 connections32 connections · Carloku turned some requests away from 128Go + RastrilloGo + Rastrillo: 16 connections16 connectionsRustRust: 64 connections64 connections · Carloku turned some requests away from 128ZigZig: 64 connections64 connectionsOCamlOCaml: 64 connections64 connections · Carloku turned some requests away from 128HaskellHaskell: 32 connections32 connections · Carloku turned some requests away from 128SwiftSwift: 128 connections128 connectionsNimNim: 128 connections128 connections · requests started failing from 256SpinelSpinel: 64 connections64 connections · requests started failing from 256

Peak page loads per second, after the writes

The read ramp again, straight after the toggle ramp, on the same database.

GoGo: 343343Go + RastrilloGo + Rastrillo: 336336RustRust: 1,3981,398ZigZig: 1,3791,379OCamlOCaml: 743743HaskellHaskell: 391391SwiftSwift: 532532NimNim: 1,4791,479SpinelSpinel: 585585

Page loads after the writes: where it slows down

The same rule, for the read ramp run straight after the writes.

GoGo: 8 connections8 connectionsGo + RastrilloGo + Rastrillo: 16 connections16 connectionsRustRust: 128 connections128 connectionsZigZig: 32 connections32 connectionsOCamlOCaml: 32 connections32 connections · Carloku turned some requests away from 128HaskellHaskell: 8 connections8 connectionsSwiftSwift: 64 connections64 connectionsNimNim: 32 connections32 connectionsSpinelSpinel: 16 connections16 connections · requests started failing from 32

Memory at rest

Resident memory just after start, measured on our own machine because Carloku doesn't show members memory figures.

GoGo: 17.0 MB17.0 MBGo + RastrilloGo + Rastrillo: 18.7 MB18.7 MBRustRust: 13.8 MB13.8 MBZigZig: 2.5 MB2.5 MBOCamlOCaml: 10.7 MB10.7 MBHaskellHaskell: 11.1 MB11.1 MBSwiftSwift: 23.7 MB23.7 MBNimNim: 3.5 MB3.5 MBSpinelSpinel: 8.2 MB8.2 MB

Memory under load

The most resident memory each app used during 10 seconds of 64 simultaneous page loads and then 10 seconds of 64 simultaneous toggles, on our own machine.

GoGo: 70.7 MB70.7 MBGo + RastrilloGo + Rastrillo: 63.3 MB63.3 MBRustRust: 52.6 MB52.6 MBZigZig: 25.3 MB25.3 MBOCamlOCaml: 17.0 MB17.0 MBHaskellHaskell: 76.6 MB76.6 MBSwiftSwift: 33.4 MB33.4 MBNimNim: 11.5 MB11.5 MBSpinelSpinel: 12.8 MB12.8 MB

Binary size

The linux-arm64 file that ships.

GoGo: 12.3 MB12.3 MBGo + RastrilloGo + Rastrillo: 14.1 MB14.1 MBRustRust: 3.2 MB3.2 MBZigZig: 1.9 MB1.9 MBOCamlOCaml: 8.2 MB8.2 MBHaskellHaskell: 28.7 MB28.7 MBSwiftSwift: 22.0 MB22.0 MBNimNim: 1.8 MB1.8 MBSpinelSpinel: 8.7 MB8.7 MB

Cold build

Every library compiled from already-downloaded sources. One run each, because some take close to an hour under emulation.

GoGo: 10.3 s10.3 sGo + RastrilloGo + Rastrillo: 10.4 s10.4 sRustRust: 49.6 s49.6 sZigZig: 2.5 min2.5 minOCamlOCaml: 18.3 min18.3 minHaskellHaskell: 19.1 min19.1 minSwiftSwift: 81.4 s81.4 sNimNim: 33.9 s33.9 sSpinelSpinel: 4.8 min4.8 min

Rebuild after a one-line change

Everything already compiled, then one line added to the code that serves the page (a unique comment, so no cache can skip the work). Median of three.

GoGo: 0.1 s0.1 sGo + RastrilloGo + Rastrillo: 0.1 s0.1 sRustRust: 5.8 s5.8 sZigZig: 5.4 s5.4 sOCamlOCaml: 3.3 s3.3 sHaskellHaskell: 26.6 s26.6 sSwiftSwift: 3.9 s3.9 sNimNim: 0.9 s0.9 sSpinelSpinel: 104.0 s104.0 s

Lines of code

Non-blank lines of each app's own code. Tests are not counted.

GoGo: server 665Go: templates 83Go: JavaScript 132880Go + RastrilloGo + Rastrillo: server 609Go + Rastrillo: templates 102Go + Rastrillo: JavaScript 86797RustRust: server 702Rust: JavaScript 113815ZigZig: server 964Zig: JavaScript 941,058OCamlOCaml: server 597OCaml: JavaScript 100697HaskellHaskell: server 685Haskell: JavaScript 113798SwiftSwift: server 650Swift: JavaScript 75725NimNim: server 819Nim: templates 75Nim: JavaScript 1121,006SpinelSpinel: server 495Spinel: JavaScript 71566
SetupservertemplatesJavaScript
Go66583132
Go + Rastrillo60910286
Rust7020113
Zig964094
OCaml5970100
Haskell6850113
Swift650075
Nim81975112
Spinel495071

Cost to build

What Opus 5.5 cost at list price for the build and its fix rounds.

GoGo: $3.69$3.69Go + RastrilloGo + Rastrillo: $4.67$4.67RustRust: $2.92$2.92ZigZig: $5.71$5.71OCamlOCaml: $7.65$7.65HaskellHaskell: $5.06$5.06SwiftSwift: $4.60$4.60NimNim: $4.84$4.84SpinelSpinel: $10.70$10.70

Tokens written to build

Output tokens Opus 5.5 produced for the build and its fix rounds.

GoGo: 65,22465,224Go + RastrilloGo + Rastrillo: 62,18562,185RustRust: 53,14353,143ZigZig: 75,29575,295OCamlOCaml: 107,648107,648HaskellHaskell: 85,74985,749SwiftSwift: 74,84474,844NimNim: 73,65873,658SpinelSpinel: 120,010120,010

Readability

A blind score out of 10 from Fable, a different model from the builder. Median of three readings.

GoGo: 8 / 108 / 10Go + RastrilloGo + Rastrillo: 8 / 108 / 10RustRust: 8 / 108 / 10ZigZig: 9 / 109 / 10OCamlOCaml: 8 / 108 / 10HaskellHaskell: 8 / 108 / 10SwiftSwift: 8 / 108 / 10NimNim: 8 / 108 / 10SpinelSpinel: 7 / 107 / 10

The apps

Go

Live app
https://todo-go-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/go
Stack
Go's standard web server and templates, with SQLite through modernc.org/sqlite (pure Go).
Readability
Strongest: comments consistently explain why (migration policy, single-writer pool, Origin check, focusout during swap), and the package docs state the security model up front. Request flow is short: route in New -> itemAction -> action -> Store method -> render "list". Error path is explicit via ErrNotFound and fail, with buffered rendering for clean 500s. Weakest: action/itemAction wrappers with inline closures and two differing callback signatures add a layer to step through; `one`, `w`/`r` fields and `bumpMarker` are terse or odd. loadStatic ignores fs.Sub and WalkDir errors, and the view struct mixes filter state, counts and Version. app.js swap/draft restoration is dense.
Left after review
  • [spec] [apps/go/main.go:114](/home/paulca/amadan.net/carlos/todomvc-bakeoff/.worktrees/go/apps/go/main.go:114): `checkLoopback` accepts any loopback address, including `127.0.0.2` and `::1`. The brief explicitly requires binding only to `127.0.0.1`. These alternative `--listen` addresses pass validation and start a listener; restrict validation to the required address.

Go + Rastrillo

Live app
https://todo-go-rastrillo-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/go-rastrillo
Stack
Rastrillo 0.28, the CARLOS web framework: chi for routing and GORM over SQLite.
Readability
Strongest: a toggle is traceable in seconds — Router lists every route, owned() is the single ownership seam, afterItemWrite maps error/no-row/success to 500/404/done, and done() explains the 303-vs-fragment split. Comments consistently explain why (Resolve not Run, why not csrf.Protect, parseForm's 1 MiB cap, toggleAll's single-statement race note). Weakest: renderStatus swallows template errors with a bare http.Error and no log or ref, unlike fail(); several `uid, _ := sessions.UserID(r)` rely on an invariant stated only in visitor.go; repeated 'the contract' references assume context the reader lacks; done() is a vague name for 'respond after success'. app.go mixes wiring with parseForm middleware.
Left after review
  • [correctness] `apps/go-rastrillo/internal/todo/static/app.js:42` — Every action response replaces the entire app, preserving only the new-todo input. With a request pending, double-click another todo and start editing: when the earlier response arrives, the editor disappears and its unsaved text is lost. Preserve the active editor’s value and focus across replacement, or defer replacement until editing finishes.

Rust

Live app
https://todo-rust-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/rust
Stack
axum on tokio, Maud for HTML, and rusqlite with SQLite built in.
Readability
Strongest: the `act` helper gives every action one pipeline (resolve visitor, transaction, 404/303/fragment), so a request is easy to follow from route to db to markup; comments explain real why (body-read before 403 behind the edge, mutex over a pool, append-only migrations, editing li preservation). db.rs is one query per function with scoped list ids. Weakest: handler params are single letters (s, h, q, f) and the `found`/`Outcome` indirection adds a hop; `swap` in app.js is dense DOM surgery that needs careful reading; errors are only rusqlite wrapped in AppError and logged with eprintln, with a few `expect`s standing in for proper failure paths.
Left after review
  • [correctness] [apps/rust/static/app.js:10](/home/paulca/amadan.net/carlos/todomvc-bakeoff/.worktrees/rust/apps/rust/static/app.js:10): Filter navigation can discard pending changes. Requests wait in a promise queue, but filter links navigate immediately. With a toggle request pending, edit another todo and click a filter: blur queues the save, then navigation destroys the queue before that save reaches the server. Wait for pending actions before navigating.
  • [spec] [apps/rust/src/main.rs:45](/home/paulca/amadan.net/carlos/todomvc-bakeoff/.worktrees/rust/apps/rust/src/main.rs:45): `--listen` accepts every loopback address, including `::1` and `127.0.0.2`. The brief explicitly restricts binding to `127.0.0.1`. Validate that exact address.

Zig

Live app
https://todo-zig-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/zig
Stack
Zig's standard-library HTTP server, with SQLite 3.54 compiled in.
Readability
Strongest: a request is trivially traceable (app.js form → handle → post → itemAction → Store.toggle → page/303), helped by the Response/Visitor/Form structs and tight module split. Comments earn their place: why headers are copied before reading the body, why keep-alive drops after an oversized body, why a single mutexed SQLite connection. Tests double as documentation. Weakest: errors in db.zig collapse to one opaque error.Sqlite with logging as the only detail; readerExpectContinue's failure is remapped to WriteFailed, hiding the cause; post() mixes routing and handling in one long if-chain, and get() parses the query inline. Minor: static() and run() names are terse.

OCaml

Live app
https://todo-ocaml-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/ocaml
Stack
OCaml 5 with Eio and cohttp-eio, and sqlite3-ocaml over SQLite 3.54.
Readability
Strongest: a toggle is trivially traceable (handle → action_of_path → post → perform → Store.toggle → View.fragment or 303), helped by App being server-independent and each module having one clear job. Comments are nearly all 'why' (Budget rationale, cookie age, migration rule, CSP hash, JS render invariants) with no noise. Weakest: handle's `filter p <> None ... Option.get (filter p)` double evaluation is clunky and hides intent; Store errors surface only as failwith exceptions caught by run_server's generic logger, and user_version's exec result is ignored; main.ml's Budget/connection plumbing and nested try blocks take effort to follow; a few terse names (change, shows, done_, go) need a moment.
Left after review
  • [correctness] `apps/ocaml/static/app.js:14` — Filter navigation can discard queued edits. With a toggle request pending, edit another item and click “Active”: blur queues the save, but the ordinary filter link immediately unloads the page before that save runs. The changed title is lost. Wait for pending mutations before navigating between filters.

Haskell

Live app
https://todo-haskell-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/haskell
Stack
warp and WAI, Lucid for HTML, and sqlite-simple.
Readability
Strongest: tracing a request is effortless. The toggle route goes from the pathInfo case through the `post` helper to Db.toggleTodo and back to Html.fragment, with the Bool-means-found contract stated at each end. Comments consistently explain why (WAL via query_, getrandom over /dev, single connection, the `swapping` flag) without restating code. Weakest: error handling is mostly implicit. Database exceptions reach Warp's default 500 with no logging; migrate silently assumes version 0 on an odd PRAGMA result; the getrandom loop retries on any negative result, not only EINTR; app.js hides every failure behind location.reload(). Minor: `app`'s where-block is long and the multiway-if in `post` is dense; `common`, `with`, `f`, `v` are terse names.

Swift

Live app
https://todo-swift-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/swift
Stack
Hummingbird 2 on SwiftNIO, with SQLiteNIO.
Readability
Strongest: traceability and comments. Toggling an item reads straight through: route in Routes.register, the shared `action` helper, Store.toggle's single RETURNING statement, then Page.body or a 303. Doc comments explain real decisions (one SQLite connection serialising writes, append-only migrations, 400-day cookie, nojs controls, the JS action queue) with almost no noise. Weakest: a few quiet fallbacks. Store.todos maps missing columns to 0/""/false instead of failing; app.js turns every failed action into a silent page reload; Assets is referenced but its definition (generated) isn't here, and Static.swift is an odd home for one helper. Helper names `action` and `changed` are generic, and the two trimmingWhitespace extensions live in unrelated files.
Left after review
  • [correctness] [apps/swift/static/app.js:32](/home/paulca/amadan.net/carlos/todomvc-bakeoff/.worktrees/swift/apps/swift/static/app.js:32): Every action response removes the entire list, including any active editor. Toggle one item, then start editing another before the response arrives: the replacement discards the draft and focus without saving. Serializing requests does not protect unsent edits. Preserve active editor contents and focus across updates, or defer replacement until editing finishes.

Nim

Live app
https://todo-nim-2.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/nim
Stack
Nim's standard-library async HTTP server (a patched copy, to serve a Unix socket and cap request bodies), with SQLite through db_connector.
Readability
Strongest: comments explain non-obvious decisions (maxBody sizing, trimTitle UTF-8 defect, chunked-body fix flagged in the vendored server, JS request queue and rendering flag) without noise; store.nim's prepared-statement enum plus bound/exec/count keeps SQL in one readable table; app.nim is cleanly sectioned. Weakest: `action` hand-parses path segments with len checks and a `found` flag, so the per-item verbs are harder to scan than a dispatch table would be; the `var r: Reply` side-channel for Set-Cookie headers in `listFor` is indirect; many `discard reset/finalize/close` and a stray `# make bump` comment sitting above the wrong constant. The vendored httpserver.nim is large but well-marked.
Left after review
  • [correctness] `apps/nim/src/store.nim:181` — `$column_text(st, 1)` reads a NUL-terminated C string, although titles are stored with an explicit byte length. Posting `title=before%00after` therefore displays only `before`; saving that edit permanently loses the suffix. Read the value using `column_bytes` so accepted Unicode titles survive retrieval intact.

Spinel

Live app
https://todo-spinel-4.bfj.oncarlos.com
Source
https://amadan.net/carlos/todomvc-bakeoff?ref=main&path=apps/spinel
Stack
tep, a Sinatra-style layer, compiled to a native program by Spinel, with SQLite 3.50 compiled in.
Readability
Strongest: traceability. Each route reads top to bottom: origin check, visitor, path_id, todo_exists, one inline SQL statement, finish_post, render_lists. Comments explain the non-obvious (Spinel subset, single-cursor shim, token-issuance check, BUILD_NOTE). Weakest: errors. Write paths (run_sql, add_todo, rename_todo, record_token) discard DB.step's result, so a failed INSERT or UPDATE silently returns a stale fragment; the JS catch-all reload hides failures too. Lesser points: parallel arrays with a `done` field of ints, generic run_sql with a magic `id > 0` bind rule, layout/page/path_id filed under the Actions divider, and unexplained constants (18 and 19 character limits).
Left after review
  • [correctness] `apps/spinel/app.rb:475` and `:527` — `strip` only removes ASCII whitespace. A title containing only U+00A0 or U+2003 creates a blank-looking todo, and editing to that value does not delete it. Apply Unicode whitespace trimming before the empty-title check.
  • [correctness] `apps/spinel/patches/2-tep-bakeoff.patch:330` — URL decoding turns `%00` into a NUL inside a C string, which is then truncated when copied into Ruby. Posting `title=before%00after` silently stores `before`; editing with `title=%00after` deletes the todo. Preserve decoded string lengths so input cannot silently become a different title or action.
  • [spec] `apps/spinel/Makefile:130` — `clean-deps` deletes only the local SQLite and tep artifacts. The arm64 build still links precompiled OpenSSL, zlib and zstd libraries from the shared toolchain root (`scripts/arm64-cc:21`), so the next build does not compile every third-party library from scratch as required. Build those dependencies into the app’s own cache and include them in `clean-deps`.
Note
Its server process exited 16 times during the load test, and Carloku started it again each time. Requests that arrived meanwhile got an error.

How we measured

What this does and doesn't show