Nov 6, 20268 min read

Supabase Edge Functions: When They Fit and When You Need a Real Backend

Supabase Edge Functions run short TypeScript handlers on Deno at the edge. When that model wins for webhooks and light APIs, when always-on services with Postgres and queues on your own cloud fit better, and how Encore works alongside.

Supabase Edge Functions are TypeScript handlers on a Deno-compatible edge runtime, placed close to users for short request and response work. They fit webhooks, auth-adjacent glue and small APIs that call Supabase Auth, Postgres or Storage and return quickly.

They are not a substitute for an always-on API process with durable workers, multi-minute jobs and cloud resources you own end to end. This guide covers when Edge Functions win, where the hosted limits push you toward an always-on backend, and how Encore works alongside that split on your own AWS or GCP account.

Edge Functions at a glance

ConcernSupabase Edge FunctionsAlways-on backend (e.g. Encore on your cloud)
RuntimeDeno-compatible TypeScript at the edgeContainers / services in your AWS or GCP account
LifetimeWorker wall clock 150s or 400s by plan; 2s CPU per requestLong-lived processes; jobs and queues you design
Memory256 MBSized per service
DataCalls into Supabase Postgres, Auth, StorageDeclared Postgres, object storage, Pub/Sub, cron, secrets
Cold startsPossible; design for short, idempotent workWarm services; scale policy per environment
Best fitWebhooks, light HTTP, edge glueProduct API + workers on infrastructure you control

What Edge Functions are good at

Edge Functions sit behind Supabase's edge gateway, which can validate JWTs and route traffic before your handler runs. Supabase's Edge Functions documentation lists the usual jobs: Stripe or GitHub webhooks, small authenticated HTTP endpoints, on-demand image or Open Graph generation, short AI orchestration calls, bots and transactional email triggers.

Local iteration uses the Supabase CLI (supabase functions serve), and deploy goes through the CLI, dashboard or automation. For a product that already keeps Auth, Postgres and Storage in Supabase, putting a thin TypeScript handler next to those products avoids standing up a second HTTP stack for glue that should stay near the data.

Hosted limits that shape the design

Supabase documents runtime and platform limits for the hosted platform: 256 MB memory, wall-clock worker lifetime of 150 or 400 seconds depending on the project plan, 2 seconds of CPU time per request, and a 150 second request idle timeout that returns 504 if the handler never answers. Function bundles are capped (20 MB when built locally via the CLI, 5 MB when bundled server-side), and secrets are project-scoped with their own size and naming rules.

Workers can stay warm and serve more than one request inside that wall-clock window, and background work can continue with patterns such as EdgeRuntime.waitUntil, but nothing escapes those caps. Outbound SMTP ports 25 and 587 are blocked; HTML responses need a custom domain; multithreading native addons such as sharp are not supported. Cold starts remain possible, so handlers should stay short and idempotent.

When you need a real backend

Long-lived Postgres connection pools, multi-minute batch jobs, consumers that read a queue continuously, cron schedules that must fire without an inbound HTTP request, and multi-service layouts with private networking push past what edge isolates are for. Supabase's own guidance moves heavy or long-running work to background workers, queues or cron outside the request path.

Compliance and enterprise buyers often want the API, database and messaging in an AWS or GCP account they already operate, with IAM roles and VPC placement they can audit. Edge Functions can still front webhooks into that estate; the always-on system then owns the durable state and the workers.

How Encore works alongside Edge Functions

Encore

Encore works alongside Edge Functions and other pieces of your stack. The platform provisions and deploys into your own AWS or GCP account: keep Deno handlers for low-latency Supabase-adjacent glue, and put the product API, Postgres, object storage, Pub/Sub, cron jobs and secrets in Encore on your own cloud, as covered in the AWS infrastructure docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Set up the always-on side

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create orders --example=ts/hello-world$ cd orders && mkdir -p orders/migrations$ cat > orders/migrations/1_create_order_event.up.sql <<'SQL'CREATE TABLE order_event (event_id TEXT PRIMARY KEY,order_id TEXT NOT NULL,amount_cents INTEGER NOT NULL);CREATE TABLE fulfillment_outbox (event_id TEXT PRIMARY KEY,order_id TEXT NOT NULL,amount_cents INTEGER NOT NULL,published_at TIMESTAMPTZ);CREATE TABLE fulfillment_applied (event_id TEXT PRIMARY KEY);SQL

A typical split is an Edge Function that verifies a Stripe webhook and forwards a small JSON payload to your Encore API, while Encore owns the order row, a queue for fulfillment and a nightly reconciliation cron. The handler writes the fulfillment to an outbox in the same transaction as the order event. A relay publishes pending rows and retries them, and the webhook is acknowledged only once that publish is recorded. The consumer still dedupes on eventId.

