All Articles
CategoryBackend
Reading Time
14 min read
Published
2026-04-18
Word Count
3,298words

Grab a coffee — this one is a deep dive!

Supabase Edge Functions: Serverless Backend with the Deno Runtime in 2026

Summary

Supabase Edge Functions, Deno 2 runtime, Postgres RLS integration, Auth/Storage webhooks, cold start optimization, and a real-world deploy experience.

  • Supabase Edge Functions runs on Deno 2, with an average cold start of ~100ms (Node.js Lambda: 400-800ms).
  • There are two client patterns: the Service Role Client bypasses RLS, and the User Context Client keeps RLS active.
  • EdgeRuntime.waitUntil() lets you run a background task after the response, with a CPU time limit of 50ms (max 2s).
  • Free tier: 500K invocations/month, Pro: $2/1M invocations; memory limit 256MB, execution timeout 150 seconds.
Supabase Edge Functions: Serverless Backend with the Deno Runtime in 2026

The most critical problem in serverless backend architecture has always been the same: cold start time. AWS Lambda's Node.js runtime creates real problems in production with cold start times of 400-800ms. Supabase Edge Functions, built on the Deno 2 runtime, changes this equation entirely. In real-world benchmarks it hits a ~100ms cold start, a quarter of the time of Node.js-based alternatives.

In this piece I'll go through Supabase Edge Functions from the perspective of a senior developer using it in production. We'll dig into what Deno 2 brings to the table, Postgres RLS integration, Auth and Storage webhooks, secret management, and the real deployment experience.

💡 Pro Tip: The most critical performance gain for Supabase Edge Functions is minimizing the import chain in functions that start with Deno.serve(). Every external import adds 2-5ms to cold start. Use a lazy initialization pattern instead of top-level await.

Table of Contents


Deno 2 Runtime: How Is It Different from Node.js?

Deno 2 became stable at the end of 2024 and forms the technical foundation of Supabase Edge Functions. Compared to Node.js, the core differences are:

Security Model: Deno does not grant file system, network, or environment variable access by default. Explicit permissions like --allow-net and --allow-read are required. In the Edge Functions environment, these permissions are controlled by the platform.

Native TypeScript: No Babel or tsc pipeline needed. Deno runs TypeScript directly. It strips types and runs at runtime.

URL-based imports: Instead of Node.js's node_modules, Deno uses URL imports. jsr: (JavaScript Registry) and npm: prefixes give access to both modern and legacy packages.

Web Standards First: Web APIs like fetch, Request, Response, and Headers are natively supported. Things that need extra polyfills in Node.js are built in here.

Startup Speed: Thanks to V8 snapshot optimization and a small binary size, cold start is ~100ms. On Node.js Lambda this ranges from 400-800ms.

The most critical innovation Deno 2 brings is the maturation of the Node.js compatibility layer. With the npm: prefix you can now use Node.js packages like express, pg, and stripe directly in Deno. This is revolutionary for Supabase Edge Functions, because the ecosystem constraint has largely disappeared.


Supabase Edge Functions Architecture

Supabase Edge Functions run in a custom runtime built on top of Deno Deploy. Here's how the architecture works:

When a request comes in, Supabase's CDN layer routes it to the nearest edge node. At the edge node, the Deno runtime executes the function code inside a V8 isolate. This isolate reaches Postgres through Supabase's connection pooler (PgBouncer).

A critical point: every Edge Function invocation runs in its own isolated V8 context. There is no shared global state. This is advantageous for security, but it's also a constraint for stateful patterns.

Functions live in the supabase/functions/ directory. Each function has an index.ts file in its own subdirectory:

swift
1supabase/
2 functions/
3 hello-world/
4 index.ts
5 process-payment/
6 index.ts
7 _shared/
8 cors.ts
9 supabase-client.ts

The _shared/ directory is used for helper code shared across functions and is not included in deployment — it can only be imported.


Your First Edge Function: Basic Structure and Deploy

A minimal Edge Function looks like this:

typescript
1// supabase/functions/hello-world/index.ts
2import { corsHeaders } from '../_shared/cors.ts'
3 
4Deno.serve(async (req: Request) => {
5 // Early return for OPTIONS preflight
6 if (req.method === 'OPTIONS') {
7 return new Response('ok', { headers: corsHeaders })
8 }
9 
10 try {
11 const { name } = await req.json()
12 const data = {
13 message: `Hello ${name}!`,
14 timestamp: new Date().toISOString(),
15 }
16 return new Response(JSON.stringify(data), {
17 headers: { ...corsHeaders, 'Content-Type': 'application/json' },
18 status: 200,
19 })
20 } catch (error) {
21 return new Response(JSON.stringify({ error: error.message }), {
22 headers: { ...corsHeaders, 'Content-Type': 'application/json' },
23 status: 400,
24 })
25 }
26})
typescript
1// supabase/functions/_shared/cors.ts
2export const corsHeaders = {
3 'Access-Control-Allow-Origin': '*',
4 'Access-Control-Allow-Headers':
5 'authorization, x-client-info, apikey, content-type',
6 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
7}

The Supabase CLI is used for deployment:

bash
1# Login
2supabase login
3 
4# Local dev (test the function locally)
5supabase functions serve hello-world --env-file .env.local
6 
7# Production deploy
8supabase functions deploy hello-world
9 
10# Deploy all functions
11supabase functions deploy

In local development, the supabase functions serve command runs on Docker and supports hot reload. Use the --env-file flag for environment variables.


Auth Token Management from Inside supabase-js

Accessing Supabase from Edge Functions uses two different client patterns. Which client you use depends on the use case:

1. Service Role Client (for admin operations):

typescript
1import { createClient } from 'npm:@supabase/supabase-js@2'
2 
3// This client bypasses RLS - use carefully
4const supabaseAdmin = createClient(
5 Deno.env.get('SUPABASE_URL') ?? '',
6 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
7 {
8 auth: {
9 autoRefreshToken: false,
10 persistSession: false
11 }
12 }
13)

2. User Context Client (RLS active, safe):

typescript
1import { createClient } from 'npm:@supabase/supabase-js@2'
2import { corsHeaders } from '../_shared/cors.ts'
3 
4Deno.serve(async (req: Request) => {
5 if (req.method === 'OPTIONS') {
6 return new Response('ok', { headers: corsHeaders })
7 }
8 
9 // Get the JWT token from the Authorization header
10 const authHeader = req.headers.get('Authorization')!
11 
12 // Create the client with user context - RLS stays active
13 const supabaseClient = createClient(
14 Deno.env.get('SUPABASE_URL') ?? '',
15 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
16 {
17 global: {
18 headers: { Authorization: authHeader },
19 },
20 auth: {
21 autoRefreshToken: false,
22 persistSession: false
23 }
24 }
25 )
26 
27 // This query is subject to the calling user's RLS policies
28 const { data: userData, error: userError } = await supabaseClient.auth.getUser()
29 
30 if (userError || !userData.user) {
31 return new Response(
32 JSON.stringify({ error: 'Unauthorized' }),
33 { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
34 )
35 }
36 
37 // Fetch the user's own data - RLS filters automatically
38 const { data, error } = await supabaseClient
39 .from('user_documents')
40 .select('*')
41 
42 if (error) {
43 return new Response(
44 JSON.stringify({ error: error.message }),
45 { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400 }
46 )
47 }
48 
49 return new Response(
50 JSON.stringify({ data }),
51 { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }
52 )
53})

The critical advantage of this pattern: RLS policies apply even inside an Edge Function. A user can only access their own data, so you don't have to write extra authorization code.


Postgres RLS Trigger Pattern

One of Supabase's most powerful features is Row Level Security. But when integrating it with Edge Functions, some patterns are far more effective than others:

RLS Policy Example:

sql
1-- Users can only view their own profile data
2CREATE POLICY "Users can view own profile"
3ON profiles
4FOR SELECT
5USING (auth.uid() = user_id);
6 
7-- Users can only update their own data
8CREATE POLICY "Users can update own profile"
9ON profiles
10FOR UPDATE
11USING (auth.uid() = user_id)
12WITH CHECK (auth.uid() = user_id);
13 
14-- The admin role can access all data
15CREATE POLICY "Admins can do everything"
16ON profiles
17FOR ALL
18USING (
19 EXISTS (
20 SELECT 1 FROM user_roles
21 WHERE user_id = auth.uid()
22 AND role = 'admin'
23 )
24);

RLS Trigger Pattern with Edge Functions:

Sometimes you need Postgres to run an operation automatically when an Edge Function is triggered. For this, a combination of a Postgres trigger and an Edge Function is used:

sql
1-- Automatically create a profile when a new user signs up
2CREATE OR REPLACE FUNCTION public.handle_new_user()
3RETURNS trigger AS $$
4BEGIN
5 INSERT INTO public.profiles (id, email, full_name, created_at)
6 VALUES (
7 new.id,
8 new.email,
9 new.raw_user_meta_data ->> 'full_name',
10 now()
11 );
12 RETURN new;
13END;
14$$ LANGUAGE plpgsql SECURITY DEFINER;
15 
16-- Add a trigger to the users table in the auth schema
17CREATE TRIGGER on_auth_user_created
18 AFTER INSERT ON auth.users
19 FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();

This pattern runs at the Postgres level without an Edge Function and is much faster. Save Edge Functions for complex business logic rather than using them for simple cases like this.


Auth Hooks: beforeUser and afterUser

Supabase Auth Hooks let you hook into the authentication lifecycle. This feature is critical for integrating custom business logic:

beforeUser Hook (Custom Access Token):

typescript
1// supabase/functions/custom-access-token/index.ts
2Deno.serve(async (req) => {
3 const payload = await req.json()
4 
5 // Check the hook type
6 if (payload.type !== 'before.user.create') {
7 return new Response(JSON.stringify({}), { status: 200 })
8 }
9 
10 const userId = payload.user?.id
11 const email = payload.user?.email
12 
13 // Add custom claims
14 const customClaims = {
15 user_role: await getUserRole(userId),
16 subscription_tier: await getSubscriptionTier(email),
17 feature_flags: await getFeatureFlags(userId),
18 }
19 
20 return new Response(
21 JSON.stringify({
22 decision: 'continue',
23 user: {
24 ...payload.user,
25 app_metadata: {
26 ...payload.user.app_metadata,
27 ...customClaims,
28 },
29 },
30 }),
31 { headers: { 'Content-Type': 'application/json' }, status: 200 }
32 )
33})
34 
35async function getUserRole(userId: string): Promise<string> {
36 // Fetch the user's role from the DB
37 const supabaseAdmin = createAdminClient()
38 const { data } = await supabaseAdmin
39 .from('user_roles')
40 .select('role')
41 .eq('user_id', userId)
42 .single()
43 return data?.role ?? 'user'
44}

afterUser Hook (Post-signup Operations):

The afterUser hook runs after user signup. It's ideal for email verification, CRM sync, or triggering onboarding:

typescript
1// supabase/functions/post-signup/index.ts
2Deno.serve(async (req) => {
3 const payload = await req.json()
4 
5 if (payload.type !== 'after.user.create') {
6 return new Response(JSON.stringify({}), { status: 200 })
7 }
8 
9 const user = payload.user
10 
11 // Start operations in parallel
12 await Promise.all([
13 sendWelcomeEmail(user.email),
14 createUserInCRM(user),
15 initializeUserSettings(user.id),
16 ])
17 
18 return new Response(JSON.stringify({ success: true }), { status: 200 })
19})

Auth Hooks need to be registered from the Supabase Dashboard or via the CLI. In the Dashboard, you specify the function URL under Authentication > Hooks.


Storage Webhooks

You can trigger Edge Functions when a file is uploaded to or deleted from Supabase Storage. This is very useful for thumbnail generation, virus scanning, and metadata extraction:

typescript
1// supabase/functions/storage-webhook/index.ts
2import { createClient } from 'npm:@supabase/supabase-js@2'
3 
4interface StorageWebhookPayload {
5 type: 'INSERT' | 'UPDATE' | 'DELETE'
6 table: string
7 record: {
8 bucket_id: string
9 name: string
10 owner: string
11 created_at: string
12 updated_at: string
13 metadata: Record<string, unknown>
14 }
15 old_record: null | Record<string, unknown>
16}
17 
18Deno.serve(async (req) => {
19 const payload: StorageWebhookPayload = await req.json()
20 
21 // Only handle image uploads
22 if (
23 payload.type === 'INSERT' &&
24 payload.record.bucket_id === 'avatars'
25 ) {
26 const filePath = payload.record.name
27 const userId = payload.record.owner
28 
29 try {
30 // Image optimization
31 await processImage(filePath, userId)
32 
33 // Update metadata
34 const supabaseAdmin = createAdminClient()
35 await supabaseAdmin
36 .from('profiles')
37 .update({
38 avatar_updated_at: new Date().toISOString(),
39 avatar_processed: true
40 })
41 .eq('id', userId)
42 
43 } catch (error) {
44 console.error('Image processing failed:', error)
45 }
46 }
47 
48 return new Response(JSON.stringify({ processed: true }), {
49 headers: { 'Content-Type': 'application/json' },
50 status: 200,
51 })
52})
53 
54async function processImage(filePath: string, userId: string) {
55 // Generate a thumbnail with Sharp or the Cloudflare Images API
56 // This part varies by use case
57 console.log(`Processing image for user ${userId}: ${filePath}`)
58}

To register the webhook, go to Database > Webhooks in the Supabase Dashboard and create a new webhook. Specify your function URL for the INSERT event on the objects table in the storage schema.


Secret Management: Vault Integration

To manage API keys, webhook secrets, and other sensitive values in production, Supabase offers two approaches:

1. Environment Variables (Simple Usage):

bash
1# Add a secret
2supabase secrets set STRIPE_SECRET_KEY=sk_live_xxx
3supabase secrets set SENDGRID_API_KEY=SG.xxx
4 
5# List all secrets (values hidden)
6supabase secrets list
7 
8# Use it inside an Edge Function
9const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')

2. Vault (Encrypted, Rotatable):

Supabase Vault stores secrets encrypted in Postgres and offers rotation support:

sql
1-- Enable the vault extension
2CREATE EXTENSION IF NOT EXISTS vault;
3 
4-- Add a secret
5SELECT vault.create_secret('stripe_key', 'sk_live_xxx', 'Stripe production key');
6 
7-- Read the secret
8SELECT decrypted_secret
9FROM vault.decrypted_secrets
10WHERE name = 'stripe_key';
typescript
1// Read a Vault secret from an Edge Function
2Deno.serve(async (req) => {
3 const supabaseAdmin = createAdminClient()
4 
5 const { data: secretData } = await supabaseAdmin
6 .rpc('get_secret', { secret_name: 'stripe_key' })
7 
8 const stripeKey = secretData?.decrypted_secret
9 
10 // Do the Stripe operation
11 // ...
12})

Prefer Vault for critical secrets in production. When rotation is needed you can update the secret without redeploying the Edge Function.


Background Tasks and Deno Queue

Edge Functions can keep running even after returning an HTTP response. This is used for background tasks:

typescript
1// supabase/functions/process-order/index.ts
2Deno.serve(async (req) => {
3 const { orderId } = await req.json()
4 
5 // Respond immediately - the client doesn't wait
6 const response = new Response(
7 JSON.stringify({ message: 'Order accepted for processing', orderId }),
8 { headers: { 'Content-Type': 'application/json' }, status: 202 }
9 )
10 
11 // Start a background task - runs after the response
12 EdgeRuntime.waitUntil(processOrderInBackground(orderId))
13 
14 return response
15})
16 
17async function processOrderInBackground(orderId: string) {
18 // Long-running operations go here
19 await sendOrderConfirmationEmail(orderId)
20 await updateInventory(orderId)
21 await notifyWarehouse(orderId)
22 await generateInvoice(orderId)
23}

EdgeRuntime.waitUntil() is an API familiar from Cloudflare Workers. After the response returns, the process stays alive until the promise resolves. The CPU time limit (50ms default, max 2s) still applies.

For longer operations, use Supabase Queues (based on pgmq):

sql
1-- Create a queue
2SELECT pgmq.create('order_processing');
3 
4-- Add a message from inside an Edge Function
5SELECT pgmq.send(
6 'order_processing',
7 '{"orderId": "123", "action": "process"}'::jsonb
8);
9 
10-- A separate consumer Edge Function processes the messages
11SELECT * FROM pgmq.read('order_processing', 30, 10);

Streaming Response and WebSocket

Edge Functions offer streaming response and WebSocket support:

typescript
1// Streaming AI response example
2Deno.serve(async (req) => {
3 const { prompt } = await req.json()
4 
5 const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
6 method: 'POST',
7 headers: {
8 'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
9 'Content-Type': 'application/json',
10 },
11 body: JSON.stringify({
12 model: 'gpt-4o',
13 messages: [{ role: 'user', content: prompt }],
14 stream: true,
15 }),
16 })
17 
18 // Forward the OpenAI stream directly to the client
19 return new Response(openAIResponse.body, {
20 headers: {
21 'Content-Type': 'text/event-stream',
22 'Cache-Control': 'no-cache',
23 'Connection': 'keep-alive',
24 ...corsHeaders,
25 },
26 })
27})

For WebSocket, use Deno.upgradeWebSocket():

typescript
1Deno.serve((req) => {
2 if (req.headers.get('upgrade') !== 'websocket') {
3 return new Response(null, { status: 501 })
4 }
5 
6 const { socket, response } = Deno.upgradeWebSocket(req)
7 
8 socket.addEventListener('open', () => {
9 console.log('WebSocket connection opened')
10 })
11 
12 socket.addEventListener('message', (event) => {
13 // Echo server example
14 socket.send(`Echo: ${event.data}`)
15 })
16 
17 return response
18})

Cold Start Benchmarks

In real-world benchmarks, Supabase Edge Functions is noticeably faster than its competitors:

Platform
Runtime
Average Cold Start
Supabase Edge Functions
Deno 2
~100ms
Vercel Edge Functions
V8 Edge
~150ms
Cloudflare Workers
V8 Isolate
~50ms
AWS Lambda
Node.js 20
~400ms
AWS Lambda
Python 3.12
~600ms

This difference comes from Deno's small binary size and V8 snapshot optimization. The Deno runtime is ~35MB, versus ~80MB for the Node.js runtime.

For an "always warm" strategy in production, you can add a cron job that pings functions at regular intervals:

typescript
1// supabase/functions/health-ping/index.ts - for warmup
2Deno.serve(() => {
3 return new Response(JSON.stringify({ status: 'warm', ts: Date.now() }), {
4 headers: { 'Content-Type': 'application/json' },
5 })
6})

CORS Patterns and PKCE Flow

Calling an Edge Function from the browser requires CORS to be set up correctly:

typescript
1// supabase/functions/_shared/cors.ts
2// Separate origins for development and production
3const allowedOrigins = [
4 'http://localhost:3000',
5 'https://myapp.com',
6 'https://staging.myapp.com',
7]
8 
9export function getCorsHeaders(origin: string | null) {
10 const allowedOrigin = origin && allowedOrigins.includes(origin)
11 ? origin
12 : allowedOrigins[0]
13 
14 return {
15 'Access-Control-Allow-Origin': allowedOrigin,
16 'Access-Control-Allow-Headers':
17 'authorization, x-client-info, apikey, content-type',
18 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
19 'Access-Control-Max-Age': '86400',
20 }
21}

For OAuth login with the PKCE (Proof Key for Code Exchange) flow:

typescript
1Deno.serve(async (req) => {
2 const url = new URL(req.url)
3 const code = url.searchParams.get('code')
4 
5 if (!code) {
6 return new Response('Missing code', { status: 400 })
7 }
8 
9 const supabaseClient = createClient(
10 Deno.env.get('SUPABASE_URL') ?? '',
11 Deno.env.get('SUPABASE_ANON_KEY') ?? ''
12 )
13 
14 // PKCE code exchange
15 const { data, error } = await supabaseClient.auth.exchangeCodeForSession(code)
16 
17 if (error) {
18 return Response.redirect(`${Deno.env.get('SITE_URL')}/auth/error`)
19 }
20 
21 // Set the session cookie and redirect
22 return Response.redirect(`${Deno.env.get('SITE_URL')}/dashboard`)
23})

Pricing, Limits, and Production Strategy

Pricing:

  • Free tier: 500K invocations/month
  • Pro: $2/1M invocations (first 2M included)
  • CPU time: 50ms default, can be raised to 150ms
  • Memory: 256MB hard limit
  • Execution time: 150 seconds max

Critical Limits:

swift
1CPU Time: 50ms (default), 150ms (max)
2Memory: 256MB
3Request: 6MB body size
4Response: 6MB (unlimited for streaming)
5Timeout: 150 seconds
6Cold Start: ~100ms

Production Strategy:

  1. Keep functions small to minimize cold start
  2. Don't initialize database connections at the function's top level — do it per request
  3. Prefer lightweight alternatives over large npm packages
  4. Actively monitor Edge logs (Supabase Dashboard > Edge Functions > Logs)
  5. Integrate the Sentry Edge SDK for error tracking

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

typescript
// test/edge-function.test.ts
import { assertEquals } from 'jsr:@std/assert'
 
// Local dev server URL
const BASE_URL = 'http://localhost:54321/functions/v1'
 
Deno.test('hello-world function works', async () => {
const response = await fetch(`${BASE_URL}/hello-world`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Deno.env.get('SUPABASE_ANON_KEY')}`,
},
body: JSON.stringify({ name: 'World' }),
})
 
assertEquals(response.status, 200)
const data = await response.json()
assertEquals(data.message, 'Hello World!')
})
bash
# Run tests
deno test --allow-net --allow-env test/

Conclusion

Supabase Edge Functions holds an important place in serverless backend development with the Deno 2 runtime. A ~100ms cold start, native TypeScript support, Web Standards APIs, and Postgres RLS integration together make for a powerful platform.

The most critical points:

  • Using the user context client, RLS kicks in automatically, so you don't write extra auth code
  • Auth Hooks give you full control over the authentication lifecycle
  • Storage Webhooks are the cleanest way to build a file processing pipeline
  • Use EdgeRuntime.waitUntil() or pgmq for background tasks
  • Keep functions small and focused, keeping CPU time and memory limits in mind

Related Resources:

Related Posts on This Site:

Tags

#Supabase#Edge Functions#Deno#serverless#Postgres#backend#2026
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share