Before you write another screen, list every place your Flutter app talks to Supabase and mark each one as either a row a policy can fully describe or a workflow that needs server code. Most Flutter and Supabase tutorials stop when a query returns rows. Production starts a few problems later: a session that ends when the user didn't expect it, a policy that scans a whole table, a realtime subscription that stops scaling once enough devices join, and an app opened with no signal.
I should say up front what I run on this stack. BookBed, the property management SaaS I built solo, is Flutter across six platforms on Firebase and Stripe. It does not use Supabase. Pizzeria Bestek is React on Supabase, not Flutter. So this post isn't a report from a Flutter and Supabase app of mine. It's a production pattern assembled from the documentation, checked against the primary sources on 30 September 2026, with my recommendations labelled as recommendations. Where the call is close, I say so.
What does a production Flutter and Supabase app look like?

The client talks straight to Postgres only where a row-level security policy fully describes the rule, and everything else goes through server-side code. That line is the whole architecture.
It sounds obvious until you try to draw it. The tempting version of the stack is a Flutter app with no backend: the SDK is right there, the tables are right there, why add a hop? Because some rules aren't row predicates. "This user may read this booking" is a predicate. "This user may refund this booking, but only within 48 hours, and only if the payment isn't disputed" is a workflow, and workflows belong where you can log them, retry them and change them without shipping a new build to two app stores. The 48 hours in that example is an illustration, not a real rule.
| 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, the 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 build. Rows on the left get a policy and an index. Rows on the right get a function, and the Flutter side never sees a secret key.
Your RLS policies are your query plan

Treat row-level security as part of query performance, not only a security feature. Supabase's RLS performance guide says Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. On a phone, every list view is a round trip, and a table small enough to benchmark on your laptop hides the problem.
Supabase's RLS guide gives three rules to apply first, and none of them touch your Dart:
- Index the columns your policies filter on. The guide says an unindexed filter column turns a read into a sequential scan, and that a column counts as indexed only when it comes first in a btree index. A membership table keyed on
(team_id, user_id)has no index onuser_id. - Call functions with select. Write
(select auth.uid()) = user_idinstead ofauth.uid() = user_id. The guide says wrapping the function makes Postgres run aninitPlanthat caches the result per statement instead of calling the function on each row. It only works if the result doesn't depend on row data. - Name the role with
to authenticated. The guide says this stops the policy running for anonymous users, since execution ends at the role check.
The current Supabase pages don't publish benchmark timings for these edits, so I won't quote any. Measure your own instead. The performance guide's method is to run the query with RLS enabled, then again with it disabled in a non-production environment, and compare. If the times are similar, the query is the problem, not the policy. It also suggests adding an explicit filter such as .eq('user_id', userId) in the client query even though it duplicates the policy, because Postgres can use it to build a better plan.
There's one failure that looks like a Supabase bug and isn't. You have INSERT and UPDATE policies, you call upsert() from Flutter, and Postgres reports a row-level security violation. The PostgreSQL CREATE POLICY documentation explains it: an INSERT with ON CONFLICT DO UPDATE requires SELECT permissions on the relation, and the proposed rows are checked against the SELECT policies. Add a SELECT policy, or move the operation into an RPC.
Storage uploads and rows are two separate writes
Uploading a file to a bucket doesn't create a row in your database, and a row doesn't prove the file arrived. My recommendation is to write the record after the upload resolves, not alongside it. Otherwise a list view can render a thumbnail for an object that doesn't exist, and the bug report reads "broken images" when the real bug is ordering.
Offline is an architecture decision
An offline-first Flutter app reads from a local database and syncs in the background, which is a decision about your data layer, not a package you add in month five. Supabase's own offline-first Flutter guide, published in October 2024, introduces Brick as a data manager that handles querying and uploading between Supabase and a local cache such as SQLite. It opens by noting that people use phones on subways, airplanes and sub-3G connections.
Read the caveats in that post before committing. It says Brick's subscription stream doesn't use Supabase's channels by default, so if Supabase updates, your app isn't notified, and it calls that opt-in feature under active development. That's a reasonable trade, but it's a trade, and finding out after you've modelled forty tables is expensive.
Here's my recommendation for most apps: you probably don't need full offline sync. You need reads served from a cache so a cold launch isn't a spinner, and writes that fail visibly instead of silently. That's a much smaller project.
Rotate your API keys before the deadline
Supabase is deprecating the legacy anon and service_role keys by the end of 2026, in favour of publishable (sb_publishable_) and secret (sb_secret_) keys. Its migration guide says both key types work simultaneously, so you can swap clients one at a time and deactivate the legacy keys only after nothing depends on them. It lists callers that are easy to miss, and the first is mobile or desktop app versions already in users' hands. Old installs are the mobile-specific risk.
On the Flutter side, the supabase_flutter package page shows Supabase.initialize taking a publishableKey, and listed version 2.18.0 on 30 September 2026. The half that's easy to miss is every service_role key sitting in an Edge Function, a CI secret or a quick script. Find those while both key types still work.
Why is my Supabase realtime subscription not scaling?