// orders/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("orders");
// orders/orders.ts import { api, APIError, ErrCode } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { Subscription, Topic } from "encore.dev/pubsub"; import { CronJob } from "encore.dev/cron"; import { secret } from "encore.dev/config"; const db = new SQLDatabase("orders", { migrations: "./migrations" }); const webhookSecret = secret("WebhookSharedSecret"); export interface IngestParams { eventId: string; orderId: string; amountCents: number; sharedToken: string; } interface FulfillmentEvent { eventId: string; orderId: string; amountCents: number; } const fulfillments = new Topic<FulfillmentEvent>("order-fulfillments", { deliveryGuarantee: "at-least-once", }); async function relayFulfillments(): Promise<void> { const pending = await db.queryAll<{ event_id: string; order_id: string; amount_cents: number; }>` SELECT event_id, order_id, amount_cents FROM fulfillment_outbox WHERE published_at IS NULL ORDER BY event_id `; for (const row of pending) { await fulfillments.publish({ eventId: row.event_id, orderId: row.order_id, amountCents: row.amount_cents, }); await db.exec` UPDATE fulfillment_outbox SET published_at = NOW() WHERE event_id = ${row.event_id} AND published_at IS NULL `; } } export const ingest = api( { expose: true, method: "POST", path: "/internal/ingest" }, async (p: IngestParams): Promise<{ ok: boolean }> => { if (p.sharedToken !== webhookSecret()) { throw APIError.unauthenticated("unauthorized"); } // order_event and the outbox row commit together. A retry is not // acknowledged until this event's outbox row has been published. await using tx = await db.begin(); await tx.exec` INSERT INTO order_event (event_id, order_id, amount_cents) VALUES (${p.eventId}, ${p.orderId}, ${p.amountCents}) ON CONFLICT (event_id) DO NOTHING `; await tx.exec` INSERT INTO fulfillment_outbox (event_id, order_id, amount_cents) SELECT event_id, order_id, amount_cents FROM order_event WHERE event_id = ${p.eventId} ON CONFLICT (event_id) DO NOTHING `; await tx.commit(); const published = await db.queryRow<{ event_id: string }>` SELECT event_id FROM fulfillment_outbox WHERE event_id = ${p.eventId} AND published_at IS NOT NULL `; if (!published) { await relayFulfillments(); const done = await db.queryRow<{ event_id: string }>` SELECT event_id FROM fulfillment_outbox WHERE event_id = ${p.eventId} AND published_at IS NOT NULL `; if (!done) { throw new APIError(ErrCode.Unavailable, "fulfillment not published"); } } return { ok: true }; }, ); // At-least-once delivery can run this more than once; the applied row commits // only with the work, so a failed attempt is retried and a duplicate is a no-op. const _applied = new Subscription(fulfillments, "apply-fulfillment", { handler: async (event) => { await using tx = await db.begin(); const first = await tx.queryRow<{ event_id: string }>` INSERT INTO fulfillment_applied (event_id) VALUES (${event.eventId}) ON CONFLICT (event_id) DO NOTHING RETURNING event_id `; if (!first) return; await tx.commit(); }, }); export const relayOutbox = api({}, async () => { await relayFulfillments(); }); export const reconcile = api({}, async () => { // Compare order_event rows to the payment provider on a schedule. }); const _relay = new CronJob("relay-fulfillments", { title: "Relay fulfillment outbox", every: "1h", endpoint: relayOutbox, }); const _reconcile = new CronJob("reconcile-orders", { title: "Reconcile orders", every: "1h", endpoint: reconcile, });

encore run starts Postgres locally for the database. Cron jobs do not run locally; invoke relayOutbox and reconcile by hand while developing. Secret values are set with encore secret set and are not stored in the repo, so AI coding agents working in the tree cannot read them from git. On AWS, Encore provisions managed Postgres and messaging and provisions an IAM role for the service. See the TypeScript Pub/Sub docs, SQL databases and cron jobs.

An AI coding agent can add another topic or cron endpoint in the same service and test the database path with encore run before you deploy.

Best fit: Teams that keep Supabase Edge Functions for edge glue and need an always-on TypeScript or Go backend with Postgres, queues and cron in their own cloud.

When to pick which

SituationPrefer
Stripe webhook, JWT-gated helper, OG imageSupabase Edge Functions
Product REST/GraphQL API with pooled PostgresAlways-on backend (Encore or equivalent)
Multi-minute jobs, queue consumers, hourly reconcileAlways-on workers + cron
Must run under your AWS/GCP IAM and VPCEncore (or hand-rolled) on your account
Both webhook latency and durable domain logicEdge Function in front, Encore behind

Frequently asked questions

What are Supabase Edge Functions?

Supabase Edge Functions are TypeScript handlers that run on a Deno-compatible edge runtime close to users. Teams use them for webhooks, light HTTP endpoints and glue that talks to Supabase Auth, Postgres and Storage.

What are the main limits of Supabase Edge Functions?

Hosted workers stay active up to 150 or 400 seconds depending on the project plan, with a 2 second CPU time budget per request and a 150 second request idle timeout. Memory is capped at 256 MB, and cold starts are possible.

When do I need a real backend instead of Edge Functions?

Move work to always-on services when you need long-lived connection pools, multi-minute jobs, independent queue consumers, cron that is not tied to an HTTP request, or infrastructure that must live in your own AWS or GCP account under your IAM.

Can Encore work alongside Supabase Edge Functions?

Yes: keep Edge Functions for low-latency glue next to Supabase Auth or Storage, and run the always-on API, Postgres, queues and cron in Encore on your own cloud, since Encore works alongside the frameworks and BaaS pieces you already use.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders