diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index 0ca56c33284..94a9dc993dd 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -1663,6 +1663,10 @@ export const functions: NavMenuConstant = {
name: 'Status codes',
url: '/guides/functions/status-codes' as `/${string}`,
},
+ {
+ name: 'Recursive/Nested function calls',
+ url: '/guides/functions/recursive-functions' as `/${string}`,
+ },
{
name: 'Limits',
url: '/guides/functions/limits' as `/${string}`,
diff --git a/apps/docs/content/guides/functions/limits.mdx b/apps/docs/content/guides/functions/limits.mdx
index f466414a8cc..4546969659b 100644
--- a/apps/docs/content/guides/functions/limits.mdx
+++ b/apps/docs/content/guides/functions/limits.mdx
@@ -25,6 +25,7 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform."
- Enterprise: Unlimited
- Maximum log message length: 10,000 characters
- Log event threshold: 100 events per 10 seconds
+- Recursive/Nested Function Calling: ~5000 requests per minute [more details](/docs/guides/functions/recursive-functions)
### Secrets
diff --git a/apps/docs/content/guides/functions/recursive-functions.mdx b/apps/docs/content/guides/functions/recursive-functions.mdx
new file mode 100644
index 00000000000..aca4a2bac60
--- /dev/null
+++ b/apps/docs/content/guides/functions/recursive-functions.mdx
@@ -0,0 +1,293 @@
+---
+id: 'function-recursive-functions'
+title: 'Recursive / Nested Function Calls'
+description: 'Learn about rate limiting for Edge Functions that call other Edge Functions'
+subtitle: 'Understanding rate limits when Edge Functions invoke each other'
+---
+
+Edge Functions can call other Edge Functions using `fetch()`. This enables powerful patterns like function chaining, fan-out/fan-in workflows, and recursive processing. To protect platform stability and prevent runaway amplification, Supabase rate limits these internal function-to-function calls.
+
+## What gets rate limited
+
+Rate limiting applies to **outbound `fetch()` calls** made by your Edge Functions to other Edge Functions within your project. This includes:
+
+- **Direct recursion**: A function calling itself
+- **Function chaining**: Function A calling Function B
+- **Circular calls**: Function A calling Function B, which calls Function A
+- **Fan-out patterns**: A function calling multiple other functions concurrently
+
+
+
+Inbound requests to your Edge Functions and requests to external APIs (e.g., Stripe, OpenAI) are **not** subject to this rate limit. Only outbound calls from one Edge Function to another Edge Function are counted.
+
+
+
+## Rate limit budget
+
+Each request chain has a budget of at least **5,000 requests per minute**. In busier regions, this budget may be higher. All function-to-function calls within the same request chain share this budget.
+
+For example, if Function A calls Function B, and Function B calls Function C, all three calls count toward the same budget pool.
+
+## Handling rate limit errors
+
+When the rate limit is exceeded, calling another Edge Function throws a `RateLimitError`. This error includes a `retryAfterMs` property indicating how long to wait (in milliseconds) before retrying. You should catch this error and handle it gracefully:
+
+
+
+
+```typescript
+import { createClient } from 'jsr:@supabase/supabase-js@2'
+
+const supabase = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_ANON_KEY')!
+)
+
+Deno.serve(async (req) => {
+ try {
+ const { data, error } = await supabase.functions.invoke('other-function', {
+ body: { foo: 'bar' },
+ })
+
+ if (error) throw error
+
+ return new Response(JSON.stringify(data), {
+ headers: { 'Content-Type': 'application/json' },
+ })
+ } catch (err) {
+ if (err instanceof Deno.errors.RateLimitError) {
+ // Use retryAfterMs to tell the client when to retry
+ const retryAfterSeconds = Math.ceil(err.retryAfterMs / 1000)
+ return new Response(
+ JSON.stringify({ error: 'Service temporarily unavailable. Please retry later.' }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Retry-After': retryAfterSeconds.toString(),
+ },
+ }
+ )
+ }
+ throw err
+ }
+})
+```
+
+
+
+
+
+```typescript
+Deno.serve(async (req) => {
+ try {
+ const response = await fetch(
+ `${Deno.env.get('SUPABASE_URL')}/functions/v1/other-function`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${Deno.env.get('SUPABASE_ANON_KEY')}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ foo: 'bar' }),
+ }
+ )
+ return response
+ } catch (err) {
+ if (err instanceof Deno.errors.RateLimitError) {
+ // Use retryAfterMs to tell the client when to retry
+ const retryAfterSeconds = Math.ceil(err.retryAfterMs / 1000)
+ return new Response(
+ JSON.stringify({ error: 'Service temporarily unavailable. Please retry later.' }),
+ {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Retry-After': retryAfterSeconds.toString(),
+ },
+ }
+ )
+ }
+ throw err
+ }
+})
+```
+
+
+
+
+You can also use `retryAfterMs` to implement automatic retries within your function:
+
+
+
+
+```typescript
+import { createClient } from 'jsr:@supabase/supabase-js@2'
+
+const supabase = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_ANON_KEY')!
+)
+
+async function invokeWithRetry(
+ functionName: string,
+ payload: object,
+ maxRetries = 3
+) {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ const { data, error } = await supabase.functions.invoke(functionName, {
+ body: payload,
+ })
+ if (error) throw error
+ return data
+ } catch (err) {
+ if (err instanceof Deno.errors.RateLimitError && attempt < maxRetries - 1) {
+ // Wait for the recommended duration before retrying
+ await new Promise((resolve) => setTimeout(resolve, err.retryAfterMs))
+ continue
+ }
+ throw err
+ }
+ }
+}
+```
+
+
+
+
+
+```typescript
+async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ return await fetch(url, options)
+ } catch (err) {
+ if (err instanceof Deno.errors.RateLimitError && attempt < maxRetries - 1) {
+ // Wait for the recommended duration before retrying
+ await new Promise((resolve) => setTimeout(resolve, err.retryAfterMs))
+ continue
+ }
+ throw err
+ }
+ }
+}
+```
+
+
+
+
+## Tips for avoiding rate limits
+
+### 1. Batch operations instead of individual calls
+
+Instead of calling a function once per item, batch multiple items into a single call:
+
+```typescript
+// ❌ Avoid: One call per item
+for (const item of items) {
+ await supabase.functions.invoke('process-item', { body: item })
+}
+
+// ✅ Better: Batch items into one call
+await supabase.functions.invoke('process-items', { body: { items } })
+```
+
+### 2. Limit recursion depth
+
+If your function is recursive, set a maximum depth to prevent unbounded call chains:
+
+```typescript
+Deno.serve(async (req) => {
+ const { depth = 0, data } = await req.json()
+
+ if (depth >= 5) {
+ // Stop recursion at max depth
+ return new Response(JSON.stringify({ result: data }))
+ }
+
+ // Process and recurse with incremented depth
+ const processed = processData(data)
+ const { data: result } = await supabase.functions.invoke('my-function', {
+ body: { depth: depth + 1, data: processed },
+ })
+
+ return new Response(JSON.stringify(result))
+})
+```
+
+### 3. Use queues for large workloads
+
+For processing large datasets, consider using [Supabase Queues](/docs/guides/queues) instead of recursive function calls. Queues handle backpressure automatically and are better suited for high-volume workloads.
+
+### 4. Use shared libraries instead of separate functions
+
+Instead of creating separate Edge Functions that call each other, create a shared library of functions and import them directly. This avoids HTTP overhead and rate limits entirely:
+
+```typescript
+// supabase/functions/_shared/transform.ts
+export function validate(data: any) {
+ // validation logic
+}
+
+export function transform(data: any) {
+ // transformation logic
+}
+
+export async function save(data: any) {
+ // save logic
+}
+```
+
+```typescript
+// supabase/functions/process-data/index.ts
+import { validate, transform, save } from '../_shared/transform.ts'
+
+Deno.serve(async (req) => {
+ const data = await req.json()
+ const validated = validate(data)
+ const transformed = transform(validated)
+ const result = await save(transformed)
+ return new Response(JSON.stringify(result))
+})
+```
+
+### 5. Add delays for non-urgent processing
+
+If immediate processing isn't required, add delays between calls to spread the load:
+
+```typescript
+async function processWithDelay(items: any[]) {
+ for (const item of items) {
+ await supabase.functions.invoke('process-item', { body: item })
+ await new Promise((resolve) => setTimeout(resolve, 100)) // 100ms delay
+ }
+}
+```
+
+## Common patterns and their impact
+
+| Pattern | Budget consumption | Recommendation |
+| --- | --- | --- |
+| Simple chain (A to B to C) | Low | Generally safe |
+| Fan-out (A to B, C, D, E) | Moderate | Limit concurrency |
+| Deep recursion (A to A to A...) | High | Set max depth |
+| Unbounded loops | Very high | Avoid, use queues |
+
+## Increasing rate limits
+
+Currently, all plans have the same rate limit budget. We are working on introducing custom limits for different use cases.
+
+If you need a higher rate limit for your project, [contact support](/dashboard/support/new) with details about your use case.
diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml
index 94bfb40c377..110de046c84 100644
--- a/supa-mdx-lint/Rule003Spelling.toml
+++ b/supa-mdx-lint/Rule003Spelling.toml
@@ -377,6 +377,7 @@ allow_list = [
"asyncpg",
"bcrypt",
"behaviors",
+ "[Bb]ackpressure",
"camelCase",
"dbdev",
"degit",