Usually because it uses Postgres Changes, which authorizes every event against each subscriber, instead of Broadcast, which Supabase recommends for scalability and security.
Supabase's Postgres Changes guide is explicit: when you make one change to a table with 100 subscribed users, Realtime performs 100 authorization checks, so throughput scales with the number of subscribers, not the write rate. Changes are also processed on a single thread to preserve order. The guide says that above roughly 3,000 concurrent subscribers on the same changes, you should use Broadcast. The subscribing-to-database-changes guide calls Broadcast the recommended method for scalability and security, and describes a database trigger that calls realtime.broadcast_changes().
| Postgres Changes | Broadcast from the database | |
|---|---|---|
| Who authorizes | Realtime, per subscriber, per event | Broadcast authorization RLS policies on a private channel |
| Payload control | The row, subject to a size limit | What the trigger sends |
| Scaling profile | Scales with subscribers, single-threaded | Sent once, fanned out to all subscribers |
| Setup cost | One subscription in the client | A trigger plus a policy on realtime.messages |
Know the quotas before you design around them. The Realtime limits page lists concurrent connections of 200 on Free, 500 on Pro, and 10,000 on Pro without a spend cap and on Team, with messages per second of 100, 500 and 2,500 for the same plans. Postgres change payloads are limited to 1,024 KB, and past that limit the new and old records include only fields whose value is 64 bytes or smaller, so a wide row degrades into a partial one instead of failing loudly.
Mobile adds its own cost. The supabase_flutter README says Supabase disconnects the realtime socket when the app is paused and reconnects it, rejoining every joined channel, on resume, and that every reconnect costs a new socket and a fresh authorization of each channel. It documents RealtimeLifecycleOptions to keep the socket open through short pauses. My inference is that a phone that keeps switching apps generates channel joins that a browser tab doesn't, so budget your joins per second accordingly.
Why does my Flutter app log users out with Supabase?
Because the session ended for a documented reason, or because your routing acted before the auth state had spoken. Check the documented reasons first, then check your startup order.
Supabase's user sessions documentation lists when a session terminates: the user signs out, changes their password or performs a security-sensitive action, times out from inactivity, reaches its maximum lifetime, or signs in on another device, depending on configuration. The same page says a refresh token can be exchanged only once, and describes a mobile-shaped failure: if the response to a refresh isn't received, the client comes back online with a token already used, which can terminate the session unexpectedly. Reuse within a default 10-second interval is tolerated, and reuse of the parent of the active token returns the active one.
Then your startup order. The package persists the session for you: the pub.dev README says it uses shared_preferences by default, with a LocalStorage interface you can replace. I haven't reproduced a race between Supabase.initialize and a first read of currentSession in a Flutter app, so I won't claim one. My recommendation is defensive:
- Drive routing from
onAuthStateChange, which the API reference lists with aninitialSessionevent, instead of a one-shot read ofcurrentSession. - Pass an
onErrorhandler to that stream. The reference says network errors, such as an offline token refresh, are emitted as stream errors, and without a handler Dart rethrows them as unhandled zone exceptions that crash the app. - Show a real loading state between "initialize returned" and "auth state arrived", not a splash you dismiss on a timer.
- Let exactly one piece of code own refreshing, so two widgets never race to use the same single-use token.
- Register your redirect scheme on every platform you ship. The README says
supabase_flutterusesapp_linksinternally for deep links, and desktop targets are the ones people forget until a magic link opens nothing.
Sign-out deserves the same care in reverse. Dispose account-scoped channels and clear cached rows, or the next person on the device can see a flash of someone else's data before the first fetch lands.
When should you pick Firebase instead of Supabase?
Pick Firebase when your data is document-shaped and you want offline persistence without adding a sync layer, and Supabase when the data is relational and someone on the team will own SQL policies. BookBed's case study documents Firebase and Stripe, and I'm not claiming a benchmark against Supabase.
| Signal | Leans Firebase | Leans Supabase |
|---|---|---|
| Data shape | Documents | Relational tables, joins, constraints |
| Access rules | Firestore security rules | SQL policies you write and tune |
| Offline needs | Firestore offline persistence, on by default on Android and Apple platforms, off by default on web | Needs Brick, another sync layer, or your own cache |
| Team's SQL depth | Low | Comfortable reading and writing policies |
The offline row comes from the Firestore offline data documentation. The access-rules row is my summary: Supabase gives you real SQL, and keeping the policy layer fast becomes your job. If you want the wiring notes rather than the decision, they're in the Flutter and Supabase stack guide.
Time zones are backend-independent. BookBed's case study describes an early overbooking check that normalized dates with setUTCHours(0,0,0,0) and toISOString() and gave the wrong date for bookings stored late in the evening UTC, which is already the next day in Zagreb. Postgres won't save you from that class of bug either. Its date and time documentation says timestamptz values are stored as UTC and converted to the session time zone on output, so formatting in the wrong zone still shows the wrong-looking day.
Pick one screen and time its policy
Open your slowest list view, take the policy behind it, and apply the three edits above: index the filtered column, wrap the auth call in select, name the role. Run the query with RLS on and off before and after. If the number doesn't move, the round trip or an image is your problem, not a policy, and you've ruled out the expensive suspect quickly. Which one is it in your app?
