SQL vs NoSQL for Mobile and Web Apps - A Practical Guide
For most mobile and web apps, a SQL database is the safer default: app data is usually relational (users, teams, orders, comments), and SQL handles joins, reports and changing requirements without rewriting stored data. NoSQL document databases like Firestore or MongoDB are a good fit when data is naturally self-contained per document, when offline-first mobile sync is the top priority, or when the schema truly varies per record. The choice isn't about scale for most apps; both scale far beyond what a typical app needs.
The clearest way to see the difference is to build the same feature both ways.
#The same app, two models
Take a small team chat: workspaces, channels, members, messages. Screens need: "channels in my workspace with unread counts", "last 50 messages in a channel with author names", and an admin report "messages per workspace per day".
#Relational (Postgres)
create table workspaces (id uuid primary key default gen_random_uuid(), name text not null);
create table members (
workspace_id uuid references workspaces on delete cascade,
user_id uuid references auth.users on delete cascade,
role text not null default 'member',
primary key (workspace_id, user_id)
);
create table channels (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces on delete cascade,
name text not null
);
create table messages (
id bigint generated always as identity primary key,
channel_id uuid not null references channels on delete cascade,
author_id uuid not null references auth.users,
body text not null,
created_at timestamptz not null default now()
);
create index on messages (channel_id, created_at desc);The message list with author names is one query, and the admin report another:
select m.id, m.body, m.created_at, p.display_name
from messages m join profiles p on p.id = m.author_id
where m.channel_id = $1
order by m.created_at desc limit 50;
select c.workspace_id, date_trunc('day', m.created_at) as day, count(*)
from messages m join channels c on c.id = m.channel_id
group by 1, 2 order by 2 desc;Each fact is stored once. A user changes their display name, and every message shows the new one.
#Document (Firestore-style)
workspaces/{workspaceId}
name
members: { uid1: "admin", uid2: "member" }
workspaces/{workspaceId}/channels/{channelId}
name, lastMessageAt
workspaces/{workspaceId}/channels/{channelId}/messages/{messageId}
body, createdAt, authorId, authorName <- copied for displayThe message list is one fast read of a subcollection, and authorName is stored on each message so no second lookup is needed. In exchange:
- When a user renames themselves, you update every message they wrote, or accept stale names.
- The admin report needs a scheduled job that aggregates counts, or an export to a warehouse, because there's no
group byacross subcollections. - Unread counts are kept in separate counter documents updated on every message.
Neither is wrong. The document model optimizes the screens you designed today; the relational model keeps future questions cheap.
#Where NoSQL is the better fit
- Self-contained documents. User settings, drafts, form submissions, game saves: each read loads one thing and nothing else depends on it.
- Offline-first mobile. Firestore's SDKs cache data, queue writes and sync automatically. That's a real productivity win for apps used on the move.
- Truly variable shape. Product catalogs where every category has different attributes, event payloads from many sources.
- Very high write fan-in to independent keys, such as IoT readings, where each write touches one small document.
#Where SQL is the better fit
- Relationships. Anything with users, teams, permissions, orders and items.
- Reporting and admin tools. One SQL query instead of a pipeline.
- Changing requirements. New screens become new queries, not data migrations.
- Integrity. Foreign keys, unique constraints and transactions across tables prevent whole classes of bugs.
- Security per row. Postgres row level security filters data for each user inside the database.
#You can have both in Postgres
Postgres's jsonb covers most "I need flexible fields" cases inside a relational schema:
create table products (
id bigint generated always as identity primary key,
name text not null,
price_cents int not null,
attributes jsonb not null default '{}'
);
create index on products using gin (attributes jsonb_path_ops);
select name from products where attributes @> '{"color": "red", "size": "M"}';Fixed fields get columns and constraints; variable fields live in jsonb, indexed. Many teams that chose MongoDB for flexibility find this hybrid enough.
#Security model differences
In Firestore, security rules describe who can read and write each document path. In a Postgres backend, policies describe who can see each row:
alter table messages enable row level security;
create policy "Members read their channels" on messages for select to authenticated
using (exists (
select 1 from channels c join members mb on mb.workspace_id = c.workspace_id
where c.id = messages.channel_id and mb.user_id = auth.uid()
));Rules that depend on other data (membership, roles) are natural in SQL, because the policy can join. In document rules you often duplicate membership onto documents so rules can check it. See Postgres Row Level Security for patterns.
#Offline sync: the NoSQL advantage, closing
Offline support has been the strongest reason mobile teams picked Firestore. SQL backends are catching up with client-side sync layers. For example, StitchDB's offline sync keeps chosen Postgres tables on the device, serves reads locally, queues writes while offline and applies a conflict rule per table (latest edit wins, server wins, device wins, or your own SQL function). Each user receives only rows their policies allow.
const db = stitchdb.offline({ tables: ['messages'] })
await db.ready
await db.table('messages').upsert({ id: crypto.randomUUID(), channel_id, body: 'On my way' })Rows created offline need ids the device can generate, so use uuid keys for synced tables.
If you only need live updates, not offline storage, Postgres Changes streams inserts, updates and deletes from chosen tables to subscribed clients, which covers the "live listener" part of what Firestore users expect.
#Scaling: mostly a myth for apps
"NoSQL scales, SQL doesn't" comes from an era of small servers. A single modern Postgres server handles large apps, read replicas spread read traffic, and partitioning handles very large tables. Document databases do scale out more easily for some write patterns, which matters at very large scale. For an app with thousands to millions of users, data modeling and indexes decide performance far more than the database category.
#Cost differences
Pricing models differ as much as data models. Firestore bills per document read, write and delete, so cost follows how many documents each screen reads. Postgres hosting bills mostly for size, compute and transfer. As of November 2026, Firestore's free Spark quota is 50K reads and 20K writes a day; StitchDB's Free plan includes a 500 MB Postgres database, and Pro is $15.99/month with 10 GB. Compare more in BaaS pricing compared.
#Decision checklist
Answer yes or no:
- Will you need to show data from three or more entity types on one screen?
- Will someone ask for reports across all users or tenants?
- Do permissions depend on membership in teams or groups?
- Do you expect requirements to change a lot in the first year?
- Is offline-first behavior on mobile the most important feature?
- Is each record mostly independent of the others?
Mostly yes to 1-4: choose SQL. Mostly yes to 5-6 and no to 1-4: a document database fits. Mixed: Postgres with jsonb, plus a sync layer if you need offline.
#FAQ
#Is SQL or NoSQL better for mobile apps?
SQL for relational data and reporting; NoSQL for offline-first apps with self-contained documents. Sync layers now give SQL backends offline support too.
#Is Firestore SQL or NoSQL?
NoSQL. It's a document database with collections and documents and no joins.
#Can Postgres store JSON like MongoDB?
Yes. The jsonb type stores and indexes JSON documents, and you can mix JSON and regular columns in one table.
#Which is easier for beginners?
Document databases feel easier on day one. SQL tends to be easier by month six, when requirements change.
Try relational data with an API and offline sync built in: start free on StitchDB.