# 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: 2026-08-16
- Author: 友田 陽大
- Tags: Supabase, TypeScript, アーキテクチャ設計, 信頼性, セキュリティ, コスト最適化
- URL: https://tomodahinata.com/en/blog/supabase-edge-functions-production-guide-with-supabase-background-tasks-limits
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- CPU time decides whether to use them at all. 2s of CPU per request, 256MB of memory, and a wall clock of 150s on Free / 400s on paid plans. Keep heavy computation and huge bundled dependencies out; put I/O-bound work in.
- Declare authentication with withSupabase from @supabase/server. With auth: 'user', ctx.supabase is scoped to the caller's RLS and ctx.supabaseAdmin bypasses it. No jose, no JWKS configuration to write yourself.
- Build webhooks in this order: verify, record under an idempotency key, return 2xx immediately, then push heavy work to EdgeRuntime.waitUntil. Being able to keep working after responding is the real strength here.
- Test by extracting pure functions and running them under Deno.test in milliseconds. Mocking globalThis.fetch lets you exercise the real code path without changing production code.
- Logs cap at 10,000 characters per message and 100 events per 10 seconds. Use structured logging rather than printf debugging, and never include PII.

---

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:

```bash
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:

```ts
export default {
  fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req, ctx) => {
    const { name } = await req.json()

    return Response.json({
      message: `Hello ${name}!`,
    })
  }),
}
```

Invoking it locally:

```bash
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**.

```ts
// 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

```ts
interface SupabaseContext {
  supabase: SupabaseClient      // user-scoped, respects RLS
  supabaseAdmin: SupabaseClient // admin client with service role
  userClaims: UserIdentity | null
  jwtClaims: JWTClaims | null
  authMode: AuthMode
}
```

```ts
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](/blog/supabase-anon-key-service-role-key-exposure-guide)).

### Its relationship with verify_jwt

Platform-level JWT verification is controlled in `config.toml`:

```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](/blog/supabase-auth-production-implementation-guide-flows-jwt-signing-keys-sessions-mfa)).

---

## 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.

```ts
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:

```ts
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:

```bash
# 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:

```text
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.

```sql
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.

```ts
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.

```ts
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:

```tsx
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:

```toml
[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.

```ts
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](/blog/supabase-realtime-broadcast-presence-postgres-changes-production-implementation-guide)).

### Temporary files (`/tmp`)

```ts
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:

```text
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
```

```ts
// 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;
}
```

```ts
// 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 }))
})
```

```bash
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.log` inside a loop pushes out the logs you actually need

Given that, `console.log(payload)` is useless in production. Use **structured logs**.

```ts
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

```yaml
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**:

```yaml
      - 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 `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.
