Scientyfic World

How to Optimize Supabase Edge Functions for Low Latency (Region, Limits, Background Tasks)

Supabase Edge Functions are TypeScript functions that run on Supabase’s Deno-based edge runtime, close to your users by default. Making them fast is mostly about three things: where the function...

Share:

Get an AI summary of this article

Supabase Edge Functions blog banner

Supabase Edge Functions are TypeScript functions that run on Supabase’s Deno-based edge runtime, close to your users by default. Making them fast is mostly about three things: where the function runs relative to your database, how much work sits on the request’s critical path, and how quickly it rejects bad requests. This guide builds an event-tracking function for a React Native app and applies each of those, using the current Supabase APIs.

The 2024 version of this post called client-side Supabase queries “edge functions” and never showed actual function code or deployment. Edge Functions are separate server code that you deploy with the Supabase CLI; this version shows the real thing.

What I tested: the function’s request logic passes three Deno tests with a fake database client, the function entry point type-checks with deno check against @supabase/server 1.9, the database migration and its row-level security policies passed checks in PGlite (Postgres compiled to WebAssembly) with a stand-in for Supabase’s auth schema, and the client call type-checks with supabase-js 2.118. Running functions locally with supabase functions serve requires Docker, which I did not have, so I did not deploy or benchmark latency.

Where Edge Functions Run, and Why It Matters

From Supabase’s regional invocation docs: functions “automatically execute in the region closest to the user making the request”. That is ideal for work that does not touch your database, such as validating a webhook or calling a third-party API. But your Postgres database lives in one region. A function running in Singapore that queries a database in Virginia pays a long round trip for every query, so a function that makes several queries can end up slower than one running next to the database.

The docs say that for “intensive database or storage operations, executing in the same region as your database often provides better performance”. You can pin a function’s region with the x-region header, a forceFunctionRegion query parameter (useful for webhooks), or the region option in supabase-js. The response’s x-sb-edge-region header tells you where it ran. The trade-off: a pinned region is not re-routed automatically during a regional outage.

Function mostly…Run it
Calls external APIs, transforms requests, checks signaturesNear the user (default)
Makes one quick database writeEither; measure both
Makes several queries or large reads and writesIn the database’s region

Know the Limits

Design within Supabase’s published limits:

LimitValue
Memory256 MB
CPU time per request2 seconds (async I/O does not count)
Wall-clock time per worker150 s on the Free plan, 400 s on paid plans
Request idle timeout150 s, after which the caller gets a 504
Function size20 MB bundled with the CLI, 5 MB bundled server-side

The 2-second CPU budget is the one that bites: heavy computation, large JSON transformations or image processing belong elsewhere. Native Node libraries that use threads, such as sharp, are not supported.

Step 1: The Table and Row-Level Security

Events are stored per user, and row-level security makes sure a user can only insert and read their own rows, even if a bug in the function forgets to check:

create table public.events (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
  name text not null check (char_length(name) between 1 and 64),
  props jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create index events_user_created_idx on public.events (user_id, created_at desc);

alter table public.events enable row level security;

create policy "users insert their own events" on public.events
  for insert to authenticated with check ((select auth.uid()) = user_id);

create policy "users read their own events" on public.events
  for select to authenticated using ((select auth.uid()) = user_id);

Wrapping auth.uid() in a sub-select lets Postgres evaluate it once per statement instead of once per row, a pattern Supabase recommends for RLS performance. I ran this migration in PGlite with a minimal stand-in for the auth schema: a user could insert their own event, a row with someone else’s user_id was rejected by RLS, an empty name was rejected by the check constraint, and each user saw only their own rows.

Step 2: Create the Function

supabase init
supabase functions new track-event

Keep the request logic in a plain module with its dependencies passed in, so it can be tested without the Supabase runtime:

// Pure request logic, kept separate so it can be tested without the Supabase runtime.
type Insertable = {
  from(table: string): {
    insert(row: Record<string, unknown>): PromiseLike<{ error: { message: string } | null }>;
  };
};

export type Deps = {
  supabase: Insertable;               // RLS-scoped to the caller
  userId: string;
  waitUntil: (p: Promise<unknown>) => void;
  notify?: (event: { name: string; userId: string }) => Promise<void>;
};

const NAME = /^[a-z][a-z0-9_]{0,63}$/;
const MAX_PROPS_BYTES = 2048;

export async function handleTrackEvent(req: Request, deps: Deps): Promise<Response> {
  if (req.method !== 'POST') return Response.json({ error: 'Use POST' }, { status: 405 });

  let body: { name?: unknown; props?: unknown };
  try {
    body = await req.json();
  } catch {
    return Response.json({ error: 'Invalid JSON' }, { status: 400 });
  }

  const { name, props = {} } = body;
  if (typeof name !== 'string' || !NAME.test(name)) {
    return Response.json({ error: 'name must be snake_case, 1-64 chars' }, { status: 400 });
  }
  if (typeof props !== 'object' || props === null || Array.isArray(props) ||
      new TextEncoder().encode(JSON.stringify(props)).length > MAX_PROPS_BYTES) {
    return Response.json({ error: 'props must be an object under 2 KB' }, { status: 400 });
  }

  // One round trip to Postgres, on the critical path.
  const { error } = await deps.supabase.from('events').insert({ name, props, user_id: deps.userId });
  if (error) return Response.json({ error: 'Could not record event' }, { status: 500 });

  // Anything the client does not need to wait for runs after the response.
  if (deps.notify) deps.waitUntil(deps.notify({ name, userId: deps.userId }).catch(() => {}));

  return new Response(null, { status: 204 });
}

The entry point uses withSupabase from @supabase/server, which Supabase’s current function auth docs use. With auth: 'user' it validates the caller’s JWT before your code runs and gives you ctx.supabase, a client scoped to that user so RLS applies:

import { withSupabase } from 'npm:@supabase/server@1';
import { handleTrackEvent } from './handler.ts';

// Provided by the Supabase Edge Runtime; declared here for type checking.
declare const EdgeRuntime: { waitUntil(promise: Promise<unknown>): void };

const WEBHOOK_URL = Deno.env.get('EVENTS_WEBHOOK_URL'); // optional, e.g. an analytics sink

export default {
  fetch: withSupabase({ auth: 'user' }, (req, ctx) =>
    handleTrackEvent(req, {
      supabase: ctx.supabase,
      userId: ctx.userClaims!.id,
      // Keeps the worker alive for background work after the response is sent.
      waitUntil: p => EdgeRuntime.waitUntil(p),
      notify: WEBHOOK_URL
        ? async e => { await fetch(WEBHOOK_URL, { method: 'POST', body: JSON.stringify(e) }); }
        : undefined,
    }),
  ),
};

EdgeRuntime.waitUntil is the runtime’s background task API: the response goes back immediately, and the webhook call keeps running afterwards. Background work still counts against the wall-clock, CPU and memory limits. I declared EdgeRuntime in the file because importing Supabase’s type definitions did not register it with deno check in Deno 2.9; the runtime provides it either way.

Step 3: Test the Logic

import { assertEquals } from 'jsr:@std/assert@1';
import { handleTrackEvent, type Deps } from './handler.ts';

function fakeDeps(insertError: { message: string } | null = null) {
  const inserted: Record<string, unknown>[] = [];
  const background: Promise<unknown>[] = [];
  const notified: unknown[] = [];
  const deps: Deps = {
    supabase: { from: () => ({ insert: (row) => { inserted.push(row); return Promise.resolve({ error: insertError }); } }) },
    userId: 'user-1',
    waitUntil: p => { background.push(p); },
    notify: async e => { notified.push(e); },
  };
  return { deps, inserted, background, notified };
}

const post = (body: unknown) =>
  new Request('http://localhost/track-event', { method: 'POST', body: typeof body === 'string' ? body : JSON.stringify(body) });

Deno.test('records a valid event and defers the webhook', async () => {
  const f = fakeDeps();
  const res = await handleTrackEvent(post({ name: 'level_complete', props: { level: 3 } }), f.deps);
  assertEquals(res.status, 204);
  assertEquals(f.inserted, [{ name: 'level_complete', props: { level: 3 }, user_id: 'user-1' }]);
  assertEquals(f.background.length, 1);
  await Promise.all(f.background);
  assertEquals(f.notified, [{ name: 'level_complete', userId: 'user-1' }]);
});

Deno.test('rejects bad input before touching the database', async () => {
  const f = fakeDeps();
  for (const body of ['not json', { name: 'Bad Name' }, { name: 'ok', props: [1] }, { name: 'ok', props: { x: 'y'.repeat(3000) } }]) {
    assertEquals((await handleTrackEvent(post(body), f.deps)).status, 400);
  }
  assertEquals((await handleTrackEvent(new Request('http://localhost', { method: 'GET' }), f.deps)).status, 405);
  assertEquals(f.inserted.length, 0);
});

Deno.test('database errors return 500 without leaking details', async () => {
  const f = fakeDeps({ message: 'permission denied for table events' });
  const res = await handleTrackEvent(post({ name: 'ok' }), f.deps);
  assertEquals(res.status, 500);
  assertEquals(await res.json(), { error: 'Could not record event' });
  assertEquals(f.background.length, 0);
});
$ deno test --allow-net=jsr.io handler.test.ts
records a valid event and defers the webhook ... ok
rejects bad input before touching the database ... ok
database errors return 500 without leaking details ... ok
ok | 3 passed | 0 failed

With Docker installed, run the full stack locally with supabase start and supabase functions serve track-event, which serves it at http://localhost:54321/functions/v1/track-event with hot reload.

Step 4: Deploy and Call It From React Native

supabase login
supabase link --project-ref your-project-ref
supabase functions deploy track-event
supabase secrets set EVENTS_WEBHOOK_URL=https://example.com/hooks/events

From the app, supabase.functions.invoke sends the signed-in user’s access token automatically. Pin the region to your database’s region if the function is database-heavy:

import { createClient, FunctionRegion } from '@supabase/supabase-js';

const supabase = createClient('https://your-project.supabase.co', 'your-publishable-key');

// Signed-in users only: the user's access token is sent automatically.
export async function trackEvent(name: string, props: Record<string, unknown> = {}) {
  const { error } = await supabase.functions.invoke('track-event', {
    body: { name, props },
    // Run next to the database (here: a project in us-east-1) instead of next to the user.
    region: FunctionRegion.UsEast1,
  });
  if (error) console.warn('Event not recorded', error.message);
}

Optimization Checklist

  1. Pick the region deliberately. Measure with the x-sb-edge-region header and response times from the regions your users are in.
  2. Do one thing per request. Keep database round trips to a minimum; combine queries or move multi-step logic into a Postgres function called with rpc().
  3. Validate early. Reject bad input before any network call, as the handler does.
  4. Move non-essential work off the critical path with EdgeRuntime.waitUntil.
  5. Select only the columns you need and index the columns you filter and sort on.
  6. Keep functions small. Smaller bundles load faster; avoid heavy dependencies and CPU-bound work.
  7. Let RLS enforce access with the user-scoped client, and reserve the admin client for jobs that truly need it.
  8. Watch the logs and metrics in the Supabase dashboard after deploying.

Conclusion

Fast Supabase Edge Functions come from good placement and small critical paths: run database-heavy functions next to the database, validate first, make as few round trips as possible, and push everything else into background tasks. Keep the handler logic testable, let RLS guard the data, and measure from real regions before and after each change. For calling APIs from the client side generally, see how to make an HTTP request.

FAQs

Where do Supabase Edge Functions run?

By default, in the region closest to the user making the request. You can force a specific region with the x-region header, the forceFunctionRegion query parameter or the region option in supabase-js.

Should my function run near the user or near the database?

Near the user for work that does not touch the database, and in the database’s region for functions that make several queries or move a lot of data, because each query otherwise pays a long round trip.

What language do Supabase Edge Functions use?

TypeScript (or JavaScript) on the Deno runtime. npm packages can be imported with the npm: prefix.

What are the main Edge Function limits?

256 MB of memory, 2 seconds of CPU time per request, a 150-second idle timeout, and a wall-clock limit of 150 seconds on the Free plan or 400 seconds on paid plans.

How do I run work after sending the response?

Pass a promise to EdgeRuntime.waitUntil. The response is sent immediately and the worker stays alive until the promise settles, within the function’s limits.

Do I need Docker to develop Edge Functions?

To run them locally with supabase start and supabase functions serve, yes. Deployment can fall back to API-based bundling without Docker, and you can unit-test handler logic with plain deno test.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts