Supabase Edge Functions run next to your database and can safely hold a service-role key, which makes them the right home for privileged data work, webhooks, and scheduled jobs. Vercel Functions run alongside your frontend and are the natural place for anything the app calls while rendering or handling a user action. The split follows privilege, not performance.
The rule
Does this code need to act with more authority than the user calling it?
If yes, it belongs next to the database. Privileged work wants to live where the privileged credential already lives, and where it never has to travel through your frontend deployment.
If no — it acts as the user, as part of something the app is doing — it belongs with the frontend, where it shares the session, the environment, and the deployment.
Almost everything else people argue about here follows from that.
Side by side
| Supabase Edge Functions | Vercel Functions | |
|---|---|---|
| Runs next to | The database | The frontend |
| Natural credential | Service role | The user’s session |
| Deploys with | Its own command, per function | The app, automatically |
| Best for | Webhooks, cron, privileged writes | App API routes, rendering data |
| Database latency | Very low | A network hop away |
| Frontend integration | Separate | Same repo, same env |
| Timeout ceiling | Generous but finite | Plan-dependent, often short |
Why webhooks belong on the database side
A webhook is an unauthenticated request from a third party that needs to write privileged data. There is no user session, and the payload is the only thing you have.
Putting that on the frontend deployment means shipping a service-role credential into the same environment as your web app and writing your own verification. Putting it next to the database means the privileged credential never leaves the place it belongs.
Same logic for scheduled work. A nightly job has no user, needs full access, and should not depend on your web deployment being healthy.
How they fail differently
This is the useful part, because it is what you actually spend time on.
The timeout kills long model calls silently. The worst failure I have hit in this area: a function makes a call to a language model, the model takes longer than the platform ceiling, and the request is cut. The caller frequently sees a generic error rather than a timeout, so it reads as a bug in your own logic and you go looking in the wrong place.
The fix is architectural, not a config change. Anything that can exceed the ceiling has to stop being a request and become a job: one endpoint accepts the work and returns an identifier, a worker does it, and the client polls. Raising the timeout postpones the problem rather than solving it, because model latency is not something you control.
Deploying many functions at once fails badly. On the product with 98 of them, batch deploys produce failures that are unpleasant to untangle — you end up unsure which functions actually updated. Deploy one at a time. It is slower and it is the only approach I trust now.
Deployment order matters across the boundary. If a frontend release expects a new function signature, deploy the function first. Doing it the other way leaves a window where the app calls something that does not exist yet, and on a busy product that window is real traffic.
Environment variables inline at build time. On the frontend side, anything exposed to the browser is baked in when you build, not read at runtime. Changing it in a dashboard does nothing until you rebuild. Every team learns this once, usually during an incident.
What surprised me at 98 functions
Two things, neither of which is about performance.
Discoverability becomes the real problem. At that count, knowing which function does what, and which are still used, matters more than any runtime characteristic. Naming conventions and a shared module for common concerns stop being tidiness and start being how you keep the thing maintainable.
Shared code is what keeps it sane. On that product, cross-cutting concerns like sending email live in shared modules that the functions import, rather than being reimplemented per function. When the email provider changed, that was one module rather than a search across ninety-eight files.
How I split it
| Work | Where | Why |
|---|---|---|
| Third-party webhooks | Supabase | No session, needs privilege |
| Scheduled jobs | Supabase | No user, must not depend on the web deploy |
| Privileged writes across tenants | Supabase | Service role stays put |
| Sending email | Supabase | Triggered by data events, not page loads |
| App API routes | Vercel | Shares the session and the deploy |
| Server-rendered data fetching | Vercel | Part of the render |
| Anything on the critical render path | Vercel | Fewer hops |
Practical notes
- Never let a function await something unbounded. Queue it and poll.
- Deploy edge functions one at a time, and the function before the frontend that calls it.
- Put shared concerns in shared modules from function three, not function thirty.
- Log the identity a function is acting as. Half of privileged-function bugs are "it ran as the wrong thing", and that is invisible unless you record it.
- Verify after deploying, do not trust the CLI. Fetch a version endpoint or a content-hashed asset and confirm the new code is actually serving.
FAQ
What is the difference?
Supabase functions run next to the database and can safely hold a service-role key. Vercel functions run with your frontend and share its session and deployment.
Which should handle a webhook?
Whichever owns the data it writes, which is usually the database side. A webhook has no session and needs privileged write access.
Why do long-running functions fail silently?
The platform timeout cuts the request mid-call, often surfacing as a generic error rather than a timeout. Anything that can exceed the ceiling must become a queued job.
Can I use both?
Yes, and most real projects do. Split on privilege.
Related: Supabase vs Firebase, other tool comparisons, and Postgres row-level security.