DEV Community

kanta13jp1
kanta13jp1

Posted on

Supabase Edge Functions Advanced — Streaming, WebSockets, and Background Jobs

Supabase Edge Functions run on Deno Deploy and are far more capable than simple REST handlers. This guide covers three advanced patterns every indie developer should know: streaming responses (for LLM integrations), WebSocket upgrades (for real-time features), and background jobs using EdgeRuntime.waitUntil.

Pattern 1: Streaming Responses (SSE)

The most common use case is streaming LLM output without blocking the client.

// supabase/functions/stream-ai/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

serve(async (req) => {
  const { prompt } = await req.json()

  const upstream = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      model: 'claude-haiku-4-5',
      max_tokens: 1024,
      stream: true,
      messages: [{ role: 'user', content: prompt }],
    }),
  })

  // Pipe the upstream SSE stream directly to the client
  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*',
    },
  })
})
Enter fullscreen mode Exit fullscreen mode

Flutter Client for SSE

Stream<String> streamAiResponse(String prompt) async* {
  final request = http.Request(
    'POST',
    Uri.parse('$supabaseUrl/functions/v1/stream-ai'),
  )
    ..headers['Authorization'] = 'Bearer $anonKey'
    ..headers['Content-Type'] = 'application/json'
    ..body = jsonEncode({'prompt': prompt});

  final response = await request.send();

  await for (final line in response.stream
      .transform(utf8.decoder)
      .transform(const LineSplitter())) {
    if (!line.startsWith('data: ')) continue;
    final data = line.substring(6).trim();
    if (data == '[DONE]') return;

    try {
      final json = jsonDecode(data) as Map<String, dynamic>;
      final delta = json['delta']?['text'] as String?;
      if (delta != null) yield delta;
    } catch (_) {
      // skip malformed chunks
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: WebSocket Upgrades

Edge Functions can be upgraded to full WebSocket connections, enabling real-time bidirectional communication without Supabase Realtime channels.

// supabase/functions/ws-relay/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

// In-memory room registry (per isolate)
const rooms = new Map<string, Set<WebSocket>>()

serve((req) => {
  if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
    return new Response('WebSocket upgrade required', { status: 426 })
  }

  const url = new URL(req.url)
  const room = url.searchParams.get('room') ?? 'default'

  const { socket, response } = Deno.upgradeWebSocket(req)

  socket.onopen = () => {
    if (!rooms.has(room)) rooms.set(room, new Set())
    rooms.get(room)!.add(socket)
  }

  socket.onmessage = (event) => {
    const peers = rooms.get(room) ?? new Set()
    for (const peer of peers) {
      if (peer !== socket && peer.readyState === WebSocket.OPEN) {
        peer.send(event.data)
      }
    }
  }

  socket.onclose = () => rooms.get(room)?.delete(socket)

  return response
})
Enter fullscreen mode Exit fullscreen mode

Important Caveat

Each Edge Function invocation runs in its own Deno isolate. The in-memory rooms map is local to one instance. For production multi-room chat, use Supabase Realtime or a Redis-backed pub/sub.

Pattern 3: Background Jobs with waitUntil

Return a fast response to the client while doing heavy work asynchronously:

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
  const { user_id, event_type } = await req.json()

  // Kick off background work — fires-and-forgets after response is sent
  EdgeRuntime.waitUntil(
    runAnalyticsPipeline(user_id, event_type)
  )

  // Respond immediately — client doesn't wait for pipeline
  return new Response(JSON.stringify({ queued: true }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

async function runAnalyticsPipeline(userId: string, event: string) {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  // Heavy operations: aggregation, external API calls, email sending
  await supabase.rpc('update_user_metrics', { p_user_id: userId, p_event: event })
  await notifySlack(`User ${userId} triggered ${event}`)
}
Enter fullscreen mode Exit fullscreen mode

Scheduled Execution via pg_cron

Run an Edge Function on a schedule using Supabase's built-in pg_cron extension:

SELECT cron.schedule(
  'morning-digest',
  '0 9 * * *',
  $$
  SELECT net.http_post(
    url        := current_setting('app.supabase_url') || '/functions/v1/morning-digest',
    headers    := jsonb_build_object(
      'Content-Type', 'application/json',
      'Authorization', 'Bearer ' || current_setting('app.service_role_key')
    ),
    body       := '{}'::jsonb
  );
  $$
);
Enter fullscreen mode Exit fullscreen mode

Retry with Exponential Backoff

For external API calls inside Edge Functions:

async function fetchWithRetry(
  url: string,
  init: RequestInit,
  maxAttempts = 3
): Promise<Response> {
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(url, init)
    if (res.ok || res.status < 500) return res

    if (i < maxAttempts - 1) {
      const jitter = Math.random() * 1000
      await new Promise(r => setTimeout(r, (2 ** i) * 1000 + jitter))
    }
  }
  throw new Error(`All ${maxAttempts} attempts failed for ${url}`)
}
Enter fullscreen mode Exit fullscreen mode

Key Limitations to Know

Constraint Value Workaround
CPU time limit 400ms (free) / 2s (pro) Use waitUntil for heavy work
Memory per invocation 150MB Stream large payloads
WebSocket in-memory state Per isolate only Use Realtime for multi-node
Max response size 6MB Stream or paginate

Indie Dev Cost Profile

On Supabase Free: 500K invocations/month. Streaming 1K tokens via Claude Haiku costs ~$0.001. For a 1,000-user app with 10 AI queries/day: ~10M tokens/month = $10 on Haiku. Edge Function invocations: ~10M/month — well within the Pro plan limits.

Summary

Three patterns unlock the full power of Supabase Edge Functions for production apps: SSE streaming for LLM output, WebSocket upgrades for real-time features, and EdgeRuntime.waitUntil for fire-and-forget background work. Combined with pg_cron scheduling, Edge Functions can replace a significant chunk of traditional backend infrastructure.

Next up: Indie Dev SaaS Launch — pricing strategy, Stripe integration, and freemium-to-paid conversion design.

Top comments (0)