All notes

I started on Supabase. Now almost everything I build runs on Cloudflare.


When I started building side projects, the stack picked itself. I was on Vercel, Vercel has a one-click Supabase integration, and a few clicks later I had a Postgres database, authentication, storage, and an auto-generated API. For a beginner that’s incredible. You go from “idea” to “users can sign up and save data” in an afternoon, without ever thinking about what’s running underneath.

I don’t build that way anymore. If you look at my projects folder today, almost all of them run on Cloudflare — D1, R2, KV, Durable Objects, Workers AI. Exactly one still has a Supabase setup. This post is me trying to explain that drift honestly: what Supabase gets right, why I kept reaching for Cloudflare instead, and the concrete differences in architecture, performance, and cost — using apps I actually shipped.

Supabase is convenient. Maybe too convenient.

Supabase’s pitch is real: it’s “everything you need to take an idea public,” and it delivers. Postgres, auth, row-level security, storage, realtime, edge functions — all wired together on day one. You’re not integrating five services, you’re flipping switches.

But after a few projects, the convenience started to feel like weight. The honest question I kept landing on was: do I actually need any of this?

A typical side project of mine has a handful of tables and a few dozen columns. It’s read-heavy, write-light, and will be thrilled to ever see a thousand users. For that, a full Postgres instance — with its query planner, connection pooler, extensions, and a dedicated auth server (GoTrue), a PostgREST server, a realtime server, and a storage API all provisioned alongside it — is a lot of machinery to rent for what is, functionally, a small list of rows.

Do you even need Postgres?

This is the part most “vs.” posts skip. For a lot of personal projects, SQLite is not a compromise — it’s the right answer. Cloudflare’s D1 is SQLite, and for the access patterns of a small app it’s often faster end to end, for a reason that has nothing to do with the database engine.

Here’s the trap. Supabase Edge Functions run globally, but your Postgres database lives in one region. So if your function is at the edge in Singapore and your database is in us-east, every query pays a transcontinental round trip — easily 150–250 ms — before Postgres does any work. And here’s the thing people miss:

An index fixes scan time. It does nothing for the round trip.

You can CREATE INDEX all day; you cannot index your way out of the speed of light. For a small app, the network hop to a single-region Postgres is the real latency, not the query. D1 (and Durable Objects) put the data next to the compute, at the edge, so that hop mostly disappears.

And at small scale, a lot of “serious database” concerns just evaporate. With a few dozen rows in a Durable Object I’m not reasoning about connection pools, transaction isolation levels, lock contention, or whether position needs an index. There’s one writer and a tiny amount of data. The whole apparatus you’d carefully tune in Postgres is solving problems I don’t have yet.

The “land fast, migrate later” story is backwards

Supabase feels designed to land an idea quickly and graduate to production when things get demanding. In my experience it’s the opposite — the friction shows up early, on exactly the small/experimental projects it’s supposed to be best for:

  • Idle projects pause. Leave a free project alone for about a week and it gets paused. For something I poke at occasionally, that’s a cold-start-the-whole-database speed bump every time I come back.
  • You get very few free projects. The free plan covers two active projects (last I checked). I start a lot of things. Two is not many.
  • Even a trivial database provisions the whole stack. Want a tiny key-value-ish table? Supabase still spins up a full Postgres instance and its surrounding services. That’s slower to stand up and more surface area to maintain than I want for an experiment.

Cloudflare’s primitives go the other way: they scale to zero, cost nothing when idle, and nothing pauses. A D1 database or a KV namespace is just there — no instance to wake up.

Under the hood: runtimes and cost

The platforms run on genuinely different architectures, and you feel it.

Supabase Edge Functions are Deno-based, running on Supabase’s managed infrastructure. Cloudflare Workers run as V8 isolates on Cloudflare’s edge — lightweight, with effectively no cold start, in far more locations. For a globally-hit endpoint, Workers are simply closer to the user and start faster. That’s not a knock on Deno; it’s that edge compute is Cloudflare’s core product, and it shows.

Then there’s cost, which for a personal project is the whole game. The free tiers aren’t shaped the same way:

Supabase (free)Cloudflare (free)
Compute / requestsEdge Functions ~500k invocations/moWorkers 100k requests per day (~3M/mo)
What actually limits youDB size (~500 MB), egress (~5 GB), monthly active usersMostly daily request count + CPU time
Realtime / WebSocketsRealtime with connection + message capsWebSockets via Durable Objects, included
Object storage~1 GB, egress meteredR2: ~10 GB, zero egress fees
Idle behaviorProjects pause after ~1 weekNothing pauses; scales to zero
Entry paid planPro ~$25/mo per orgWorkers Paid ~$5/mo

(Numbers are early-2026 ballpark — confirm on the pricing pages before you rely on them.)

Two lines matter most to me:

  1. 100k requests per day (Cloudflare) versus a much tighter monthly envelope on Supabase’s free tier. For a hobby project that’s the difference between “never think about it” and “watch the meter.”
  2. Zero egress on R2. This is the one that quietly decides everything for media apps. Serving images and video is where metered-bandwidth bills explode. R2 charges nothing to serve data out — full stop.

That second point is the whole story for a media app, and it’s easier to feel as a curve than a table. What drives the bill isn’t time — it’s usage. Switch between the cost drivers below and drag across each to read the gap at any level of use:

Monthly cost vs. data served (illustrative)
Supabase Cloudflare
Illustrative, not a quote. The more media you serve, the more Supabase's metered egress adds to the bill; R2's zero-egress keeps Cloudflare nearly flat. Confirm real numbers on the pricing pages.

In every view the shape is the same: the two lines start close, then one keeps climbing with usage while the other stays nearly flat. That gap — egress, invocations, or latency under load — is the whole argument.

To be fair: Supabase is not nothing here. It now has Queues (pgmq-based) and Cron, and pgvector for embeddings is genuinely nice if your data already lives in Postgres. But it doesn’t have a containers product, native email sending, a CDN/DNS/WAF layer, or a CAPTCHA primitive — and Cloudflare has all of those.

What changed for me in 2025–2026

For a while, Supabase was honestly the smarter pick — not because of the database, but because of developer experience. Cloudflare’s primitives were powerful but you assembled everything yourself: configure bindings in the dashboard, wrestle with Wrangler, glue services together by hand. Supabase handed you a coherent backend; Cloudflare handed you a parts bin.

Two things flipped that:

  1. The platform matured into one place. I can now get nearly everything from Cloudflare without leaving: Workers, D1, KV, R2, Durable Objects, Queues, Workflows, Containers, Email, Workers AI, Vectorize, and Turnstile for bot protection. (Cloudflare in 2026 is a lot more than “the big DNS company.”) Supabase has no equivalent to most of that list.
  2. AI assistants got good at the glue. The Wrangler config and binding boilerplate that used to be the tax on choosing Cloudflare is now the part a coding assistant handles in seconds. The DX gap that made Supabase the pragmatic choice mostly closed.

Real example #1 — CoupleNote (a private, media-heavy app)

CoupleNote is a small iOS app for two people to share notes and photos. Here’s how the two stacks compare for it.

If I’d built it the Supabase way:

  • Supabase Auth for login
  • Postgres tables for notes and media metadata
  • Supabase Storage for the photos (egress metered every time either partner reloads)
  • Supabase Realtime to sync changes
  • An Edge Function to send the push notification

What I actually shipped on Cloudflare is just a wrangler.toml of bindings — here it is next to the Postgres schema I’d have written on Supabase instead:

wrangler.tomltoml
1main = "src/index.ts"23[[d1_databases]]4binding = "DB"5database_name = "couplenote"67[[r2_buckets]]8binding = "MEDIA"9bucket_name = "couplenote-media"1011[[kv_namespaces]]12binding = "OAUTH_KV"1314[[durable_objects.bindings]]15name = "ROOM"16class_name = "CoupleRoom"
schema.sqlsql
1create table notes (2  id uuid primary key default gen_random_uuid(),3  couple_id uuid references couples(id),4  body text not null,5  created_at timestamptz default now()6);78create table media (9  id uuid primary key default gen_random_uuid(),10  couple_id uuid references couples(id),11  storage_path text not null12);1314alter table notes enable row level security;15alter table media enable row level security;
ConcernSupabase versionWhat I shipped on Cloudflare
AuthSupabase Auth (GoTrue)OAuth in a Worker, sessions in KV
DataPostgres (single region)D1, edge-local
PhotosSupabase Storage, egress meteredR2, zero egress
SyncSupabase RealtimeA Durable Object per couple
PushEdge Function → APNsWorker → APNs (key in Secrets Store)
Cost at my scale$0 until it pauses, then ~$25/mo~$0–$5/mo, never pauses

The R2 line is the quiet winner. Two people opening the same photos over and over is pure egress, and on R2 that’s free. The Durable Object also means each couple’s live state is one tiny, consistent, edge-local object — no shared realtime cluster to pay for or reason about.

Real example #2 — Lineup (a live waitlist), where Durable Objects shine

Lineup is a virtual queue for venues: people join from their phones and watch their position update live. This is the case that made Durable Objects click for me. The whole live queue for one venue is a single DO backed by SQLite:

wrangler.jsoncjsonc
1{2  "name": "lineawait",3  "durable_objects": {4    "bindings": [{ "name": "QUEUE", "class_name": "QueueRoom" }]5  },6  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["QueueRoom"] }],7  "d1_databases": [{ "binding": "DB", "database_name": "lineawait" }]8}

On Supabase I’d model this as a queue_entries table and broadcast changes through Realtime. It works — but every position change is a DB write plus a fan-out through a shared realtime service, and I’m back to thinking about indexes, write contention, and ordering under bursts.

With a Durable Object, the venue’s queue is the object. It’s single-threaded, so it’s the sole authority over its own state — ordering is correct by construction, no locks, no isolation levels, and I genuinely don’t care whether position is indexed because it’s a short in-memory list with one writer. It holds the WebSocket connections itself and pushes updates straight to every waiting phone, edge-local, with no trip to a central region. Roughly:

QueueRoom.tsts
1export class QueueRoom extends DurableObject {2  async join(name: string) {3    const pos = this.queue.push(name);       // one writer — no lock needed4    this.broadcast({ type: "update", queue: this.queue });5    return pos;                               // strongly consistent6  }7  // WebSockets live on the object; no separate realtime cluster to bill.8}

The performance and cost story falls out of the architecture: no per-message realtime billing, no central-region round trip, and the consistency I’d otherwise engineer in Postgres is just how a DO works.

Where the client work points the same way

The same logic shows up in the studio’s project work. The construction app indexing jobsite photos and the auto-service platform where mechanics upload 15-second repair videos that customers stream from an SMS link — those are egress machines. On metered storage the bandwidth bill is the project’s biggest variable; on R2 it’s zero. And the online academy’s video streaming + AI tutor + analytics maps cleanly onto Cloudflare Stream + Workers AI + Vectorize without adding a second vendor for the AI parts.

When I’d still pick Supabase

This isn’t “Cloudflare always.” Supabase is the better starting point when:

  • Auth is the hard part. If I need email/password, social login, MFA, and row-level security yesterday, Supabase ships it as a solved problem. On Cloudflare I’m assembling it.
  • I genuinely need Postgres. Complex joins, real transactions, extensions, analytical queries — that’s Postgres’s home, and D1/SQLite isn’t the tool.
  • I think in SQL and want a managed database with a great dashboard, instant REST/GraphQL, and the option to self-host later because my data is “just Postgres.” That portability is a real advantage.

Tellingly, my one remaining Supabase project is a BI chatbot — auth plus a real relational schema plus SQL-heavy querying. Exactly the shape Supabase is best at.

Where I’ve landed

If your project is fundamentally users-and-their-data with real relational needs and auth, start on Supabase — it’ll get you there faster and it’s the right tool for that shape. If it’s code, content, media, or realtime delivered fast and cheap to a global audience, Cloudflare is hard to beat, and the old DX excuse for avoiding it is gone.

For me, most of what I build is the second kind, so most of it lives on Cloudflare now. And when something does need a real Postgres, the nice part is I don’t have to choose sides: I can still put Cloudflare in front for DNS, caching, Turnstile, and Workers, and let Supabase be the database behind it.


Back to the journal