-
supabase startruns the production architecture on your laptop: 12 containers, 2,291 MB on disk, 1,626 MB RAM under load - Those services are mostly protocol translators — and protocols can be reimplemented
- We rebuilt the surface as one process: pure
(Request) ⇒ Responsehandlers on a swappable Postgres engine - Result: a 58 MB executable, ~2 s cold start, and the official supabase-js SDK passing 168/168 integration tests unchanged
- Side effect: the same backend runs inside a browser tab
Part 1: Where the 2.3 GB goes
Run supabase start on a fresh machine and watch docker ps fill up. What you're looking at is Supabase's cloud architecture, faithfully reproduced locally — which is precisely the problem. Each box in their production diagram becomes a container on your laptop:
┌────────────────────────────────────────────────┐
│ supabase start │
│ │
│ kong → API gateway / routing │
│ postgrest → REST API over Postgres │
│ gotrue (auth) → JWTs, OAuth, magic links │
│ realtime → WebSockets (Elixir/Phoenix) │
│ storage-api → file storage over Postgres │
│ imgproxy → image transforms │
│ edge-runtime → Deno functions │
│ studio → dashboard (Next.js) │
│ postgres-meta → introspection API for Studio │
│ logflare → log aggregation │
│ vector → log shipping │
│ db (postgres) → the actual database │
└────────────────────────────────────────────────┘
Each service ships as its own image with its own runtime — an Elixir VM here, a Deno runtime there, Node for Studio, Go for auth. None of it is waste in production: these are independently scalable services doing real jobs across a fleet.
But locally, every one of those runtimes is overhead for a single developer making requests from localhost. The measured cost:
| Install footprint | Memory under load | |
|---|---|---|
| Supabase local (12 containers) | 2,291 MB | 1,626 MB |
| tinbase (single binary) | 92 MB | 66 MB |
| tinbase (native, via npx) | 36 MB | 100 MB |
And that's before the Docker Desktop tax on macOS and Windows, or environments where Docker isn't available at all: CI sandboxes, cloud IDEs, school machines, a phone.
Part 2: The key observation — it's protocols all the way down
Here's what makes the whole thing tractable. Your app never talks to those containers directly. It talks to documented HTTP and WebSocket protocols through supabase-js:
-
/rest/v1speaks the PostgREST grammar —?select=*,author:users(name)&done=eq.false -
/auth/v1speaks GoTrue's API — signup, OTP, PKCE, refresh tokens -
/storage/v1speaks the Storage API - Realtime speaks the Phoenix channel protocol over WebSockets
-
/functions/v1invokes edge functions
A protocol doesn't care what implements it. PostgREST is ~"take this URL grammar, compile it to SQL, run it with the caller's JWT claims applied." GoTrue is "manage users in auth.users, mint JWTs." These are translation layers over Postgres — and translation layers can be reimplemented in-process.
What you cannot fake is Postgres itself. RLS evaluation, auth.uid() in policies, jsonb operators, triggers, ON DELETE CASCADE — behavior differences there produce the worst kind of bug: works locally, breaks in production. So that became the design rule:
Reimplement the translation layers. Never reimplement Postgres.
Part 3: The architecture — one fetch handler
Every tinbase service is a pure function: (Request) ⇒ Response. No sockets owned, no ports assumed, no filesystem requirements baked in. The whole backend composes into a single handler:
supabase-js (unmodified)
│
▼
one (Request) ⇒ Response handler
├─ /rest/v1 → PostgREST grammar → SQL
├─ /auth/v1 → GoTrue flows → auth schema
├─ /storage/v1 → Storage API
├─ Realtime → Phoenix protocol
├─ /functions/v1 → in-process handlers
└─ /_/ → Studio dashboard
│
▼
DbEngine adapter
├─ native → embedded Postgres 17
├─ wasm → PGlite (Postgres compiled to WASM)
└─ pg-mem → pure JS, in-memory (subset)
The REST layer parses the PostgREST grammar and compiles it to parameterized SQL — embedded resources become joins, filters become WHERE clauses — then executes with the request's JWT claims set, so your RLS policies run exactly as Postgres intends:
-- every request runs with claims applied, e.g.:
SET LOCAL request.jwt.claims = '{"sub":"<user-uuid>","role":"authenticated"}';
-- so this policy Just Works, locally and in prod:
CREATE POLICY "own todos" ON todos
FOR SELECT USING (auth.uid() = user_id);
Realtime does the same per-subscriber: change events are filtered through RLS before delivery, so a user only receives events for rows they're allowed to see.
Things production Supabase does with Postgres extensions get reimplemented natively instead, because a single process can just... do them:
- database webhooks — no
pg_net -
cron.schedule()— nopg_cron - a pgmq subset — no
pgmq
Part 4: The consequences of "pure fetch handler"
This constraint looked academic. It turned out to be the most productive decision in the project.
In Node, the handler binds to a port and becomes an ordinary HTTP + WebSocket server:
npx tinbase start
# ~2 seconds → http://127.0.0.1:54321, Studio at /_/
As a binary, it compiles to a single 58 MB executable — no Node, npm, or Docker on the target machine. That's the CI story: download one file, run it, test against real Postgres.
In a browser, you hand the same handler to supabase-js as a custom fetch:
import { createClient } from '@supabase/supabase-js'
import { createTinbase } from 'tinbase'
const tin = await createTinbase({ engine: 'wasm' }) // PGlite under the hood
const supabase = createClient('http://tinbase.local', ANON_KEY, {
global: { fetch: tin.fetch } // ← the entire backend is this function
})
// no server anywhere; Postgres is running inside the tab
await supabase.from('todos').insert({ title: 'hello from a browser tab' })
Database included, running in-process. No server, no cloud. (Live demo: tinbase.dev/browser.)
We didn't set out to build a curiosity — this was the original requirement. tinbase came out of building RapidNative, where we needed a full dev stack — database, auth, storage, realtime — running in browsers and on phones with no VMs behind it. The single-process backend fell out of that constraint; replacing local Docker was the happy accident.
Part 5: How we know it's compatible
Claiming "Supabase-compatible" is easy. Our test is blunt: run the official supabase-js SDK's integration suite against tinbase, unmodified.
Current score: 168/168 passing, across both the native Postgres engine and the WASM engine. That covers the REST grammar (filters, embedded resources, RPC), the auth flows (email/password, anonymous, OTP, magic links, recovery, OAuth with PKCE), storage, and realtime's postgres_changes / broadcast / presence.
Migration compatibility is the other half. tinbase reads supabase/migrations/*.sql and seed.sql exactly like the Supabase CLI, tracked in the same table — so the exit path is: push the same files to hosted Supabase and keep going. Compatibility that doesn't include leaving isn't compatibility.
What this doesn't claim
tinbase is alpha (v0.10.0), and honesty is cheaper than a disappointed issue tracker:
- Not production-ready. It's for local dev, prototypes, and embedded/browser use.
- Not 100% of the surface. It covers the common paths (the test suite defines "common"); edges remain.
- Not a Supabase replacement. It's the local half of a workflow that ends at hosted Supabase. Real Postgres in production is still the right call.
- pg-mem is a subset. The pure-JS engine trades fidelity for zero-WASM environments; use native or PGlite when you can.
Try the numbers yourself
npx tinbase start
Repo (MIT): github.com/tinbase/tinbase — the benchmark methodology is on the site if you want to reproduce the table above.
If you've solved local-Supabase pain a different way — Nix, compose tricks, pg_regress heroics — I genuinely want to hear it in the comments. And if you try the browser engine, tell me what you build with it.
Top comments (0)