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?
- Supabase Edge Functions Architecture
- Your First Edge Function: Basic Structure and Deploy
- Auth Token Management from Inside supabase-js
- Postgres RLS Trigger Pattern
- Auth Hooks: beforeUser and afterUser
- Storage Webhooks
- Secret Management: Vault Integration
- Background Tasks and Deno Queue
- Streaming Response and WebSocket
- Cold Start Benchmarks
- CORS Patterns and PKCE Flow
- Pricing, Limits, and Production Strategy
- Conclusion
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:
1supabase/2 functions/3 hello-world/4 index.ts5 process-payment/6 index.ts7 _shared/8 cors.ts9 supabase-client.tsThe _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:
1// supabase/functions/hello-world/index.ts2import { corsHeaders } from '../_shared/cors.ts'3 4Deno.serve(async (req: Request) => {5 // Early return for OPTIONS preflight6 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})1// supabase/functions/_shared/cors.ts2export 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:
1# Login2supabase login3 4# Local dev (test the function locally)5supabase functions serve hello-world --env-file .env.local6 7# Production deploy8supabase functions deploy hello-world9 10# Deploy all functions11supabase functions deployIn 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):
1import { createClient } from 'npm:@supabase/supabase-js@2'2 3// This client bypasses RLS - use carefully4const supabaseAdmin = createClient(5 Deno.env.get('SUPABASE_URL') ?? '',6 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',7 {8 auth: {9 autoRefreshToken: false,10 persistSession: false11 }12 }13)2. User Context Client (RLS active, safe):
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 header10 const authHeader = req.headers.get('Authorization')!11 12 // Create the client with user context - RLS stays active13 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: false23 }24 }25 )26 27 // This query is subject to the calling user's RLS policies28 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 automatically38 const { data, error } = await supabaseClient39 .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:
1-- Users can only view their own profile data2CREATE POLICY "Users can view own profile"3ON profiles4FOR SELECT5USING (auth.uid() = user_id);6 7-- Users can only update their own data8CREATE POLICY "Users can update own profile"9ON profiles10FOR UPDATE11USING (auth.uid() = user_id)12WITH CHECK (auth.uid() = user_id);13 14-- The admin role can access all data15CREATE POLICY "Admins can do everything"16ON profiles17FOR ALL18USING (19 EXISTS (20 SELECT 1 FROM user_roles21 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:
1-- Automatically create a profile when a new user signs up2CREATE OR REPLACE FUNCTION public.handle_new_user()3RETURNS trigger AS $$4BEGIN5 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 schema17CREATE TRIGGER on_auth_user_created18 AFTER INSERT ON auth.users19 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):
1// supabase/functions/custom-access-token/index.ts2Deno.serve(async (req) => {3 const payload = await req.json()4 5 // Check the hook type6 if (payload.type !== 'before.user.create') {7 return new Response(JSON.stringify({}), { status: 200 })8 }9 10 const userId = payload.user?.id11 const email = payload.user?.email12 13 // Add custom claims14 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 DB37 const supabaseAdmin = createAdminClient()38 const { data } = await supabaseAdmin39 .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:
1// supabase/functions/post-signup/index.ts2Deno.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.user10 11 // Start operations in parallel12 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:
1// supabase/functions/storage-webhook/index.ts2import { createClient } from 'npm:@supabase/supabase-js@2'3 4interface StorageWebhookPayload {5 type: 'INSERT' | 'UPDATE' | 'DELETE'6 table: string7 record: {8 bucket_id: string9 name: string10 owner: string11 created_at: string12 updated_at: string13 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 uploads22 if (23 payload.type === 'INSERT' &&24 payload.record.bucket_id === 'avatars'25 ) {26 const filePath = payload.record.name27 const userId = payload.record.owner28 29 try {30 // Image optimization31 await processImage(filePath, userId)32 33 // Update metadata34 const supabaseAdmin = createAdminClient()35 await supabaseAdmin36 .from('profiles')37 .update({38 avatar_updated_at: new Date().toISOString(),39 avatar_processed: true40 })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 API56 // This part varies by use case57 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):
1# Add a secret2supabase secrets set STRIPE_SECRET_KEY=sk_live_xxx3supabase secrets set SENDGRID_API_KEY=SG.xxx4 5# List all secrets (values hidden)6supabase secrets list7 8# Use it inside an Edge Function9const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')2. Vault (Encrypted, Rotatable):
Supabase Vault stores secrets encrypted in Postgres and offers rotation support:
1-- Enable the vault extension2CREATE EXTENSION IF NOT EXISTS vault;3 4-- Add a secret5SELECT vault.create_secret('stripe_key', 'sk_live_xxx', 'Stripe production key');6 7-- Read the secret8SELECT decrypted_secret9FROM vault.decrypted_secrets10WHERE name = 'stripe_key';1// Read a Vault secret from an Edge Function2Deno.serve(async (req) => {3 const supabaseAdmin = createAdminClient()4 5 const { data: secretData } = await supabaseAdmin6 .rpc('get_secret', { secret_name: 'stripe_key' })7 8 const stripeKey = secretData?.decrypted_secret9 10 // Do the Stripe operation11 // ...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:
1// supabase/functions/process-order/index.ts2Deno.serve(async (req) => {3 const { orderId } = await req.json()4 5 // Respond immediately - the client doesn't wait6 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 response12 EdgeRuntime.waitUntil(processOrderInBackground(orderId))13 14 return response15})16 17async function processOrderInBackground(orderId: string) {18 // Long-running operations go here19 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):
1-- Create a queue2SELECT pgmq.create('order_processing');3 4-- Add a message from inside an Edge Function5SELECT pgmq.send(6 'order_processing',7 '{"orderId": "123", "action": "process"}'::jsonb8);9 10-- A separate consumer Edge Function processes the messages11SELECT * FROM pgmq.read('order_processing', 30, 10);Streaming Response and WebSocket
Edge Functions offer streaming response and WebSocket support:
1// Streaming AI response example2Deno.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 client19 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():
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 example14 socket.send(`Echo: ${event.data}`)15 })16 17 return response18})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:
1// supabase/functions/health-ping/index.ts - for warmup2Deno.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:
1// supabase/functions/_shared/cors.ts2// Separate origins for development and production3const 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 ? origin12 : 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:
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 exchange15 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 redirect22 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:
1CPU Time: 50ms (default), 150ms (max)2Memory: 256MB3Request: 6MB body size4Response: 6MB (unlimited for streaming)5Timeout: 150 seconds6Cold Start: ~100msProduction Strategy:
- Keep functions small to minimize cold start
- Don't initialize database connections at the function's top level — do it per request
- Prefer lightweight alternatives over large npm packages
- Actively monitor Edge logs (Supabase Dashboard > Edge Functions > Logs)
- 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
// test/edge-function.test.tsimport { assertEquals } from 'jsr:@std/assert' // Local dev server URLconst 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!')})# Run testsdeno 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:
- Firebase Data Connect ve GraphQL ile Cloud SQL Entegrasyonu (in Turkish)
- Supabase AI ile Vektör Arama ve Hybrid Search (in Turkish)
- RAG vs Fine-Tuning: Production LLM Rehberi (in Turkish)
- The Server-Side Swift Ecosystem: Vapor, Hummingbird, and Swift on Server
- Drizzle ORM + Turso: Edge SQLite Database Pattern 2026 Production
Tags
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.

