Firebase vs Supabase vs Custom Backend — Practical Comparison
Choosing the right backend for your Flutter app — tradeoffs, pricing, and migration paths
Open interactive version (quiz + challenge)Real-world analogy
Firebase is a fully-furnished apartment — move in instantly but you can't knock down walls. Supabase is a furnished apartment where you own the building — more control but you manage the plumbing. A custom backend is building your own house — maximum control, maximum responsibility.
What is it?
Firebase, Supabase, and custom backends are three approaches to providing server-side functionality for Flutter apps, each with different tradeoffs in speed of development, flexibility, cost, and control.
Real-world relevance
A school management app starts with Firebase for rapid MVP (auth + Firestore for student records + FCM for notifications). At 50 schools with heavy read traffic, Firestore costs spike. The team migrates to Supabase — PostgreSQL handles complex attendance reports with SQL joins, RLS secures per-school data access, and costs become predictable.
Key points
- Firebase overview — Firebase is Google's BaaS offering: Firestore (NoSQL), Realtime Database, Auth, Cloud Functions, Storage, FCM, Crashlytics, Analytics, Remote Config. Tightly integrated, excellent Flutter SDK (FlutterFire), generous free tier. Proprietary and vendor-locked.
- Supabase overview — Supabase is an open-source BaaS built on PostgreSQL. Offers: Auth (JWT + OAuth), Postgres database with Row Level Security, Realtime (via Postgres replication), Storage, Edge Functions (Deno). Self-hostable. supabase_flutter package is the Flutter client.
- Custom backend overview — REST or GraphQL API built with Node.js/NestJS, Go, Dart (Shelf/Dart Frog), or any backend framework. Full control over schema, business logic, scaling, and costs. Requires building auth, storage, push notifications, and all infrastructure yourself.
- Offline support comparison — Firestore has built-in offline persistence (local SQLite cache, automatic sync). Supabase has no built-in offline — you handle it with flutter_drift or Hive + manual sync. Custom: you design the offline layer entirely. Firestore wins for rapid offline-capable apps.
- Real-time capabilities — Firestore: snapshot listeners on collections/documents — push updates on change. Supabase Realtime: subscribe to Postgres table changes (INSERT/UPDATE/DELETE) via WebSocket. Custom: WebSocket server or SSE. Firestore is simpler; Supabase Realtime is powerful for relational data.
- Authentication options — Firebase Auth: Email/password, Google, Apple, Facebook, phone OTP, anonymous, custom tokens. Supabase Auth: Email, OAuth (Google, GitHub, etc.), magic links, phone OTP, SSO (enterprise). Custom: implement JWT yourself or use Auth0/Clerk — more complex but fully controlled.
- Pricing model and scalability limits — Firebase: pay per read/write/storage — costs spike with high read volume (Firestore charges per document read). Supabase: pay per project/compute — predictable pricing, better for read-heavy workloads. Custom: you control costs but pay for DevOps time.
- PostgreSQL advantages (Supabase) — Supabase gives you full SQL — joins, transactions, complex queries, views, stored procedures. Row Level Security (RLS) policies enforce data access at DB level. Firestore is NoSQL — no joins, limited query operators, denormalization required.
- Vendor lock-in and migration — Firebase has significant lock-in: Firestore data model, security rules DSL, Cloud Functions triggers. Migrating away requires data export + schema redesign. Supabase is self-hostable and uses standard PostgreSQL — export with pg_dump. Custom: you own everything.
- Flutter SDK quality — FlutterFire (Firebase Flutter) is mature, officially supported by Google, and deeply integrated. supabase_flutter is active and growing but younger. Custom backends use your own http/dio layer — full control but no magic.
- When to choose Firebase — Best for: MVPs and startups needing speed, apps requiring offline-first with zero custom sync code, teams without backend engineers, projects leveraging Firebase ecosystem (Analytics + Crashlytics + FCM together). Avoid at scale if Firestore read costs become prohibitive.
- When to choose Supabase or custom — Supabase: when you need relational data, SQL queries, predictable pricing, or self-hosting. Custom: when business logic is complex, regulatory compliance requires data sovereignty, or the team has strong backend expertise and performance requirements exceed BaaS capabilities.
Code example
// Supabase Flutter — auth + realtime + RLS example
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
await Supabase.initialize(
url: 'https://your-project.supabase.co',
anonKey: 'your-anon-key',
);
runApp(const App());
}
final supabase = Supabase.instance.client;
// Sign in with email
Future<void> signIn(String email, String password) async {
final response = await supabase.auth.signInWithPassword(
email: email,
password: password,
);
// JWT is automatically attached to subsequent requests
}
// Query with RLS — only returns rows the authenticated user can access
Future<List<Map<String, dynamic>>> fetchMyWorkspaces() async {
final data = await supabase
.from('workspaces')
.select('id, name, members(user_id, role)')
.order('created_at', ascending: false);
return data;
}
// Realtime subscription — listen to new messages in a channel
void subscribeToChannel(String channelId, Function(Map) onMessage) {
supabase
.channel('messages:$channelId')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'channel_id',
value: channelId,
),
callback: (payload) => onMessage(payload.newRecord),
)
.subscribe();
}Line-by-line walkthrough
- 1. Supabase.initialize() is called before runApp — it sets up the global client with your project URL and anon key (safe to include in client code).
- 2. supabase.auth.signInWithPassword returns an AuthResponse — the JWT session is stored automatically and appended to all subsequent API calls.
- 3. fetchMyWorkspaces uses Supabase's query builder — .select('id, name, members(user_id, role)') performs a JOIN to the members table in a single request.
- 4. RLS policies on the 'workspaces' table (defined in Supabase dashboard or SQL) automatically filter results — the Flutter client doesn't need to add userId filters.
- 5. supabase.channel() creates a named Realtime channel — the name is arbitrary but should be unique per subscription to avoid conflicts.
- 6. onPostgresChanges with PostgresChangeEvent.insert listens only to new row insertions, not updates or deletes — reducing noise.
- 7. The filter ensures only messages for the specific channelId trigger the callback — server-side filtering reduces bandwidth.
- 8. payload.newRecord is a Map of the newly inserted row's columns — typed models should be created with fromJson for production use.
Spot the bug
// Supabase realtime subscription
class ChatNotifier extends StateNotifier<List<Message>> {
ChatNotifier() : super([]);
RealtimeChannel? _channel;
void subscribe(String channelId) {
_channel = supabase
.channel('messages')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
callback: (payload) {
state = [...state, Message.fromJson(payload.newRecord)];
},
)
.subscribe();
}
@override
void dispose() {
_channel?.unsubscribe();
super.dispose();
}
}Need a hint?
Multiple users open different chat channels. Messages from other channels appear in the wrong chat. What's missing?
Show answer
Bug: the channel filter is missing — all message inserts across the entire 'messages' table trigger the callback regardless of channel_id. Fix: add a PostgresChangeFilter to the onPostgresChanges call: filter: PostgresChangeFilter(type: PostgresChangeFilterType.eq, column: 'channel_id', value: channelId). Also, the channel name 'messages' is shared across all ChatNotifier instances — if two channels are open simultaneously, they conflict. Fix: use a unique channel name like 'messages:$channelId' so each subscription is isolated.
Explain like I'm 5
Firebase is like a toy train set — it's ready to play with in 5 minutes and everything fits together perfectly. Supabase is like Lego — a bit more work to build but you can make any shape you want and you can take it apart and rebuild it. A custom backend is like woodworking — you make exactly what you want but you need real skills and tools.
Fun fact
Supabase reached 1 million databases hosted in 2023, just three years after launch — growing faster than Firebase did in its first three years, largely because of the PostgreSQL-familiar developer experience.
Hands-on challenge
Design the data layer for a school management app: (1) Define a Supabase schema for schools, students, and attendance with appropriate foreign keys. (2) Write RLS policies so teachers only see students from their school. (3) Implement a Supabase Realtime subscription in Flutter that updates the UI when a new attendance record is inserted. (4) Compare how you would implement the same offline-first requirement with Firebase vs Supabase.
More resources
- FlutterFire documentation (FlutterFire)
- Supabase Flutter quickstart (Supabase Docs)
- Firebase vs Supabase — detailed comparison (Supabase Blog)
- Supabase Row Level Security (Supabase Docs)