Edge Functions are the most underrated feature in Supabase, and simultaneously the most misused.
Underrated because of the assumption that "if the frontend can hit the database directly, who needs them?" Misused because people put heavy work in them without knowing the constraints. CPU execution is capped at 2 seconds per request. Whether you know that one line decides whether the design succeeds.
This article is an implementation guide for getting real production use out of Supabase Edge Functions. Faithful to the official documentation as of 2026-08-16, it covers the adopt-or-not criteria, handling auth declaratively with withSupabase from @supabase/server, receiving webhooks idempotently, background work after the response, and testing, logging, and CI/CD — with code you can use as-is.
1. Start with the limits: what to put in, what to keep out
Edge Functions run in a "Deno compatible runtime with TypeScript first." The official limits:
| Item | Value |
|---|---|
| CPU time | 2s per request (actual time spent on the CPU) |
| Wall-clock duration | Free 150s / paid 400s |
| Memory | 256MB |
| Request idle timeout | 150s (504 Gateway Timeout if no response) |
| Function size | 20MB (CLI bundled) / 5MB (server-side bundled) |
| Functions per project | Free 100 / Pro 500 / Team 1,000 / Enterprise unlimited |
| Log message length | Up to 10,000 characters |
| Log volume | 100 events per 10 seconds |
| Secrets | 100 per project, names up to 256 characters, up to 48 KiB |
Ephemeral storage (/tmp) | Free 256MB / paid 512MB |
The fact that "2s CPU" and "150–400s wall clock" are different things is the key to designing with this feature. Waiting 30 seconds for an external API doesn't burn CPU while you wait, so it doesn't consume the 2 seconds.
Which yields:
| Fits well | Doesn't fit |
|---|---|
| Receiving webhooks (Stripe, GitHub, payments, CRM) | Encoding / transforming images and video |
| Calling and shaping external APIs (BFF-style relaying) | Large-scale aggregation and report generation |
| Relaying an LLM response stream | Heavy cryptography or ML inference |
| Work requiring a server-only secret key | Bundles with huge dependencies (size limits) |
| Lightweight scheduled jobs from Cron | Long-running batches (use database-side Cron/Queues) |
| Business rules RLS can't express | Processes that must stay resident |
If heavy work genuinely has to run, let the Edge Function accept and record it, and delegate execution to a queue (chapter 6).
Where they run
Requests hit a global API gateway, which determines geographic location from the IP address and routes to the nearest edge location (for example, an Amsterdam request to Frankfurt). Each request runs in a new V8 isolate. The official docs note that "even initial executions are fast (milliseconds) due to the compact ESZip format and minimal Deno runtime overhead."
That said, if the database is far away, the whole thing isn't fast. Running near the user means nothing if the database round trip crosses half the planet. For database-heavy functions, measure before deciding placement.
2. Your first function and withSupabase
The 2026 standard is the withSupabase wrapper from the @supabase/server package. From the official announcement:
A new package that handles auth verification, client setup, request context, and common server-side boilerplate for you. It works across Edge Functions, Vercel Functions, Cloudflare Workers, Hono and Bun.
Creation through deployment is all CLI:
supabase functions new hello-world # creates supabase/functions/hello-world/index.ts
supabase start # start the local stack
supabase functions serve hello-world # run locally
supabase functions deploy hello-world # deploy
The generated template:
export default {
fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req, ctx) => {
const { name } = await req.json()
return Response.json({
message: `Hello ${name}!`,
})
}),
}
Invoking it locally:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \
--header 'apiKey: <SUPABASE_PUBLISHABLE_KEY>' \
--data '{"name":"Functions"}'
"Declare" the auth mode
The heart of withSupabase is the auth option. It isn't configuration — it's a declaration of who may call this function.
// authenticated users only (default)
withSupabase({ auth: 'user' }, handler)
// no auth required, good for webhooks and health checks
withSupabase({ auth: 'none' }, handler)
// server-to-server with secret key
withSupabase({ auth: 'secret' }, handler)
// with publishable key
withSupabase({ auth: 'publishable' }, handler)
// accept either a user JWT or a secret key
withSupabase({ auth: ['user', 'secret'] }, handler)
You can also accept only one specific key. Writing auth: 'secret:automations' admits only the secret key you named "automations" under Settings > API keys in the dashboard (publishable:<name> works the same way).
That looks minor and isn't. It lets you express in code that "the batch function is callable only with the batch key," and if a key leaks, the blast radius closes with revoking that single key.
What ctx hands you
interface SupabaseContext {
supabase: SupabaseClient // user-scoped, respects RLS
supabaseAdmin: SupabaseClient // admin client with service role
userClaims: UserIdentity | null
jwtClaims: JWTClaims | null
authMode: AuthMode
}
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
// ctx.supabase is automatically scoped to the calling user's RLS policies
const { data, error } = await ctx.supabase.from('todos').select()
if (error) return Response.json({ error: error.message }, { status: 500 })
return Response.json({ data, email: ctx.userClaims?.email })
}),
}
Choosing between ctx.supabase and ctx.supabaseAdmin is the security design of this feature.
- Default to
ctx.supabase. RLS applies, so even an authorization mistake doesn't leak data. ctx.supabaseAdminbypasses RLS completely. Use it only for work that legitimately crosses users and cannot be expressed in RLS (admin aggregation, creating notifications for other users). Comment the reason at the call site and make it a mandatory review question.
The moment supabaseAdmin starts getting used "because an error came back," RLS becomes decorative. This is a genuinely common audit finding (see also handling the service_role key).
Its relationship with verify_jwt
Platform-level JWT verification is controlled in config.toml:
[functions.hello-world]
verify_jwt = false
- User-facing functions: leave
verify_jwt = true(the default) and useauth: 'user'. The platform verifies the JWT before your handler runs - Webhook receivers: set
verify_jwt = false, useauth: 'none', and write signature verification yourself (chapter 5) - Server-to-server:
verify_jwt = false+auth: 'secret'
If you plan the signing key rotation from chapter 4, inventory the functions using
Verify JWTfirst. The official docs warn that "if you're using Edge Functions that have the Verify JWT setting, continuing with the rotation might break your app" (Supabase Auth implementation guide).
3. Routing: don't multiply functions — bundle them
Creating ten functions, one per capability, is usually the wrong move. The official docs agree: combining multiple actions into one function "reduces cold starts and improves performance by keeping one instance warm for multiple endpoints." It also helps against the per-project function cap (Free 100).
Hono is the best-fitting option.
import { Hono } from 'jsr:@hono/hono@^4'
const app = new Hono()
app.get('/hello-world', (c) => {
return c.json({ message: 'Hello World!' })
})
app.post('/hello-world', async (c) => {
const { name } = await c.req.json()
return c.json({ message: `Hello ${name}!` })
})
export default { fetch: app.fetch }
There is one pitfall you will definitely hit. In the official wording: "Within Edge Functions, paths should always be prefixed with the function name." Routes follow /functions/v1/<function-name>/<path>, and the paths you register with Hono must include the function name too. That's why the example above says app.get('/hello-world', ...). Change it to / and you get a 404.
Express (npm:express@^5) and Oak work as well, but for an edge environment the lightweight Hono — or the standard URL Pattern API if you'd rather add no dependency — is plenty.
4. Secrets and environment variables
Several environment variables are injected by default:
| Variable | Contents |
|---|---|
SUPABASE_URL | The API gateway for your Supabase project |
SUPABASE_DB_URL | The URL for your Postgres database |
SUPABASE_ANON_KEY | The anon key |
SUPABASE_SERVICE_ROLE_KEY | The service_role key |
SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYS | JSON dictionaries of API keys |
Read them with Deno's native method:
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')
if (!stripeSecret) throw new Error('STRIPE_SECRET_KEY is not configured')
Checking for presence at startup is the practical detail. An unset secret flows quietly as undefined, and you find out only when the external API returns an auth error — a pure waste of time.
Configuration:
# Local: supabase/functions/.env (add it to .gitignore!)
supabase functions serve --env-file .env.local
# Production
supabase secrets set --env-file .env
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets list
The official warning, verbatim: "Never check your .env files into Git!" And SUPABASE_SECRET_KEYS "should NEVER be used in a browser" — it bypasses Row Level Security.
5. Receiving webhooks idempotently: the core pattern
Webhook reception is the highest-value use of Edge Functions. It is also the most fragile, for one simple reason: webhooks are always retried. Network blips, timeouts, provider-side retries — the same event arriving twice is not an anomaly, it's the specification.
The correct order:
1. Verify the signature ← reject forged events
2. INSERT the event ID (unique) ← reject duplicate delivery in the database
3. Return 2xx immediately ← stop the provider's retries
4. Push heavy work to waitUntil ← continue after the response
First the record table. The key move is delegating the idempotency decision to a database unique constraint rather than an application if. Even two simultaneous deliveries let exactly one through.
create table public.webhook_events (
-- The provider's event ID. This is the idempotency key
event_id text primary key,
provider text not null,
received_at timestamptz not null default now(),
processed_at timestamptz,
payload jsonb not null
);
alter table public.webhook_events enable row level security;
-- No policies at all = nobody can read. Writes only via service_role
Now the function.
The signature-verification API names depend on the provider's SDK. The skeleton below uses Stripe as the example. Just confirm against the SDK version you're on that you use the async verification and a Web Crypto-based provider in the Deno runtime (helper names differ across versions). The skeleton — verify with the raw body → INSERT under an idempotency key → return 2xx immediately → background work — is the same regardless of provider.
import { withSupabase } from 'npm:@supabase/server@^1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@^2'
import Stripe from 'npm:stripe'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
// Use a fetch-based client on Deno
httpClient: Stripe.createFetchHttpClient(),
})
// Signature verification uses a Web Crypto (SubtleCrypto) based provider
const cryptoProvider = Stripe.createSubtleCryptoProvider()
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!
export default {
// Webhooks: verify_jwt = false + auth: 'none'. Authentication is the signature check
fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
const signature = req.headers.get('stripe-signature')
if (!signature) return new Response('missing signature', { status: 400 })
const body = await req.text() // signature verification needs the raw body
let event: Stripe.Event
try {
// Async variant. On Deno use this rather than constructEvent
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret,
undefined,
cryptoProvider,
)
} catch {
// Don't return verification detail (it's information for an attacker)
return new Response('invalid signature', { status: 400 })
}
// Idempotency: a unique violation means already processed
const { error } = await ctx.supabaseAdmin.from('webhook_events').insert({
event_id: event.id,
provider: 'stripe',
payload: event as unknown as Record<string, unknown>,
})
if (error) {
// 23505 = unique_violation. Already received, so return 200 to stop retries
if (error.code === '23505') return new Response('duplicate', { status: 200 })
// Anything else means we failed to record it — we want a retry, so 5xx
return new Response('storage error', { status: 500 })
}
// Heavy work after the response, so we never hit the provider's timeout
EdgeRuntime.waitUntil(handleEvent(ctx, event))
return new Response('ok', { status: 202 })
}),
}
async function handleEvent(ctx: { supabaseAdmin: SupabaseClient }, event: Stripe.Event) {
try {
switch (event.type) {
case 'checkout.session.completed':
// business logic
break
default:
break
}
await ctx.supabaseAdmin
.from('webhook_events')
.update({ processed_at: new Date().toISOString() })
.eq('event_id', event.id)
} catch (err) {
// Rows left with a null processed_at surface as reprocessing candidates
console.error(JSON.stringify({ level: 'error', event_id: event.id, message: String(err) }))
}
}
The design decisions, made explicit:
- Verify the signature first. Reject before writing to the database, or forged events can inflate the table.
- Return 200 for
23505(unique violation). Return 500 there and the provider retries forever. - Return 5xx when recording fails. "Received but not recorded" is exactly the state where you want a retry.
- Rows left with a null
processed_atbecome the reprocessing queue. Failure visibility comes for free, with no separate mechanism. - Return
202 Accepted. It correctly expresses "received, processing to follow."
6. Background tasks: working after you've responded
EdgeRuntime.waitUntil(promise) keeps the function instance running until the Promise you pass completes. The official phrasing: "The Function instance continues to run until the promise provided to waitUntil completes."
You can call it inside or outside the handler.
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
// Start background work without blocking the response
EdgeRuntime.waitUntil(asyncLongRunningTask())
return Response.json({ ok: true })
}),
}
It is not unbounded. The official caveat:
The maximum duration is capped based on the wall-clock, CPU, and memory limits. The function will shut down when it reaches one of these limits.
Shutdown is detectable:
addEventListener('beforeunload', (ev) => {
console.log('Function will be shutdown due to', ev.detail?.reason)
// Save state or log the current progress
})
This is not optional code. If a background task is cut off mid-flight and leaves nothing behind, it becomes a vanished process. Record progress in beforeunload and the next run knows where to resume.
Trying background tasks locally requires configuration:
[edge_runtime]
policy = "per_worker"
This setting disables auto-reload, so restart supabase functions serve after each code change.
When you outgrow background tasks
Work that doesn't finish in a few minutes isn't an Edge Function's job. Switch to accept it, enqueue it, and run it elsewhere. If you want to stay inside Supabase, database-side Cron and Queues are the natural home. This decision gets more expensive the longer you postpone it on the grounds that "it still works," so start planning the move once expected processing time approaches half the limit.
7. Streaming, WebSockets, and temporary files
WebSockets
Deno.upgradeWebSocket() is available.
export default {
fetch: (req) => {
const upgrade = req.headers.get('upgrade') || ''
if (upgrade.toLowerCase() != 'websocket') {
return Response.json(
{ error: "request isn't trying to upgrade to WebSocket." },
{ status: 400 }
)
}
const { socket, response } = Deno.upgradeWebSocket(req)
socket.onopen = () => console.log('socket opened')
socket.onmessage = (e) => {
console.log('socket message:', e.data)
socket.send(new Date().toString())
}
socket.onerror = (e) => console.log('socket errored:', e.message)
socket.onclose = () => console.log('socket closed')
return response
},
}
Authentication needs care. Browsers can't send custom headers when opening a WebSocket, so the official guidance is to pass credentials "via URL query params or via a custom protocol." Deploying requires the --no-verify-jwt flag.
Bluntly, though: realtime communication between clients is Supabase Realtime's job. WebSockets in Edge Functions are genuinely needed only for cases Realtime can't express, such as relaying to an external WebSocket API (Supabase Realtime implementation guide).
Temporary files (/tmp)
const uploadId = crypto.randomUUID()
await Deno.writeFile('/tmp/' + uploadId, req.body)
const zipFile = await Deno.readFile('/tmp/' + uploadId)
Capacity is up to 256MB on Free and up to 512MB on paid plans. And the crucial property:
Ephemeral storage will reset on each function invocation. This means the files you write during an invocation can only be read within the same invocation.
/tmp cannot be used as a cache. Synchronous APIs such as Deno.statSync() also work "only during initial script evaluation" and cannot be used inside HTTP handlers or callbacks.
8. Testing: extract pure functions and it runs in milliseconds
If testing Edge Functions feels hard, that's a sign the logic is buried in the handler. The official guide likewise recommends writing business logic as pure functions without side effects, and notes that isolated unit tests "run in milliseconds."
The recommended layout:
supabase/
├── functions/
│ ├── _shared/
│ │ └── types.ts
│ ├── process-ticket/
│ │ ├── index.ts ← the HTTP entry point only
│ │ └── pricing.ts ← pure logic
│ └── tests/
│ ├── utils/
│ │ └── supabase_env.ts
│ └── process-ticket/
│ ├── pricing.test.ts
│ └── index.test.ts
├── config.toml
└── deno.json
// supabase/functions/process-ticket/pricing.ts — pure. No I/O
export interface TicketInput {
readonly basePriceJpy: number;
readonly quantity: number;
readonly isMember: boolean;
}
const MEMBER_DISCOUNT_RATE = 0.1;
/** Compute the total. Rounding is always down, to match the accounting side */
export function calculateTotalJpy(input: TicketInput): number {
if (!Number.isInteger(input.quantity) || input.quantity < 1) {
throw new RangeError("quantity must be a positive integer");
}
const subtotal = input.basePriceJpy * input.quantity;
const discount = input.isMember ? Math.floor(subtotal * MEMBER_DISCOUNT_RATE) : 0;
return subtotal - discount;
}
// supabase/functions/tests/process-ticket/pricing.test.ts
import { assertEquals, assertThrows } from 'jsr:@std/assert'
import { calculateTotalJpy } from '../../process-ticket/pricing.ts'
Deno.test('members get 10% off (fractions rounded down)', () => {
assertEquals(calculateTotalJpy({ basePriceJpy: 1050, quantity: 3, isMember: true }), 2835)
})
Deno.test('a quantity of zero or less throws', () => {
assertThrows(() => calculateTotalJpy({ basePriceJpy: 1000, quantity: 0, isMember: false }))
})
deno test supabase/functions/tests/process-ticket/pricing.test.ts
deno test supabase/functions/tests/process-ticket/index.test.ts --allow-env
deno task test
For integration tests, mock globalThis.fetch. In the official wording, this intercepts Supabase REST calls "without modifying production code," letting you "test the real Edge Function code path." Being able to exercise the whole path without rewriting the handler is the advantage.
9. Observability: structured logs, not printf
Recorded automatically: uncaught exceptions during execution, custom logs via console.log / console.error / console.warn, and boot and shutdown logs. The dashboard's Functions section shows Invocations (request/response data including headers, body, status codes, and execution duration) and Logs (platform events, uncaught exceptions, and custom log messages).
The limits shape your implementation:
- Up to 10,000 characters per message — dumping a large object verbatim gets truncated
- 100 events per 10 seconds —
console.loginside a loop pushes out the logs you actually need
Given that, console.log(payload) is useless in production. Use structured logs.
type LogLevel = 'info' | 'warn' | 'error';
interface LogFields {
readonly event: string;
/** Correlation ID — lets you follow one request across lines */
readonly requestId: string;
readonly [key: string]: unknown;
}
/** No PII, single-line JSON, leveled. Honor those three and it stays searchable later */
export function log(level: LogLevel, fields: LogFields): void {
const line = JSON.stringify({ level, ts: new Date().toISOString(), ...fields });
// Anything over 10,000 characters gets cut, so drop the excess explicitly
const safe = line.length > 9_500 ? `${line.slice(0, 9_500)}…"truncated":true}` : line;
if (level === 'error') console.error(safe);
else if (level === 'warn') console.warn(safe);
else console.log(safe);
}
Three things to measure:
- Error rate (per function, per error type)
- Duration distribution (is p95 creeping toward the wall-clock limit?)
- Background task completion rate (
beforeunloadfirings ÷ starts)
The third is invisible unless you measure it yourself. And background tasks being cut off silently is the failure in Edge Functions that takes longest to discover.
10. Deployment and CI/CD
name: Deploy Function
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
PROJECT_ID: your-project-id
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase functions deploy --project-ref $PROJECT_ID
In practice, put tests in front of that:
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno test supabase/functions/tests/ --allow-env
- run: supabase functions deploy --project-ref $PROJECT_ID
SUPABASE_ACCESS_TOKEN goes in repository secrets. Application secrets (STRIPE_SECRET_KEY and friends) do not belong here — those are set on the project with supabase secrets set, and the principle is to avoid creating any path where they can reach CI logs.
11. Cost: what per-invocation billing implies
| Item | Free | Pro |
|---|---|---|
| Invocations | 500,000/month included | 2 million/month included, then $2 per 1 million |
Per-invocation billing is itself a design guideline.
- Stop polling. A hundred clients asking for status every 5 seconds is roughly 1.73 million calls a day — enough on its own to exceed the free allowance. The same thing is achievable with a Realtime subscription at zero invocations.
- Bundle functions. The routing in chapter 3 helps with cold starts and the function cap, and simplifies operations too.
- Control retries. Unbounded client-side retries multiply invocations exponentially during an incident. Exponential backoff and a retry ceiling are mandatory.
12. Pre-production checklist
- There are grounds that this work fits in 2 seconds of CPU (no heavy transformation or aggregation)
- p95 duration leaves ample room against the wall-clock limit (Free 150s / paid 400s)
- The
withSupabaseauthmode matches who actually calls the function - Every use of
ctx.supabaseAdminhas a commented, legitimate reason for bypassing RLS - Webhook functions use
verify_jwt = false+auth: 'none'+ signature verification - Webhook idempotency rests on a database unique constraint (not an application
if) - Duplicate deliveries return 200 and recording failures return 5xx
- Background tasks have a
beforeunloadhandler that records progress - Required secrets are validated for presence at startup
-
.envis in.gitignore - Logs are structured, PII-free, and under 10,000 characters
- No
console.loginside loops (100 events per 10 seconds) - Logic is extracted as pure functions and runs under
Deno.test - CI runs tests before deploying
- Client polling has been replaced with Realtime (invocation-count billing)
Conclusion: the constraints tell you the design
The constraints of Edge Functions — 2s CPU, 256MB memory, 150–400s duration — are not an inconvenience. They are a design brief. Work that fits in that envelope has a shape: validate the input, talk to the outside world, record the result. Which is precisely the shape of what an application's server side should be doing in the first place.
And withSupabase from @supabase/server strips the boilerplate out of that work. Auth verification, client creation, RLS scoping — code that used to be copied into every function collapses into a one-line auth declaration. What's left is the business logic.
One last emphasis. Default to ctx.supabase, and use ctx.supabaseAdmin only after writing down why. Edge Functions run server-side, so anything is possible. Whether you keep deliberately narrowing permissions in a place where anything is possible is what decides whether that system is still safe several years from now.