12-Mar-2025 "Serverless" Postgres: Bitter Reality
2500 new DBs daily; 80K stars on GitHub; Product Hunt Golden Kitty Awards 2024 All-Around Runner-Up
more than a third of the current YC are building with Supabase
Supabase looks supa-cool, and I wanted to get practical experience with it.
I built exclusively.pro with Supabase.
The project is about preventing employees from holding multiple remote jobs simultaneously.
It is a simple "who works where now" registry, but building it with Supabase was a bitter-sour experience.
The article is a cautionary tale, encouraging developers to thoroughly weigh Supabase's advantages and weaknesses against their project needs.
Supabase works hard on improving the local development experience.
Still, I immediately stumbled across a few surprises, and ended up adding crutches to my building flow.
The AI Assistant doesn't work locally, only on supabase.com.
Edge functions: CORS boilerplate
No wonder, the back-end is still there, in a form of so-called Edge functions, callable portions of code with a short lifecycle that run on the server.
Invoking an edge function e.g. supabase.functions.invoke('get-companies')
is no different from running a DB query supabase.from('companies').select('link')
.
HTTP requests hit the same host.
But for the DB, Supabase server supplies CORS headers, while for the edge functions it doesn't.
Boilerplate is required in edge functions code to allow calling them from the client-side JavaScript:
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') { // This is needed to invoke the function from a browser
const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type' }
return new Response('ok', { headers: corsHeaders })
}
// process POST
Edge functions: Confusion with `supabase functions serve`
The supabase functions serve
command doesn't start serving supabase edge functions.
It just tails the logs.
Command-line flags --env-file
, --import-map
, and --no-verify-jwt
are confusing.
If this command is just a log tail, how can those flags affect the locally running instance of Supabase?
To actually stop and restart functions, you have to run supabase stop && supabase start
.
This is sometimes needed to unstuck e.g. edge functions that had a syntax error.
Edge functions: Per-user rate limits
If some users started abusing the app, I was worried to exceed the LinkedIn API limits, which LinkedIn very vaguely documents.
Supabase only has built-in rate limits for authentication-related endpoints.
For throttling edge function invocations, they recommend using a third-party Redis service.
I was immediately against introducing such a complexity.
Instead, I quickly implemented a naive user_id
/last_run_at
Postgres table that throttled concerning LinkedIn API requests to one request a day per user.
SQL: Aggregate functions
Aggregate functions are turned off by default due to prevent potential performance issues
.
Preventing those issues by only allowing aggregate functions to the service_role from edge functions is impossible.
StackOverflow's answer from Supabase's maintainer seems outdated.
SQL and Edge functions: Storing encrypted information
For data security and privacy compliance, I planned to encrypt secret and private information.
Supabase has a Vault to store encrypted secrets, with encryption keys stored separately.
However, from edge functions, the Vault is inaccessible: relation "public.vault.decrypted_secrets" does not exist
.
use these secrets anywhere in your database: Postgres Functions, Triggers, and Webhooks
I am very sceptical if "anywhere" applies here, as this implies I have to employ Postgres Functions on top of edge functions to work with the Vault.
This adds even more complexity.
The Vault extension provides create_secret()
and update_secret()
, but no counterpart for an UPSERT.
You'll likely to have to implement it on your own by attempting a fetch first.
Granular access control
There are two main roles, one with row-level security enabled, and the "allow-all" "service_role" that bypasses the row-level security.
From edge functions, using a more permissive role is convenient, while keeping the role that is used from the client-side JavaScript restrictive.
const supabase = createClient(Deno.env.get('SUPABASE_URL'), Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')) // WARNING: THIS BYPASSES THE RLS
The reasoning behind having just two roles is clear.
In theory, you can create a custom role, but in practice there's not much use for this.
And it's impractical to have more than just those two roles. Even two!
Connections are established per role, and even though most of them are just PgBouncer pool connections, still, the number of underlying connections would have to be divided between roles, effectively limiting the number of maximum simultaneously active connections per role.
It's a bottleneck, and better be avoided.
Confusingly, the "Target Roles" multiple select dropdown in the RLS policy editor lists numerous roles: "anon", "authenticated", "authenticator", "dashboard_user", "pgbouncer", a number of "pgsodium*" roles, "postgres", "service_role", and a number of "supabase_*" roles.
Custom roles appear here, too.
But only "anon" and "authenticated" are useful for RLS.
None of those roles participate when implementing a flexible permission system.
SQL: Joins
Filtering on a column from a joined table isn't documented in Supabase.
PostgREST, that backs Supabase's JavaScript API allows this:
supabase.from('companies_admins').select('id, companies ( linkedin_id )')
// Without a filter on the joined table
> Array [ {…}, {…} ]
0: Object { id: 112, companies: {…} }
companies: Object { linkedin_id: 101111111 }
id: 112
1: Object { id: 113, companies: {…} }
companies: Object { linkedin_id: 102222222 }
id: 113
length: 2
// With a filter on the joined table
supabase.from('companies_admins').select('id, companies ( linkedin_id )').eq('companies.linkedin_id', 101111111)
> Array [ {…}, {…} ]
0: Object { id: 112, companies: {…} }
companies: Object { linkedin_id: 101111111 }
id: 112
1: Object { id: 113, companies: null }
companies: null
id: 113
length: 2
By default, joins are a LEFT OUTER JOIN (notice the companies: null
in the second query result).
For an INNER join:
// With a filter on the joined table with an INNER join
supabase.from('companies_admins').select('id, companies!inner ( linkedin_id )').eq('companies.linkedin_id', 101111111)
> Array [ {…} ]
0: Object { id: 112, companies: {…} }
companies: Object { linkedin_id: 101111111 }
id: 112
length: 1
This can all be worked around, even though it takes an effort to figure out and get used to.
I could just file issues for those problems, but e.g. for this one I haven't heard back for three weeks.
You don't have to remind me that Supabase is open-source, and I could have fixed all.
I've submitted one doc fix, but I wanted to focus on building my product, not building Supabase.
Feedback loops
Feedback loops facilitate making informed decisions on how to proceed with development.
Examples of feedback are: a log entry pointing to where the error happened; product manager's comment on a work-in-progress feature; a red test run.
The faster you get feedback, and the more useful, detailed and precise it is, the less effort is wasted.
Short feedback loops are essential for developers' and teams' effectiveness and productivity.
My main complaint to Supabase is that lack and length of its feedback loops sink developer effectiveness, productivity and happiness, both for the AI-assisted development, and traditional human-only development.
Logging: bulk inserting 25'000 records
Function failed due to not having enough compute resources (please check logs)
Funny enough regarding the "please check logs" part, the logs contain exactly the same error message.
What is the cap on compute? How higher should it be to complete the task?
This feedback loop is long.
Logging: invoke an edge functions providing invalid input
HTTP 400 with JSON response:
error: '"failed to parse tree path ([object Object])" (line 1, column 1)'
Logging: invoke an edge function that misses an "async"
Omit an async
function modifier, and you'll get just an HTTP 503 with no body, and useless JSON with just the HTTP headers in the Dashboard logs.
await Promise.all(companiesToCreate.map((company) => { ... }))
Logging: violating a uniqueness constraint
Attempt to insert a row that violates a uniqueness constraint in an edge function.
The edge function invocation returns an HTTP 504 (gateway timeout) after 2.5 minutes.
On such gateway timeout responses, CORS isn't set, so in the browser development console you'll only see:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://pbawiprsmukdthskekii.supabase.co/functions/v1/confirm-authenticity. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 504.
And a Response body is not available to scripts (Reason: CORS Missing Allow Origin)
.
It really only needed to change an INSERT to an UPSERT to be fixed. But there's just no way you can get to this conclusion except by looking at the code.
JavaScript API error logging: Social login redirect
Social login with LinkedIn redirect worked to localhost:8000.
But it kept redirecting to localhost when ran from the production domain, even though redirectTo
argument provided to signInWithOAuth()
.
Alright, you have to specify the "Site URL" and "Redirect URLs" in the configuration.
Setting the Site URL to "https://exclusively.pro/" and Redirect URLs to "https://exclusively.pro/, http://localhost:8000, http://localhost:8000/*" worked.
There are no errors, no warnings if this isn't set up correctly - it just redirected to the other location.
Syncing remote and local databases
This turned out to be necessary.
With some test seed data, resulting in a 4.5 MB seed.sql
file, every supabase db dump
or supabase db dump --data-only -f seed.sql
was taking FIVE minutes.
Every schema dump had to be manually edited, as it was adding a line that didn't apply to the schema, and could not be loaded with supabase db reset
locally:
SELECT pg_catalog.setval('"public"."user_info_id_seq"', 5, true);
I've renamed the user_info
table to a more specific user_handle
, but the seq name value retained the old name in the dump somehow.
Seeding data from supabase/seed.sql...
failed to send batch: ERROR: relation "public.user_info_id_seq" does not exist (SQLSTATE 42P01)
Dependency uptime
Even though the service provider supports status updates via email even on their free plan, there's no email notification option on the Supabase status page.
RSS/Atom should be fine as an input for automated tools, though.
Performance
I expected a tiny test database to run blazingly fast on Supabase, almost indistinguishable from running it locally inside Docker.
The project is running on a Pro plan.
Compute size is "Micro", the default for the plan.
Zero load.
The monthly quotas are not exhausted.
The database easily fits into 1 GB of RAM.
Fetching two joined records
supabase.from('companies_admins').select('companies ( linkedin_external_id, name, link_uuid )')
With 8 records total in the companies
table, and just as few in the companies_admins
, this query returns two rows, and takes 550 ms.
Just one trivial SELECT auth.uid() AS uid) = user_id)
RLS policy is involved.
COUNT
supabase
.from('connections')
.select('count()')
.eq('company_id', ...)
.not('employee_id', 'is', null)
.single()
In a table with 45'000 records, two such queries (one with a IS NULL
condition, and the other without) together take 3 seconds.
A missing index you would think? No, adding indexes didn't help a single bit.
This is supa slow.
Price
We've seen so far what we get. And this isn't too bad if you fit into a $25/mo Pro plan. $10 of those go for the Micro compute (2 shared ARM cores, 1 GB RAM).
The next sensible tier is Large, with 2 dedicated ARM cores and 8 GB RAM.
For this brief comparison, I'll use Heroku Postgres, which runs on top of AWS.
For me, Heroku offers outstanding reliability and convenience for a managed Postgres instance.
Features like High Availability, HIPAA compliance, support can cost extra.
Heroku doesn't count WAL towards the Storage Limit, Supabase does.
Heroku doesn't count IOPS and bandwidth, Supabase does.
Heroku doesn't react for months if you exceed your storage limit, even by 50%.
Prisma Postgres is based on the observation that modern hardware is incredibly powerful and cheap which was recently promoted by Basecamp
Are you serious?
Why is Prisma Postgres more expensive compared to Heorku, then?
Isn't everyone on the list more expensive than Heroku?
Speaking of modern bare-metal hardware, you could get a 6-core CPU, 64 GB of RAM and a 512 GB storage for about $49/mo.
This makes it possible to deploy highly-available triple-redundant cluster of Postgres'es spanning several data centers for under $200 a month.
Compliance
It really bothered me that in addition to the internal unique LinkedIn identifier like Y09Dr0pJaw
sufficient for my functional requirements, Supabase stores users email addresses.
An email address is considered information that can be used to identify a person.
But there is no option to tell Supabase not to store email addresses.
SOC2 will cost you nearly $7200 a year.
Conclusion
With all the complexity around Supabase, it's easy to build a system that is insecure, slow, hard to maintain, and costly to run.
The "serverless" development model with Postgres available from client-side JavaScript via some ad-hoc API, an ORM, or GrapQL makes sense.
When you've figured out all the rough edges and undocumented tricks, Supabase does its "Build in a weekend" job, for small prototypes.
However, I would not rely on Supabase for "Scale to millions".