Skip to main content
Databases & RLS
Supabase
TypeScript
アーキテクチャ設計
信頼性
セキュリティ
コスト最適化

Supabase Edge Functions in practice: @supabase/server, background tasks, and running in production inside the limits

An implementation guide for getting real production use out of Supabase Edge Functions. Decide what belongs there from the constraints (2s CPU, 256MB memory, 150/400s wall clock), handle auth and RLS scoping declaratively with withSupabase from @supabase/server, receive webhooks idempotently, and push heavy work into the background with EdgeRuntime.waitUntil. Covers Hono routing, secrets, testing with Deno.test, logging, and CI/CD — all with official-compliant code.

Published
Reading time
18 min read
Author
友田 陽大
Share

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:

ItemValue
CPU time2s per request (actual time spent on the CPU)
Wall-clock durationFree 150s / paid 400s
Memory256MB
Request idle timeout150s (504 Gateway Timeout if no response)
Function size20MB (CLI bundled) / 5MB (server-side bundled)
Functions per projectFree 100 / Pro 500 / Team 1,000 / Enterprise unlimited
Log message lengthUp to 10,000 characters
Log volume100 events per 10 seconds
Secrets100 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 wellDoesn'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 streamHeavy cryptography or ML inference
Work requiring a server-only secret keyBundles with huge dependencies (size limits)
Lightweight scheduled jobs from CronLong-running batches (use database-side Cron/Queues)
Business rules RLS can't expressProcesses 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.supabaseAdmin bypasses 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 use auth: 'user'. The platform verifies the JWT before your handler runs
  • Webhook receivers: set verify_jwt = false, use auth: '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 JWT first. 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:

VariableContents
SUPABASE_URLThe API gateway for your Supabase project
SUPABASE_DB_URLThe URL for your Postgres database
SUPABASE_ANON_KEYThe anon key
SUPABASE_SERVICE_ROLE_KEYThe service_role key
SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYSJSON 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_at become 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 secondsconsole.log inside 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:

  1. Error rate (per function, per error type)
  2. Duration distribution (is p95 creeping toward the wall-clock limit?)
  3. Background task completion rate (beforeunload firings ÷ 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

ItemFreePro
Invocations500,000/month included2 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 withSupabase auth mode matches who actually calls the function
  • Every use of ctx.supabaseAdmin has 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 beforeunload handler that records progress
  • Required secrets are validated for presence at startup
  • .env is in .gitignore
  • Logs are structured, PII-free, and under 10,000 characters
  • No console.log inside 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.

Frequently asked questions

Where's the line between work that belongs in an Edge Function and work that doesn't?
The line is CPU time. CPU execution is capped at 2 seconds per request and memory at 256MB, while wall-clock duration is 150 seconds on Free and 400 seconds on paid plans. Work that actually burns CPU — transforming large images, encoding video, large-scale aggregation — does not fit. Work that is mostly waiting on I/O — calling external APIs, receiving webhooks, reading and writing the database, relaying an LLM stream — fits well, because waiting consumes no CPU.
What changes when I use withSupabase?
The boilerplate disappears: auth verification, client creation, claim parsing, CORS. You declare an auth mode, and withSupabase verifies the caller's credentials against that mode and hands you pre-configured clients on ctx. ctx.supabase is scoped to the caller's RLS policies; ctx.supabaseAdmin bypasses RLS. The official announcement states there is no jose, no JWKS configuration, and no manual secret setup — the package also absorbs the move to the new key system.
Can I keep working after returning a response?
Yes. Passing a Promise to EdgeRuntime.waitUntil(promise) keeps the function instance running until that Promise resolves. Called inside the handler, it does not block the response. It is not unbounded, though: the official docs say the maximum duration is capped based on the wall-clock, CPU, and memory limits, and the function shuts down when it reaches one of them. Subscribing to the beforeunload event lets you receive the shutdown reason and record your progress.
How do I stop a webhook from being processed twice?
Assume the sender retries, and make the receiver idempotent. The four steps are: (1) verify the signature, (2) INSERT the provider's event ID into a table with a unique constraint, (3) if that violates the constraint, treat it as already processed and return 200, (4) if it's new, return 2xx first and push the heavy work to EdgeRuntime.waitUntil. The key move is delegating the idempotency decision to a database unique constraint rather than application logic — so even simultaneous duplicate deliveries let exactly one through.
How is cost determined?
Billing is driven by invocation count. The Free plan includes 500,000 invocations per month and Pro includes 2 million, with overage at $2 per million. Because it's per-invocation, cost spikes when calls are frequent — not when a single request is heavy. Replacing client polling with a Realtime subscription, and bundling several small endpoints into one function via routing (which also cuts cold starts), translates directly into lower bills.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Supabase-based applications, from design through production operations

Realtime design (choosing between Broadcast / Presence / Postgres Changes, and staying consistent across reconnects), Auth flows, JWT signing keys and MFA, idempotent webhook handling in Edge Functions, and Storage upload paths and cost design. Built solo on a real mobile + web product with authorization pushed down into the database via RLS — so the app stays reliable, traceable, and easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading