Skip to content
available for projects000%
All writing
ENTRY-001Infrastructure7 August 2026 · 8 min read

SER-03 · SER-03.01 · SaaS Systems: Architecture, Cost and Simplicity

Why does a SaaS Supabase bill get out of hand?

Blaming Supabase is easy. But the bill was a symptom: the real question was why, when and how much my app was talking to the backend.

Blaming Supabase is easy.

When a higher-than-expected bill arrives at the end of the month, the first reflex is usually to decide the service is expensive. My first reaction was close to that too.

As Ordovia grew, Supabase usage climbed to roughly 2.6 million requests per month. Then a Supabase bill of about $62 landed. On its own, $62 is not dramatic. But the number was never the point.

Why was a product without a large user base yet generating that much backend traffic?

That question mattered far more than Supabase's pricing, because the answer lived in the product's architecture.

Was Supabase expensive, or were we simply talking too much?

One of the biggest advantages of modern backend services is development speed. You want authentication: there it is. PostgreSQL: there it is. Realtime: there it is. Storage, edge functions, row level security, API — all a few clicks away.

That remarkable convenience creates a small danger: you stop feeling the cost of talking to the backend. If you were writing a REST API from scratch you would probably design each endpoint more carefully. But this is very easy to write:

ts
const { data } = await supabase  .from("tasks")  .select("*")  .eq("user_id", user.id);

One query. What could go wrong? The problem is not one query. The problem shows up in:

  • how many components run it,
  • how many times it repeats,
  • how many users call it,
  • whether it keeps running in hidden tabs,
  • how often the same data is requested again by different contexts.

The cost of a SaaS usually comes from hundreds of small innocent decisions, not from one large architectural mistake.

First big mistake: accepting polling as normal

When you want real-time data you have options. Realtime subscriptions. An event-driven architecture. Caching. Or you can ask the backend at intervals: did anything change?

That last one is polling. It is simple, it works, and unchecked it grows fast.

ts
setInterval(async () => {  await refreshTasks();}, 30_000);

It looks innocent. One query every 30 seconds. For a single user:

txt
2 requests / minute120 requests / hour2,880 requests / day86,400 requests / month

And that is only one polling loop. If tasks, calendar, habits, notifications and other domains each poll separately, the number multiplies.

The user does not even have to be using the app. Tab open. Phone in the background. Desktop app running. Polling continues.

“Cloud-first” does not mean “pull everything from the cloud constantly”

Ordovia became cloud-first over time, for good reasons: I wanted a consistent experience across phone, web and desktop. A task created on one device had to appear on another. The calendar had to stay current. Notes had to sync.

But there is an important distinction here:

ts
cloudFirst !== cloudEverySecond
Cloud-first: the cloud is the source of truth. Cloud-every-second: keep asking the backend whether the user did something.

They are not the same. When I design a system today I ask this first:

ts
type SyncQuestion = {  changed: boolean;  reason: "user_action" | "remote_event" | "visibility" | "scheduled_refresh";};

Why is this data being fetched again? Is there a real chance it changed? Or are we just asking in case it might have?

Second mistake: mistaking component lifecycle for a data strategy

There is a mistake that is very easy to make in React. A component mounts:

ts
useEffect(() => {  loadData();}, []);

Then another component does the same. Then another route. Then a context. Then a modal. All of them may be querying the same table.

Components that look independent in the UI are not independent to the backend. If they all want the same user data, they are all consumers of the same data domain. So data access should be centralised as much as possible.

Bad

ts
function TodayTasks() {  useEffect(() => {    fetchTasks();  }, []);}
function UpcomingTasks() {  useEffect(() => {    fetchTasks();  }, []);}
function TaskSidebar() {  useEffect(() => {    fetchTasks();  }, []);}

Better

ts
const tasks = useTasksStore();

with the store:

ts
const tasksStore = {  data: [],  lastFetchedAt: null,  stale: true,};

The question shifts from “should this component fetch?” to “is this data actually stale right now?” That small mental change can move backend traffic significantly.

Third mistake: treating cache as only a performance optimisation

Cache is usually explained as “make the page open faster”. True, but incomplete. Cache is also a cost control mechanism. If a user asks again for data they received ten seconds ago, you may not need to go to the backend at all.

Even a simple stale time makes a difference.

ts
const CACHE_TTL = 5 * 60 * 1000;
function shouldRefresh(lastFetchedAt: number) {  return Date.now() - lastFetchedAt > CACHE_TTL;}

Five minutes is not right for every kind of data, but the principle matters. Every data domain should answer these four questions:

ts
interface CachePolicy {  sourceOfTruth: "cloud" | "local";  staleAfter: number;  invalidateOn: string[];  refreshOnVisibility: boolean;}

If you define none of them, the app naturally drifts toward this model:

ts
await fetchEverything();

Fourth mistake: refetching everything on every change

A task is completed. The UI behaves like this:

ts
await completeTask(taskId);await refetchTasks();await refetchProjects();await refetchStats();await refetchToday();await refetchAchievements();

One user action, six backend calls. Later the XP system arrives. Then the calendar. Then notifications. The system cost of completing a task grows without anyone noticing.

Instead, model the effect of the mutation explicitly.

ts
type TaskMutationEffect = {  task: true;  project?: true;  stats?: true;  rewards?: true;};

Then only the required domains are invalidated. Better still, return the updated canonical state from the server where possible.

Fifth mistake: letting an invisible app keep working

A browser tab can sit in the background. A PWA can be minimised. A desktop app can stay open for hours. So this check should not be underestimated:

ts
if (document.visibilityState !== "visible") {  return;}

It does not solve every polling loop, but it can sharply reduce moving data for a UI nobody is looking at. In apps like Ordovia, which stay open for long stretches, these details matter more and more.

A cost problem is really an observability problem

My real trouble was not the bill; the bill was only a symptom. The real problem was that I did not know, visibly enough, which app behaviour produced how many requests.

As a product grows, these are the metrics to watch:

ts
interface BackendTelemetry {  requestsPerUser: number;  requestsPerSession: number;  requestsPerRoute: number;  requestsPerFeature: number;  cacheHitRate: number;  mutationCount: number;  realtimeConnections: number;}

“We made 2 million requests this month” is not enough information on its own. The real question is why. If 1.5 million of those 2 million come from one visibility polling system, the fix is obvious. But if you do not measure, you only guess.

Should I leave Supabase?

That was not my conclusion. When an infrastructure cost rises, switching services feels very attractive. Supabase expensive? Move to Firebase. Firebase expensive? Let's do Cloudflare D1. Then something else.

At that point I realised that changing technology sometimes moves architectural debt elsewhere instead of solving the real problem.

ts
badArchitecture * cheaperInfrastructure

can still be a bad system — and once you add the migration cost, an even more expensive one. So today I follow three steps first:

ts
const optimizationOrder = [  "measure",  "fix architecture",  "then evaluate infrastructure",];

Measure first. Then cut unnecessary consumption. Only then evaluate pricing.

Why Cloudflare interests me here

What appeals to me about Cloudflare is not only that it is cheap. If significant parts of the app already run on Cloudflare, these can sit on one operational surface:

  • edge caching,
  • Workers,
  • Pages,
  • R2,
  • various API layers.

But the same principle applies:

Infrastructure should be used to simplify, not to create a new hobby project.

Rewriting a working Supabase system into D1 + Workers + custom auth to save a few dollars can very easily stop being an economic optimisation and become engineering entertainment. I would rather not fall into that trap.

What would I do if I built the same system today?

I would define a sync policy per domain from day one. For example:

ts
export const syncPolicies = {  tasks: {    staleAfter: 5 * MINUTE,    refreshOnFocus: true,    realtime: true,  },  calendar: {    staleAfter: 10 * MINUTE,    refreshOnFocus: true,    realtime: false,  },  profile: {    staleAfter: 60 * MINUTE,    refreshOnFocus: false,    realtime: false,  },} satisfies Record<string, SyncPolicy>;

Then I would route the whole network layer through those policies. I would not let components talk to the database directly. And from the first week I would make this metric visible:

txt
requests / active user / day

Because as user count grows, total request count is simply a consequence of it.

The most important lesson

My Supabase bill taught me very little about Supabase. It taught me something about my own product:

Complexity you hide from the user does not disappear. It comes back somewhere as cost.

Sometimes that cost is UX. Sometimes technical debt. Sometimes latency. And sometimes it really is the bill.

The speed modern platforms provide is genuinely valuable. I still believe tools like Supabase give small teams and independent developers extraordinary leverage. But leverage works both ways: it accelerates a good architecture and it accelerates the cost of a bad one.

So when I choose a backend today, my first question is not “which one is cheaper?” It is: “why, when and how much will my app talk to this backend?” Most of the time, that is where the real answer to the bill lives.

  • Supabase
  • SaaS
  • Backend
  • Cloudflare
  • Performans
  • Sistem Mimarisi

SER-03 · SER-03.01

SaaS Systems: Architecture, Cost and Simplicity

Field notes and decision frameworks connecting backend cost, cloud choices and product complexity as one systems problem.

AVAILABLE FOR PROJECTS · PRODUCT & SYSTEMS ARCHITECT · TAKEOVER / STABILIZE / OPERATE · WEB · ANDROID · WINDOWS · AHMET CANAL

Contact

Has your product grown faster than its system?

Send the current situation, your biggest blocker and the outcome you want. We will clarify scope together.

Availability

Open to new consulting and project-based work.