Most Flutter and Supabase tutorials stop at the moment a query returns rows. Production begins about four problems later: the session that comes back null on cold start, the policy that turns a 9 ms query into 179 ms, the realtime subscription that behaves until the two hundredth device joins, and the morning a user opens your app on a train with no signal.
Worth saying up front what I do and do not run on this stack. BookBed, the property management SaaS I built solo, is Flutter across six platforms on Firebase and Stripe — not Supabase. Pizzeria Bestek is React on Supabase. So what follows is the production pattern I would build to, assembled from the parts of both stacks that survived real users, checked against what Supabase's own docs and issue tracker admit about the sharp edges. Where the call is genuinely close, I say so instead of picking a side for the sake of a tidy conclusion.
What Does a Production Flutter + Supabase App Look Like?

The client talks straight to Postgres only where a row-level security policy fully describes the rule, and everything else routes through server-side code. That line is the whole architecture.
It sounds obvious until you try to draw it. The tempting version of this stack is a Flutter app with no backend at all: the SDK is right there, the tables are right there, why add a hop? Because some rules cannot be expressed as a row predicate. "This user may read this booking" is a predicate. "This user may refund this booking, but only within 48 hours, and only if the Stripe charge has not already been disputed" is a workflow, and workflows belong somewhere you can log, retry, and change without shipping a new build to two app stores.
| Operation | Client-direct through RLS | Edge Function or server |
|---|---|---|
| Read a user's own rows | Yes, one indexed equality check | No |
| Insert a record the user owns | Yes | No |
| Payments, refunds, webhook handling | No | Yes, secret key stays server-side |
| Admin or moderation actions | No | Yes |
| Multi-step transactions | No | Yes, one RPC in one transaction |
| Anything an attacker could replay | No | Yes |
Draw that table for your own app before you write a screen. The rows that land on the left get a policy and an index. The rows on the right get a function, and the Flutter side never sees a secret key.
Your RLS Policies Are Your Query Plan

People treat row-level security as a security feature that happens to cost a little latency. On a mobile client, where every list view is a round trip over cellular, it is closer to the dominant term in your query plan. Actually, let me temper that. On a table with four hundred rows nobody will ever notice. The trouble is that the table with four hundred rows is the one you benchmark on.
Supabase publishes its own benchmarks for this, and the numbers are not marginal. Three changes, in the order I would make them:
- Wrap the auth call. Changing
auth.uid() = user_idto(select auth.uid()) = user_idlets the Postgres optimizer build aninitPlanand cache the result per statement instead of invoking the function once per row. Supabase's RLS performance guidance measures that single edit at 179 ms down to 9 ms — a 94.97% improvement. - Index the column the policy filters on. A B-tree index on
user_idin the same benchmark takes a 171 ms query to under 0.1 ms. Policies are just predicates, and unindexed predicates scan. - Name the role with
TO authenticated. Without it, the policy is evaluated for anonymous callers too; with it, execution stops early, which Supabase clocks at 170 ms down to under 0.1 ms for the anon path.
Three edits. None of them touch your Dart.
The counter-example the docs do not lead with is upsert(). You have an INSERT policy and an UPDATE policy, you call upsert() from Flutter, and Postgres tells you the new row violates row-level security. It is not a Supabase bug. As a maintainer put it in this discussion thread, Postgres requires you to be able to select the row in order to update or upsert it, so a conflict resolution needs a SELECT policy that your INSERT and UPDATE policies did not imply. Either add one, or move the operation into an RPC. I lost an evening to this shape of error on a different stack before I understood the mechanism, and the error message gives you no hint at all.
Why Is My Supabase Realtime Subscription Not Scaling?

Usually because it runs on Postgres Changes, which re-checks your policies per connection per event, instead of Broadcast, which Supabase now recommends.
That recommendation is explicit. The subscribing-to-database-changes guide calls Broadcast "the recommended method for scalability and security" and says plainly that Postgres Changes does not scale as well. The migration is a database trigger that calls realtime.broadcast_changes() and a Flutter channel that listens for the event, instead of the client subscribing to table rows directly.
| Postgres Changes | Broadcast from the database | |
|---|---|---|
| Who filters | Realtime, per connection, against RLS | Your trigger, once, at write time |
| Payload control | The row, subject to a size cap | Exactly what the trigger sends |
| Scaling profile | Degrades as connections rise | Recommended path for scale |
| Setup cost | One line in the client | A trigger plus a policy on realtime.messages |
The quotas are worth knowing before you design around them rather than after. Supabase's realtime limits page puts concurrent connections at 200 on Free, 500 on Pro, and 10,000 on Team or Pro without a spend cap; messages per second run 100, 500, and 2,500 across the same tiers, with channel joins per second matching. There is also a payload ceiling of 1,024 KB on Postgres Changes, and past it the new and old records arrive carrying only fields whose value is 64 bytes or smaller — which means a wide row silently degrades into a partial one rather than failing loudly.
On mobile that ceiling arrives sooner than you would think, because a phone rejoins channels every time it comes back from the background. Each rejoin is a channel join. A commuter who checks your app at six stops has spent six of them.
The Cold Start That Logs Your Users Out
Ever had a user insist they were logged in, and watch them land on your login screen anyway?
supabase_flutter persists the session for you. The package docs describe shared-preferences-backed storage and a LocalStorage interface if you want to swap it. But persistence and availability at the instant your router runs are different things. Read currentSession synchronously in the first frame after Supabase.initialize and you can get null while restoration is still in flight. Ship a redirect off that null and you have built a logout.
The unhappy version of this is worse than a race. There is a long-running issue on supabase-flutter whose reporter describes a mobile app that "gets into a state where currentSession is always null and client cannot recover from this state, not even with subsequent app launches" — users stranded on the splash screen until they sign in again. Refresh token rotation is part of the story: a refresh token is single-use, so two widgets racing to refresh the same session at startup is a genuinely bad idea.
The pattern that avoids all of it:
- Drive routing from
onAuthStateChange, not from a one-shot read. The stream is the source of truth; treatcurrentSessionas a convenience for code that already knows a session exists. - Give the app a real loading state between "initialize returned" and "auth state has spoken", rather than a splash screen you dismiss on a timer.
- Let exactly one thing own token refresh. Three providers that each decided to be helpful will race each other into a revoked token.
- Keep PKCE, which has been the default deep-link flow since v2, and register your redirect scheme on every platform you ship — including the desktop targets people forget until a magic link opens nothing.
Sign-out deserves the same care in reverse. Dispose the account-scoped channels and clear cached rows, or the next user on that device sees a flash of someone else's data before the first fetch lands.
Storage Uploads Happen in Two Calls
Uploading a file to a bucket does not put a row in your database, and a row in your database does not prove the file arrived. Write the record after the upload resolves, not alongside it. Otherwise your list view renders a thumbnail whose object does not exist, and you get a bug report about "broken images" that is really a bug about ordering.
Offline Is Not a Feature You Bolt On Later
An offline-first Flutter app reads from a local database and syncs in the background, which is an architecture decision, not a package you add in month five.
Supabase's own position here is refreshingly unromantic. In their offline-first Flutter guide, Tim Shedor and Tyler Shukert open by pointing out that people use phones on subways, on planes, and on sub-3G connections, then walk through Brick as the data layer that keeps SQLite and Supabase in agreement, queueing writes made while offline and replaying them on reconnect.
Read the caveats in that same post before you commit. Brick's reactivity does not use Supabase channels by default, so a change made on another device will not push itself to this one, and the offline queue does not cover Auth or Storage calls. That is a reasonable trade, but it is a trade — and discovering it after you have modelled forty tables is expensive.
Here is the honest version for most apps: you probably do not need full offline sync. You need reads to come from a cache so a cold launch is not a spinner, and writes to fail visibly instead of silently. That is a much smaller project.
When Firebase Still Wins
I run BookBed on Firebase, and I would make that choice again for that product.
The reason is not performance. It is that Firestore's rules language cannot express a slow query, so I never had the option of writing one — the tenant boundary is a path, tenants/{tenantId}/..., and the check is structural. Postgres will happily let you write a policy with a correlated subquery, and it will run per row until you notice. Supabase gives you real SQL: joins, constraints, migrations, and a schema you can reason about in a diff. The price is that keeping the policy layer fast is now your job.
| Signal | Leans Firebase | Leans Supabase |
|---|---|---|
| Data shape | Document-ish, denormalized | Relational, joins matter |
| Offline needs | Built-in persistence out of the box | Needs Brick, PowerSync, or your own cache |
| Team's SQL depth | Low | Comfortable writing and reading policies |
| Reporting and analytics | Awkward, usually an export | Native, it is Postgres |
| Exit cost | High, proprietary | Lower, it is Postgres underneath |
If you want the setup detail rather than the decision, I keep the wiring notes in the Flutter and Supabase stack guide, and the tooling I actually keep installed in my roundup of Flutter developer tools.
One more thing from the Firebase side that transfers directly. BookBed's overbooking detector once normalized dates with setUTCHours(0,0,0,0) and toISOString(), which quietly produced the wrong calendar day for anything stored at 22:00 UTC, because 22:00 UTC is already tomorrow in Zagreb. False overbooking warnings on bookings that never overlapped. Postgres will not save you from that class of bug either. timestamptz stores the instant correctly and still hands you a wrong-looking day if you format it in the wrong zone.
Rotate Your Keys Before the Deadline Finds You
Supabase is retiring the JWT-shaped anon and service_role keys in favour of opaque sb_publishable_ and sb_secret_ tokens, and its migration guide puts the deprecation of the legacy pair at the end of 2026. The same page confirms both key types work simultaneously, so you can swap clients one at a time and deactivate the old keys only once nothing depends on them.
For a Flutter app the client half is one line, since Supabase.initialize now takes publishableKey. Per its pub.dev listing, the package sits at 2.17.2 as of August 2026, with 2.17.0 having split shared code into supabase_common and sped up realtime channel rejoin on web. The half that will bite you is everywhere a service_role key is sitting in an Edge Function, a CI secret, or a script somebody wrote in a hurry. Go find those now, while both key types still work, rather than during the week the old ones stop.
Pick One Screen and Time Its Policy
Open your slowest list view, take the policy behind it, and run it through the three edits above: wrap the auth call, index the filtered column, name the role. Then run the same query again and compare. If the number does not move, your problem is a round trip or an image, not a policy, and you have ruled out the expensive suspect in ten minutes.
I am curious how often it turns out to be the missing index. In my experience it is most of the time — which one is it in your app?
