Costs, tradeoffs & comparisons

Stack in layers states what is in the box and what each piece buys you. This page is the honest other half: for each choice, what was rejected, what it costs to keep, and the trigger that would make it lose. Nothing here is a claim that the alternative is bad — it is why it did not win for a single-binary, owned-code template.

Rule of the template: a choice earns its place by what it buys, not by what it beats. Read the benefits first, then this.

Language: Go (and not TypeScript / Python)

LLMs reach for TypeScript and Python by familiarity, not fitness — the same flywheel as React: TS grows on React/Next's back, Python on AI's (+7% in 2025). Both are defensible defaults elsewhere; here they lose on three structural points.

Rejected: Node (single thread, a build step per deploy, plus an install-time code-execution primitive — npm preinstall/postinstall hooks run on npm install before any import, the delivery mechanism behind the 2026 worm waves in keyv, axios, 140+ @mastra/* packages, and 639 @antv versions; Go has no equivalent hook) and Python (packaging converged with uv plus lockfiles in 2026, but the interpreter matrix and the GIL migration remain, and raw speed is an order of magnitude off).

Cost: a smaller hiring pool than TS, no ML/data ecosystem to speak of, slower raw single-core than C/Zig/Rust.

Revisit per workload: Python the day the app is ML/data-first, TypeScript the day the team or the product is JS-only and the npm ecosystem pays the binary's rent.

goqite + retry-go (and not Redis)

A queue without a service to run: jobs persist in the SQLite the app already owns, retries back off with jitter, and one binary stays one binary.

Rejected: Redis-style brokers (a second process to persist, secure, and operate) and JetStream as a job queue (it is the cross-instance broadcast, not a work queue with per-job retry semantics).

Cost: single-process throughput — depth and worker latency are bounded by this box. Revisit when profiles show the queue, not the work, as the bottleneck.

DagNats (and not Temporal)

Workflows as declarative JSON, embedded in the binary, state durable on the JetStream the template already runs. No cluster to install, no second surface to learn.

Rejected: Temporal (a server plus database plus SDKs for durability most apps never need at this scale).

Cost: bounded dynamism by design — conditions-as-data (skip_if), bounded agent loops, planner fragments under MaxSteps/MaxDepth/AllowedTasks (full table: dagnats). Revisit when a workflow needs unbounded iteration with no static bound, free-form expression evaluation the validators reject, dynamic topology beyond the planner bounds, multi-tenant operation (namespaces, per-team RBAC and audit), or a hosted offering. Anything below that is a new DagNats primitive or a worker-side decision, not a new engine.

JetStream on by default (and not opt-in)

Durable workflows, cross-instance broadcast, and collab sync all assume it, so on-by-default keeps every feature working out of the box — and one env var switches it off.

Cost: an embedded NATS server in every boot, its ports and storage in every deploy. Strictly single-instance with no workflows? NATS_ENABLED=false removes the cost entirely.

GoAI (and not the provider SDKs)

One injectable interface over any OpenAI-compatible provider, with a stub (internal/llm/fakeserver) that keeps real LLMs out of tests.

Rejected: one SDK per vendor (a dependency each, untestable without network).

Cost: the wrapper lags newest provider features and adds its own surface to learn. Revisit per provider when it blocks a capability you actually call.

Genkit (and not zenflow, and not raw goai calls)

Genkit is the typed-flow layer above the injectable client: schemas inferred from Go structs, progress streaming, per-step OTel spans, and a Dev UI with evals — with zero provider plugins, so generation still reaches providers only through llm.Client and the Biller choke point holds.

Raw goai calls stay right for single-shot generation (Suggest); the flow earns its ~16 MB when orchestration needs types, traces, and replayable runs.

Rejected: zenflow (stale upstream, pre-1.0 API, YAML + coordinator overhead per run) and provider plugins / compat_oai (a second provider path around billing).

Cost: a second mental model plus retry/billing discipline — Genkit retry and billing middlewares stay off, goai's native retry is the single layer. Revisit if a second AI feature does not need flows: unused orchestration is dead weight no matter the megabytes.

Classifier judge (and not a generative judge)

The review loop's judge is a decision API (Jev-compatible), not a chat completion: typed choices with confidence and probabilities instead of a prose verdict that needs parsing — ~100x cheaper per call, and more accurate exactly where the demo lives (prose; classifier judges trail on math/code/logic, which is why the code tab is second). Any server speaking POST {state,model,questions} works (TypeSafe, self-hosted SimpleJev, tensai) via JEV_* env.

Cost: one more backend to configure and calibrate (~20 hand-labelled cases before trusting auto-accept). Revisit per workload: a rubric a classifier cannot express (open-ended reasoning quality) wants a generative judge behind the same terminal step — the flow does not care which backend answers.

Generative UI catalog (and not a React GenUI SDK)

The model picks components from a fixed registry — text_note, plan_cards, data_table, bar_chart, action_buttons, nestable sections — and the server renders them as Templ over SSE: no client framework, no JS bundle, trimmable like every feature.

Loro, server-owned (and not Yjs / Automerge / OT)

The merge engine with the smallest client footprint: none. The server owns the Doc, browsers POST ops and render the JSON broadcast back — no JS CRDT library, no npm build step — and offline edits still merge without last-write-wins.

Rejected: Yjs in the browser (a second CRDT system doing Loro's job, plus a bundler, plus a sync protocol that fits websockets better than the SSE + NATS transport here); Automerge (same overlap, same cost); OT in the style of the Datastar collab demo (server-serialized, plain text only, no offline merge).

Cost: every keystroke round-trips — no client-local editing — and the rich-text editor binding (cursor mapping, marks) is future work, as a feature package reusing internal/collab, never a new plugin.

age + ~/.secrets/ (and not Vault / KMS)

Secrets as encrypted files, decrypted at boot: zero services, zero bills, zero IAM.

Rejected: Vault / cloud KMS (a service to run and pay for, so a single box can hold its own secrets).

Cost: no rotation story, and distribution across instances is manual. Revisit when secrets outgrow one box or need audit trails.

Wails + gogpu/ui (and not Electron / Tauri)

Desktop shells over the same backend: Wails for a webview shell with offline edge sync, gogpu/ui for a pure-Go window with no webview and no HTTP at all.

Rejected: Electron (a Chromium per app, its own update and CVE surface).

Cost: the platform matrix and native debugging stay yours. Both are delete-with-the-package features, never core.

30+ linters (and not go vet alone)

Tuned for agent-written code: unchecked errors, leaked contexts, unclosed bodies, weak crypto, template-signal mistakes — enforced in pre-commit hooks and CI, taught upfront via the coding-standards skill.

Cost: CI minutes and a //nolint discipline curve. Revisit by deleting rules that never fire, not by adding more.

Database: Turso, and the server databases it replaces

The data layer is embedded SQLite at the file level, spoken through sqlc-generated queries. There is no database server to install, connect to, or pay for. The choice is really three-way — plain SQLite, Turso Database (the engine the app runs on), or a server database — so each is compared on its own terms.

Turso Database (and not plain SQLite, and not Postgres/Supabase/Firebase)

Turso is not "SQLite". Turso Database is the ground-up Rust rewrite of SQLite, driven from Go by turso.tech/database/tursogo (purego, no CGO). Same file format and SQL dialect, so tooling and migrations carry over — but a different engine underneath, with its own roadmap (MVCC for concurrent writers, per-tenant databases). The repo also opens plain SQLite files (through ncruces/go-sqlite3) on the paths that do not need Turso; see Why ncruces?.

So the honest comparison is:

Rejected: assembling it yourself (router + SQL + session auth + an admin you write and secure) and outsourcing it (Supabase/Firebase).

Turso Database: the cost, stated plainly

tursogo is a database/sql driver (sql.Open("turso", path)) loaded through purego, so there is no CGO and the cgo-free cross-compilation story holds. Three things are worth knowing before this becomes the only engine:

Scaling embedded SQLite — the honest ladder

The stance is single-server vertical: 10k+ persistent realtime connections on a $4 VPS, a generous busy_timeout against a serialized writer with built-in database is locked retries. That covers small to midsize SaaS without trying. Past that, in order:

  1. Disaster recovery — Litestream streams WAL to S3-compatible storage. Restores in seconds; no app changes.
  2. Read distribution — libsql embedded replicas serve reads from a local file at local-disk speed while writes forward to a primary. Caveat that kills it for us today: embedded mode needs CGO, which conflicts with the cgo-free ncruces stance that keeps cross-compilation clean.
  3. Community HA — NATS-replicated SQLite (last-writer-wins or static leader), libsql primary-replica topologies, Marmot+NATS multi-primary. All third-party, all with the same asterisk: the record-event fan-out is evaluated in-process, so it does not sync across nodes the way rows do. Leader-for-writes is the only consistent topology.
  4. Graduation — a Postgres migration (the sqlc queries are portable SQL; auth, sessions and realtime are already ours, so there is no framework surface to replace). That is a project, not a flag flip — which is why the trigger is measured write load, not anxiety.

Write-heavy-global (payments, bidding, telemetry at planetary scale) skips the ladder and starts at Postgres. Everything else should have to prove it needs to leave.

Cloud counterparts, if the box ever stops being enough

Turso (embedded replicas, per-tenant databases, MVCC concurrent writes still in beta — not a bet this quarter), Cloudflare D1 (zero-config inside Workers, ~10 GB ceiling, Workers-only), Supabase Postgres (the graduation destination, now with Turso inside it). None of them change the default: the app runs whole on one box with zero cloud dependencies until the ladder above says otherwise.

Routers: the stdlib, and that ends the debate

The stack routes on a thin composition over Go's net/http.ServeMux. The famous alternatives beat it on microbenchmarks:

None of it matters here: routing is microseconds against milliseconds of SQLite, and the SQLite wisdom holds exactly — ignore artificial benchmarks, apps bottleneck on DB operations. There is no router upgrade that moves our numbers; the day routing shows up in a profile, the database has already been the problem for months.

Datastar (and not htmx, and not a JS framework)

Datastar's own guide puts it plainly: backend reactivity like htmx plus frontend reactivity like Alpine, with no npm packages. Signals hold state, the backend drives DOM patches, and SSE streaming is a first-class primitive — unlike either. htmx overlap is real and acknowledged: simple hyperlink-style swaps stay simpler in htmx.

Rejected: SPA frameworks (a bundler, hydration, a second source of truth in the client) for the default path.

Cost: a younger, smaller ecosystem than React; persistent connections your proxies must allow; more logic lives in the backend by design. The repo pays down the newness with datastar-lint and the internal/datastar wrapper.

React/Next: the flywheel is real, and so are its costs

State of React 2025 names the mechanism honestly: React's ecosystem mass means more training data, which makes AI better at React, which brings more developers, which makes more data (React ~47%, Next.js ~21% in the 2025 Stack Overflow survey). An LLM reaching for Next.js is usually reaching for familiarity, not fitness — which is exactly why it should not decide the stack.

The costs are structural, converging across independent 2026 production reports:

Datastar is 11.29 KiB full (~5 KiB core): signals, computed, effects, and SSE patchElements/patchSignals — everything realtime needs, no npm, no build step, no second source of truth to drift.

Demo features shipped (and not a bare core)

todo, whiteboard, room, notes ship as working pages — not because every app needs a todo list, but because patterns teach better than prose: todo is the reference MVC, whiteboard the CRDT canvas, room the actor pattern, notes the collaborative text. The AI demos follow the same rule: Ask AI proves constrained generation, chat proves persisted history, review proves a typed multi-role loop with a classifier judge.

Rejected: a minimal core with nothing to copy (every scaffold would re-derive the same wiring, badly).

Cost: scaffold weight and trim burden — each demo is a unit the installer offers to strip (whiteboard, goakt, landing, config-view, credits, sounds, skins, review, genui, dagnats) or a documented manual removal (todo, notes), and every demo rots if its pattern drifts from the core. Revisit per demo: anything nobody copies in real scaffolds is a deletion candidate, not heritage.

The flat stack — the cost

Architecture decision argues the benefits. The cost, stated plainly: we own every line above (auth, schema, realtime fan-out), Turso is pre-1.0 with heavyweight platform libs, and there is no admin UI, generic REST, or file storage to lean on.

Revisit if owning the commodity parts costs more than the opinions they replaced — the day the team wants a database GUI, a generic REST surface, or file uploads without writing them.

PocketBase is rejected on a different axis: cmd/check-flatdeps bans it by module prefix because it is a framework surface, not a database choice — an embedded router, auth, and admin UI that would replace the owned seams (chi composition, argon2id sessions, age secrets) instead of the data layer. Swapping the store keeps every seam; adopting PocketBase deletes them. That is why the ban is mechanical (import path) rather than advisory.