Edge Function limits, one ceiling at a time
When a deploy of a Supabase Edge Function breaks, the report usually says “we hit the limit”. There are several ceilings, most of them have a different lever, and two common failures are not ceilings at all. Each ceiling below has its documented figure next to what the platform returned when the figure was crossed.
Every figure marked measured comes from one battery of the
supabase-lab harness,
experiments/edge-function-limits,
run on 2026-09-02 against a Micro project in ap-southeast-1 on a Pro
organization, created and destroyed within the hour, with Supabase CLI 2.116.0
and Docker present on the operator machine. The docs page the figures are
compared against was read the same day.1 Plan entitlements were
read from a Free, a Pro and a Team organization. Anything not marked measured
is cited to the docs and was not run.
Rows dated 2026-10-10 come from a second experiment,
edge-runtime-auth
(module ER02), run the same day against its own throwaway project on a Pro
organization in ap-southeast-1, from a Singapore vantage, with
@supabase/supabase-js 2.117.3. They cover what a caller sees around a
redeploy, whether functions.invoke retries, and latency; they are
separate from the 2026-09-02 battery.
TL;DR:
- Functions per project is a plan entitlement.
function.max_countreads 100 on Free, 1000 on Pro, 2000 on Team, matching the docs, which doubled the Pro and Team figures on 2026-08-18. A cap of exactly 1000 identifies a Pro organization on the current table; confirm which organization before treating it as a platform limit. A cap that matches no plan figure points at an override on the entitlement row (reasoned, not measured). - Bundle size is set by where bundling happens, and both ceilings return
the same 413. The Management API and the CLI’s
--use-apibundle server-side and refused an 8 MB source with413 request entity too large; the Dashboard deploys through the same endpoint and was not driven separately. The CLI’s default with Docker bundles locally, landed 8 MB in 32 s, and refused 24 MB with the same 413 body. The size entitlement (function.size_limit_mb) is 20 on every plan. - Secrets are four limits. 100 user-defined secrets per project, 24,576
characters per value, 256-character names, and no
SUPABASE_prefix. All four bit at exactly the documented boundary. The value ceiling counts characters: 24,576 three-byte characters (73,728 bytes) were accepted. - Deploys can report success and not land, with nothing to see. 24 Management API deploys, run 8 at a time, all returned 201 and 10 of the 24 were present afterwards. 8 concurrent CLI processes all exited 0 and 9 of 24 functions were present. No 429 surfaced in either run. Verify with a GET per function after every deploy round; the exit code is not the state.
- Invocation-time ceilings: exhaustion is one code, and the rest were
measured. CPU past 2 s and memory past 256 MB both answer
546 WORKER_RESOURCE_LIMITwith identical bodies. An active stream is cut between 395 s and 400 s on a paid project; the two log limits truncate at exactly 10,000 characters and 100 events; the recursive cap documented on 2026-09-02, ~5000 calls per minute per request chain, did not bite at about 110,600 nested calls per minute. Since 2026-09-232 the docs give each trace “a budget of 30 requests within a 60-second window”3; at 2 nested calls per trace the run did not approach it. - Two documented restrictions did not hold as written. Port 587 was reachable from the runtime on two runs (port 25 hangs), and a static file sent through the API deploy was accepted with 201 and then missing at runtime.
The order of questions
Section titled “The order of questions”The same order, as text:
- Read the error string. A limit names itself: a count, a size, one of the four secrets limits.
- If nothing is named, ask how many deploys were in flight. A 413 under parallelism needs a serial reproduction before it is a size limit; a 429 is the control-plane throttle, not a quota; a 409 is two deploys of the same slug overlapping.
- Then read
GET /v1/projects/{ref}/functions/{slug}for every function the run claimed to deploy. A 2xx or an exit code of 0 with the function absent is silent loss.
A limit rejects deterministically with a named error and reproduces on a retry of the same input. Deployments that break intermittently, or that report success and then turn out not to have happened, point somewhere else. The sections below take the named limits first, then the failures that only look like one.
Which ceiling is it
Section titled “Which ceiling is it”| What was reported | Which ceiling | Lever |
|---|---|---|
| a hard cap on creating functions, at 100 / 1000 / 2000 or some other figure | functions per project, plan-gated with a per-organization override | read function.max_count on the org: if the row equals the plan figure the lever is a plan change; if it does not (above or below) the lever is a change to that row, which no public API performs |
413 request entity too large on a serial deploy | bundle size for that deploy path | move the deploy to local CLI bundling (20 MB) or shrink the bundle |
| 413 only when several deploys run at once | possibly parallelism (a reported shape; neither parallel run in this battery produced a 413) | reproduce the same bundle serially before quoting a size |
You can only store 100 secrets per project at maximum. | secrets count | fewer secrets per project; one secret per tenant in a shared project is an architecture question |
Too big: expected string to have <=24576 characters | secret value size | split or externalise the value |
Too big: expected string to have <=256 characters | secret name length | rename |
Secret name must not start with the SUPABASE_ prefix. | reserved prefix | rename |
546 WORKER_RESOURCE_LIMIT at invocation | CPU or memory at run time | reduce per-request work; long jobs do not fit an Edge Function on any plan |
504 IDLE_TIMEOUT at invocation | request idle timeout | stream or respond earlier |
| exit 0 or 201, function 404s afterwards | not a limit | verify per function, retry the difference, lower concurrency |
| 409 on deploy | not a limit | two deploys of the same slug overlapped |
429 ThrottlerException: Too Many Requests on a deploy or list call | the Management API throttle, 120 requests per minute per user; not a quota | space the calls out, then verify what landed |
429 {"code":"RATE_LIMIT_EXCEEDED"} on an invocation | an invocation-time rate limiter with a 1 s retry hint; a different limiter from the control-plane throttle above, and not shown to be the recursive cap (0.02 per cent of calls at about 110,000 nested calls per minute, 2 nested calls per trace against a documented budget of 30 per trace within a 60-second window, which the docs say surfaces as a RateLimitError)3 | retry after a second |
| log lines cut at 10,000 characters, or a burst of log events thinning out | the two log limits; the invocation itself succeeded | shorten lines, batch events; nothing to retry |
| a long response stops after several minutes while the logs show 200 | the worker wall clock, 400 s on paid plans and 150 s on Free; streaming does not exempt it | move long work out of the request path |
Functions per project
Section titled “Functions per project”The docs table and the entitlement agree, and the entitlement is what gates.1
| Plan | Documented maximum | function.max_count read from the entitlements API |
|---|---|---|
| Free | 100 | 100 (measured) |
| Pro | 1000 | 1000 (measured) |
| Team | 2000 | 2000 (measured) |
| Enterprise | Unlimited | not read |
The figures have moved. The July 2024 changelog entry that removed usage-based billing for Edge Functions set the caps at 25 on Free, 500 on Pro and 1000 on Team;4 the docs changed to Pro 1000 and Team 2000 in a commit dated 2026-08-18,5 and the Limits page read 100, 1000 and 2000 on 2026-09-02. Those dates belong to the documentation; when the default entitlement rows moved on the platform is not published. A report quoting 500 is most likely reading a note written before the docs changed; confirm on the entitlement row, since a row set before a raise keeps its value.
The figure is an entitlement row per organization. GET /v1/organizations/{slug}/entitlements returns a flat list keyed by feature,
and function.max_count carries a value and an unlimited flag per
organization.6 A row that differs from the plan’s documented
figure is an override, and an override replaces the plan default rather than
lifting it: it can hold an organization above the documented figure or below
it, and a documented raise does not touch a row that was set before it. A
cap that matches no plan figure (800, say) points at the row, and the fix is
on the row; a cap that matches the plan figure points at the plan. This is
reasoned from what an override is, not measured: the three lab organizations
all carried the plan default. The same list carries
function.size_limit_mb, which read 20 on all three organizations.
Function size, by deploy path
Section titled “Function size, by deploy path”The docs give two figures and attach them to deploy paths rather than plans.1 The battery sent the same random, incompressible source down each path and proved every acceptance from the outside by invoking the function and reading back the payload length.
| Bundling path | Documented ceiling | 2 MB | 8 MB | 24 MB |
|---|---|---|---|---|
| Management API multipart deploy (also the Dashboard) | 5 MB | 201, landed, invoke returns 2,097,004 bytes (measured) | 413 request entity too large, nothing created (measured) | not sent |
CLI --use-api | 5 MB (same path by construction) | not sent | exit 1, unexpected deploy status 413: {"message":"request entity too large"} (measured) | not sent |
| CLI default with Docker (local bundling) | 20 MB | not sent | exit 0, landed, invoke returns 8,388,460 bytes, 32 s (measured) | exit 1, unexpected create function status 413: {"message":"request entity too large"} (measured) |
Anyone deploying programmatically through the Management API is on the 5 MB
path by construction, which is the usual shape for a platform that generates
functions per tenant. The Dashboard is the same path. The docs attach the two
figures to deploy paths and to no plan, and the function.size_limit_mb
entitlement read 20 on Free, Pro and Team alike.
The rejection is a 413 on both paths, with the same body. The published OpenAPI document declares 201, 401, 402, 403, 429 and 500 for the multipart deploy endpoint and no 413,6 so this is an undeclared response: a client generated from the schema has no typed branch for it. The CLI wraps it differently (“deploy status” on the API path, “create function status” on the local path) but a customer relaying the message will say “413, request entity too large” either way. The status code therefore does not identify which ceiling was crossed, and it does not distinguish a real size rejection from the 413 that reports attribute to parallel deploys, a shape this battery did not reproduce (neither parallel run below produced a 413). Reproduce the same bundle serially before quoting a size limit.
On CLI 2.116.0 the visible flag is --use-api, which forces server-side
bundling. The older --use-docker flag is still accepted but no longer listed
in --help. With Docker present the default bundled locally.
Secrets: four limits
Section titled “Secrets: four limits”The docs list four figures under one heading, and the Management API declares
all four in the request schema of POST /v1/projects/{ref}/secrets
(maxItems: 100, name maxLength: 256 with pattern ^(?!SUPABASE_).*, value
maxLength: 24576).16 Each was probed at N and N+1.
| Limit | Documented | Measured at the boundary | Error, verbatim |
|---|---|---|---|
| Secrets per project | 100 | 100 user secrets stored; the 101st refused with 400 | You can only store 100 secrets per project at maximum. |
| Value size | 48 KiB (24,576 characters) | 24,576 accepted, 24,577 refused with 400 | 0.value: Too big: expected string to have <=24576 characters |
| Name length | 256 characters | 256 accepted, 257 refused with 400 | 0.name: Too big: expected string to have <=256 characters |
| Reserved prefix | names must not begin SUPABASE_ | refused with 400, nothing stored | 0.name: Secret name must not start with the SUPABASE_ prefix. |
The value ceiling counts characters. A value of 24,576 three-byte characters (73,728 bytes, well past 48 KiB) was accepted, so the “48 KiB” in the docs is 24,576 at two bytes per character rather than a byte limit.
Once a project has had any function deployed, GET /v1/projects/{ref}/secrets
also lists seven platform-managed entries with the reserved prefix (the
project URL, the anon and service-role JWTs, the publishable-key and
secret-key lists, the database URL, the JWKS). They do not count toward the 100:
a run that counted the 7 platform rows toward its total stopped at 93 user
secrets when the list read 100, and the next user secret (the 94th user
secret, the 101st entry in the list) was accepted. Count user-defined secrets
when reasoning about the ceiling.
Ask which of the four was hit before answering. A project pressing against 100 secrets usually means one secret per tenant inside a shared project, and that is a placement question rather than a quota one - see where a tenant should live.
When it is not a limit
Section titled “When it is not a limit”The battery drove the two shapes that get misreported as quota problems and recorded, for every deploy,
both what was reported and what GET /v1/projects/{ref}/functions/{slug} said
afterwards.
Parallel deploys report success and do not land
Section titled “Parallel deploys report success and do not land”| Shape | Reported | Present afterwards | 429 / 409 / 413 seen |
|---|---|---|---|
| 24 Management API deploys, 8 in flight | 24 of 24 returned 201 | 10 of 24 | none |
8 concurrent CLI processes, 3 functions each, --use-api | 8 of 8 exited 0 | 9 of 24 | none |
Both rows are measured. A listing taken several minutes later, during cleanup, found exactly the functions the per-slug reads had found, so the loss is not a read-timing artefact. Both losses came from distinct slugs deployed concurrently, while the same-slug rounds below produced 409s, monotonic versions and a healthy function. In these runs that rules out slug collision; where the loss happens on the platform side is not visible to this battery.
A public issue on the CLI reports the same shape at larger scale: 138 of 237
functions absent after a run in which the CLI reported success for all 237
and exited 0, with unexpected list functions status 429: ThrottlerException: Too Many Requests surfacing on some invocations but far from all of the
missing ones.7 In the lab run the throttle did not surface at all,
so here it explains none of the loss. The
Management API’s documented rate limit is 120 requests per minute, tracked
per user for personal access tokens, and every call for the rest of that
minute answers 429 once it is crossed.8
That issue arrived at the same rule: after every deploy round, list what is deployed and retry only the difference. An automated bootstrap that trusts the exit code ships part of an environment and reports it complete.
Two deploys of the same slug
Section titled “Two deploys of the same slug”| Shape | Statuses | Version sequence | Function afterwards |
|---|---|---|---|
| 3 rounds of 2 concurrent deploys of one slug | 201 x3, 409 x3 | 1 -> 2 -> 3 -> 4, no regression | invoke 200 |
| 5 rounds of 4 concurrent deploys of one slug (second project) | 201 x9, 409 x11 (version read once per round after all four settled) | 1 -> 2 -> 3 -> 4 -> 5 -> 6, one bump per round even where two of the four answered 201, no regression | invoke 200 after every round |
| delete issued 150 ms into a deploy of the same slug | deploy 201, delete 200 | - | present, ACTIVE, invoke 200; one trial |
| delete issued 0-400 ms into a deploy, 10 rounds, then a redeploy of the same slug (second project) | deploy 201 x10, delete 200 x10 | no regression | 8 present and healthy, 2 absent (deploy 201 then absent, attributed to the delete that was in flight); all 10 redeploys 201 and invoke 200; 0 present-but-404 |
| 5 redeploys of one slug through the Management API, one after another, with 4 pollers answering throughout (ER02, 2026-10-10, own project) | 0 non-200 across 618-639 samples per ~30 s cycle | the build hash was read instead of the version: 0 old-build answers after the first new-build answer | new build answered 781-987 ms after the deploy call returned |
All measured. A 409 is what a same-slug collision looks like on this platform, and it is a different failure from a 429; do not merge them in a report. Two or three of every four concurrent deploys of one slug answer 409, the version moved forward once per round, and the function kept answering, so the same-slug metadata corruption this battery was built to look for did not appear in eight rounds across two projects. The delete-during-deploy path was the other shape it was built to look for; eleven rounds produced either a healthy function or a cleanly absent one, and the slug redeployed cleanly after every round. Ten and five rounds repeat the first wave’s result; they do not prove absence, and a run at higher concurrency could still find a signature.
The last row asks what a caller sees while the build changes, which is a
different question from whether a deploy lands. ER02 (measured 2026-10-10) redeployed
one function five times through the Management API multipart endpoint, with
verify_jwt false. Each cycle was observed for about 30 s by 4 sequential
pollers with a 100 ms pause, which collected 618 to 639 samples. The function
returns a build hash that the module substitutes into the source on each
deploy. All times are in milliseconds, counted from when the deploy call
returned.
| Cycle | Deploy API call | Last old-build answer | First new-build answer | Old-build answers after the first new one | Non-200 |
|---|---|---|---|---|---|
| 1 | 1391 | 8 | 975 | 0 | 0 |
| 2 | 1703 | none after the call returned | 781 | 0 | 0 |
| 3 | 1441 | none after the call returned | 976 | 0 | 0 |
| 4 | 1324 | 2 | 987 | 0 | 0 |
| 5 | 1558 | 36 | 974 | 0 | 0 |
No answer of either build arrived between 36 and 781 after the call returned. Successful answers in the first 10 s after a deploy had p95 240 ms and max 1035 ms, against a p95 of 109 ms over the 3 s baselines before each deploy, so a request issued at the boundary took up to about 1 s and then returned the new build. The module does not record when each request was sent, so “held until the new version was ready” and “booted slowly” are not separated. One function, one region, one project, the Management API path; the CLI deploy path and other regions were not run.
A first run of the same module reported a 3 to 6 s window in which both builds answered, with 68 to 143 old-build answers after the first new one. That was a time-origin defect in the module, which counted pre-deploy samples as post-deploy. The figures are withdrawn; the table above is the corrected run.9
There is no published safe concurrency figure. Any number quoted for “how many deploys in parallel is safe” is an estimate.
Restrictions that are not size or count limits
Section titled “Restrictions that are not size or count limits”These sit below the limits tables on the same docs page.1 Two of them break deploys outright, and two did not hold as written.
| Documented restriction | Measured |
|---|---|
Node libraries that require multithreading are not supported (sharp, libvips) | npm:sharp bundled and deployed (201); invocation answered 500 with an empty body. It fails at run time, not at bundle time. |
| Static files cannot be deployed using the API flag; use Docker with the CLI | Through the API deploy with static_patterns: 201, and the file was missing at runtime (NotFound ... /source/static/hello.txt, function 500s). Through CLI local bundling with static_files in config.toml: exit 0 and the file read back. |
| Outgoing connections to ports 25 and 587 are not allowed | Port 25: connect hung to an 8 s timeout. Port 587: TCP connect to a public SMTP host succeeded, on two runs. Port 465, undocumented, also connected. Port 443 control connected. SMTP submission itself was not attempted. |
Serving HTML is only supported on custom domains; otherwise a GET returning text/html is rewritten to text/plain | GET arrived as text/plain. A POST returning the same body kept text/html; charset=utf-8. The rewrite is GET-only, as documented. The static-hosting experiment (HS03, 2026-10-06) found it also covers XHTML and XML; see static site hosting. |
Web Worker API and Node vm API are not available | typeof Worker is undefined, construction throws ReferenceError. import("node:vm") fails with NotCapable: Requires run access. |
The static_files paths in config.toml are relative to the supabase/
directory (./functions/<name>/static/*).10 A glob rooted at
./supabase/functions/... matched nothing and shipped a 654-byte bundle with
no warning, which is the second silent way a static asset goes missing.
Runtime ceilings
Section titled “Runtime ceilings”These are invocation-time limits, separate from anything about deploys.1
| Ceiling | Documented | Measured |
|---|---|---|
| Memory | 256 MB | 64 MB allocation 200; 400 MB -> 546 {"code":"WORKER_RESOURCE_LIMIT","message":"Function failed due to not having enough compute resources (please check logs)"} |
| CPU time per request (excluding async I/O) | 2 s | 500 ms busy loop 200; 3 s -> 546 WORKER_RESOURCE_LIMIT with the body quoted in the Memory row |
| Wall clock | 150 s Free, 400 s paid; the docs define it as how long a worker stays active | a single stream on a fresh worker, ticking every 5 s and never idle, delivered its last tick at 395 s on a paid project and was cut between 395 s and 400 s; the client saw a truncated body, not an HTTP error, because the headers had long since been sent. Because the figure is a worker lifetime, a warm worker serving several requests shares it and a request that starts late in that life is cut sooner into itself; the shared case was not run. Free not run |
| Request idle timeout | 150 s, then 504 | 504 IDLE_TIMEOUT at 150 s, measured in incident resilience, class 10 |
| Recursive or nested calls | ~5000 requests per minute (read 2026-09-02); since 2026-09-23, 30 requests per trace within a 60-second window, per trace, not per project3 | did not bite: a self-calling function ran 59,562 depth-2 chains in 64.6 s, 110,607 nested calls per minute as the module recorded it, on a Micro project; 27 refusals (13 outer, 14 inner) across 119,124 nested calls and about 178,700 invocations in all, 0.02 per cent, answered 429 {"code":"RATE_LIMIT_EXCEEDED","message":"Too many requests. Re-try the request in 1 seconds."}; nothing else refused. Each chain made 2 function-to-function calls, so the run did not approach the current budget of 30 per trace; that budget was not probed. A per-project cap of 5,000 per minute would have refused most of the run |
| Log message length | 10,000 characters | a 12,019-character console.log line was stored as its first 10,000 characters followed by ....[truncated]; the invocation answered 200 |
| Log event threshold | 100 events per 10 seconds | 150 console.log calls inside one invocation: the first 100 were stored (indices 000 to 099), the last 50 dropped; the invocation answered 200 (the burst was under 10 s and inside one invocation, so per-invocation vs per-10 s is not separated) |
| Added latency (not a ceiling) | none cited | 2026-10-10, ER02: 600 s at 1 request/s, one function in ap-southeast-1 from a Singapore vantage. Edge Function p50 88 / p95 112 / p99 146 / max 242 ms over 600 answers, against an RPC twin (a SQL function behind PostgREST /rest/v1/rpc/, called alternately at the same rate) p50 27 / p95 44 / p99 68 / max 147 ms; all 1,200 answered 200. Per-30 s p95: median 110, max 151 ms, 0 of 20 buckets over twice the median. 466 of 600 function answers reported a module-load age under 2 s (age_ms); the other 134 had p50 61 ms |
CPU and memory exhaustion return the same code and the same message, so the response does not say which one was hit; the function logs do. Long-running work does not fit inside an Edge Function on any plan.
The latency row measures the path and states no limit. age_ms is the
time since the canary’s own module was loaded, so a young value shows a recent
module load, which the run does not separate from an instance start or from a
reload inside a longer-lived instance. The module also counted 543 distinct
instance ids over 600 answers while 134 answers carried a served count above 1,
which do not agree, so the instance id is not relied on here. Not measured: a
request rate at which instances stay warm, a window longer than 600 s,
concurrency above 1, more than one function or region, and the platform’s own
logs.9
The two log limits reject nothing. The line length bit at exactly the
documented figure; the event count did too, though the run cannot say whether
the window is ten seconds or one invocation. A function that exceeds them
keeps deploying and keeps answering; its
logs are cut instead, a long line to 10,000 characters plus an explicit
marker and a burst to its first 100 events, which is why they arrive as “we
hit a limit” from someone reading a dashboard rather than from a failed
deploy. The lines were read back through the Management API logs endpoint
(source = 'function_logs'), which needs an explicit iso_timestamp_start
and iso_timestamp_end or answers its generic backend error.
Egress addresses and region pinning
Section titled “Egress addresses and region pinning”Both of these are documented and neither was measured in this battery.
Edge Functions have no static egress IPs. They run globally and do not originate from a single address or a small stable range, so an upstream that allow-lists callers by IP will likely block them.11 The documented answers are an outbound proxy with a static address that the upstream allow-lists, or, for a server you control, a shared secret sent in a request header and checked by the server in place of the address; self-hosting the Edge Runtime on a host with a static IP is the third.11
Region pinning is per call. A function runs in the region closest to the
caller by default; the x-region header, the client library’s region
option, or the forceFunctionRegion query parameter pins one invocation, so
the caller chooses it on every request.12 A call pinned
with x-region is not re-routed to another region during an outage, and the
docs suggest changing the region by hand.12 The default
placement, and execution moving to the pinned region under x-region, are measured in
data residency; failover was not.
What to do about each ceiling
Section titled “What to do about each ceiling”Each row names the measurement it rests on; a row with no module id is a design choice rather than a result.
| Ceiling or failure | Practice | Evidence | Module |
|---|---|---|---|
| Functions per project | Read function.max_count on the organization; route all tenants through one function. | Quote that figure rather than the plan’s. A routing row can be ejected without a redeploy and the function count stays flat. | EF02; edge-resilience W25 (routing-row eject) |
| Bundle over 5 MB | Deploy through the CLI with Docker present, including from a programmatic pipeline. | The CLI bundles locally under the 20 MB ceiling, so a platform that deploys programmatically should run the CLI in its pipeline for large bundles; the multipart endpoint and --use-api are the 5 MB path by construction. | EF04 |
| A 413 | Reproduce the same bundle serially before calling it a size limit. | Both ceilings return the same body, and a serial 413 is a real size rejection. | EF04, EF05 |
| Secrets count | Keep per-tenant configuration out of Edge Function secrets, and count user secrets only. | The secrets list also shows 7 platform SUPABASE_* rows, which do not count against the 100. | EF03 |
| Secrets count | Hold per-tenant configuration in a table, in Vault for anything sensitive. | Vault stores ciphertext, and a row read by tenant id at request time does not consume the secrets ceiling. | EF03; security-lockdown S07 |
| Secret value size | Split a secret above 24,576 characters, or move it to Vault. | The ceiling counts characters, so a base64 blob of up to 24,576 characters fits regardless of byte size. | EF03 |
| Deploys that report success | After every deploy round, list the functions or GET each slug and redeploy the difference. | Treat CLI exit 0, and an OpenTofu Apply complete, as “the request was sent”. Lower concurrency: nothing in the battery lost a serial deploy, and the OpenTofu provider at -parallelism=1 landed all 24. | EF05, EF11, TF01a, TF02a (4 runs) |
| Same-slug deploys | Serialise deploys of one slug. | A 409 is the collision signature; the losing deploy can be retried, and versions stayed monotonic through 8 rounds. | EF05, EF11 |
| Redeploy of a live function | Stamp a build hash in each response and alert on any build you did not just deploy. | After a Management API redeploy no old-build answer followed a new-build answer in 5 of 5 cycles, and the last old-build answer came at most 36 ms after the call returned (measured 2026-10-10, one function, one region). A stamp is the only way to see which build answered. | ER02 |
| Redeploy of a live function | Budget about 1 s of added latency for requests at a redeploy boundary. | The first new-build answer came 781-987 ms after the call returned; successful answers in the first 10 s had p95 240 ms and max 1035 ms against a p95 of 109 ms before. | ER02 |
| Invocation 429 | Retry after the 1 s the body asks for, in your own wrapper. | It appeared on 0.02 per cent of calls. Keep it separate from the control-plane ThrottlerException on deploys, which needs spacing between calls rather than a retry loop. functions.invoke (supabase-js 2.117.3) made 1 attempt on an injected 503 (with and without Retry-After), 502, 500 and 429, 3 trials each, so the retry is the caller’s (measured 2026-10-10; the statuses were returned by the function itself, not by the platform). | EF10; rate-limits L01; ER02 (retry) |
| CPU or memory 546 | Read the function logs, and move CPU-bound or memory-heavy work out of the request path. | The response does not say which of the two was hit. | EF07 |
| Wall clock | Chunk long work below 400 s; queue anything longer with a consumer outside Edge Functions. | Streaming keeps a request alive past the idle timeout but not past the worker’s 400 s. | EF09; edge-resilience W13 |
| Log limits | Log one structured line per request under 10,000 characters, and sample high-volume paths. | The event threshold is 100 per 10 seconds. Nothing warns when the cut happens. | EF08 |
| Image processing | Do not import sharp: use Storage image transformations or a runtime with native threads. | sharp bundles, deploys and dies at invocation; the alternative runtime has to support native threads. | EF06; image transformations billing |
| Static files | Deploy static_files via the CLI, declared in config.toml, or serve it from Storage. | Paths are relative to the supabase/ directory. The API path accepts the deploy and loses the asset. | EF06 |
| Outbound SMTP | Use an HTTP email API. | 25 hangs; 587 and 465 connected in two runs, so the block is not uniform and not a guarantee. | EF06 |
| Upstream that allow-lists by IP | Route through an outbound proxy with a static IP, or, for a server you control, send a shared-secret header. | Edge Functions have no static egress IPs (documented). No lab record. | none |
Region pinned with x-region | Unpin, or switch the pinned region by hand during a regional outage. | Pinned calls are not re-routed (documented); placement is measured in data residency, failover not measured. | none |
| HTML responses | Put the function behind a custom domain. | On the project hostname a GET’s text/html is rewritten to text/plain. Through a custom domain it renders, under /functions/v1/<slug>/ only (static site hosting, measured 2026-10-06). | EF06 |
Where the docs disagree with runtime
Section titled “Where the docs disagree with runtime”| Doc claim | Runtime measured | Severity |
|---|---|---|
| ports 25 and 587 blocked | 25 hangs; 587 connects (two runs); 465 connects | docs overstate |
| static files cannot be deployed via the API | API deploy with static_patterns answers 201; asset missing at runtime | docs understate: it fails silently |
| multithreaded libraries unsupported | sharp bundles and deploys; dies at invocation | deploy-time check absent |
| value ceiling stated as 48 KiB and as 24,576 characters | counts characters; 73,728 bytes accepted | docs conflate two units |
| the multipart deploy endpoint declares 201/401/402/403/429/500 | it also returns 413 on an oversized bundle | undeclared response; a client generated from the schema surfaces it only as an unknown status |
| 100 secrets per project | 100 user secrets; the 7 platform SUPABASE_* rows the list also shows do not count | docs silent |
| recursive or nested calls capped at ~5000 requests per minute (read 2026-09-02) | 110,607 nested calls per minute ran for a minute with 27 429 RATE_LIMIT_EXCEEDED refusals, 0.02 per cent, and no other ceiling | docs figure of 2026-09-02 did not bite at 22x. Superseded 2026-09-23: the docs now give 30 requests per trace within a 60-second window, per trace, not per project; EF10 made 2 nested calls per trace and did not test it |
| wall clock 400 s on paid plans, defined per worker | one stream on a fresh worker: last tick at 395 s, cut between 395 s and 400 s (5 s tick) | holds for a single request; the shared-worker case not run |
| log message 10,000 characters; 100 events per 10 s | 10,000 kept plus a ....[truncated] marker; first 100 of 150 kept | figures hold; the 10 s window was not isolated from a per-invocation count |
| size ceilings 5 MB / 20 MB | both refuse with 413 request entity too large | docs silent on the error shape |
the @supabase/server package docs say a function using publishable, secret or none should disable the platform JWT check13 | with verify_jwt true, valid sb_ keys still reached the handler in the cells tried (2026-10-10); detail and the cells on Server auth modes | docs stricter than the cells needed; see that page |
None of the docs disagreements on this page had been filed with Supabase as
of 2026-09-02. The verify_jwt row added on 2026-10-10 has not been filed
either, per the Server auth modes page.
Reading the numbers
Section titled “Reading the numbers”- One project, one region, one day. The silent-loss counts (10 of 24, 9 of 24) describe that run’s control-plane conditions. The finding is the shape (success reported, function absent, no throttle visible); do not plan against the ratio as a rate.
- The 8 CLI processes ran with
--use-apiso that eight local Docker bundles did not contend for one machine. The public issue ran the CLI’s default and reports the same shape.7 - Port reachability was a TCP connect from inside the function to a public SMTP host, with an 8 s timeout. A hang is recorded as blocked; a completed handshake as open. Nothing was sent on the socket.
- Boundary probes send N then N+1 and record both statuses. A pass means the platform accepted N and refused N+1; the docs figure is what N was set to.
- The size sources are random base64 in a string literal. A repeated character measures the bundler’s compressor rather than the ceiling.
- The second-wave rows (log limits, active wall clock, the recursive cap, the repeated races) come from a second project the same afternoon. The recursion rate is what 100 concurrent clients achieved against a Micro project, about 1,800 nested calls per second averaged over the run (59,562 chains in 65 s, about 916 chains per second); a larger compute tier or more clients would push it higher, and the ~5000-per-minute figure documented on 2026-09-02 still did not appear at this rate. The wall-clock cut is one run on one paid project.
- The ER02 rows (redeploy, retry, latency) are one function on one project in one region, from one vantage, on 2026-10-10. The latency window ran at 1 request/s, a rate at which most answers reported a recent module load, so the figures describe that rate only; a warm steady state was not measured. The retry row is 3 trials per status on one client version.
Evidence, by module
Section titled “Evidence, by module”Measured claims map to module ids in experiments/edge-function-limits; the run record is its RUNLOG.md, and the redacted run artifacts with their measurement tables are published under out/2026-09-02/. Every number in a measured row below was taken from those tables. Documented rows rest on the cited pages only.
| Claim | Status | How it was checked |
|---|---|---|
| Secrets limits declared in the published OpenAPI schema match the docs; deploy endpoint declares 429, not 413 | measured | EF01 - the published OpenAPI document parsed and compared to pinned docs constants |
function.max_count 100 / 1000 / 2000 on Free / Pro / Team; function.size_limit_mb 20 on all three | measured | EF02 - entitlements read per organization, plan from the organization endpoint |
| All four secrets limits bite at N+1 with the errors quoted above; value counts characters; platform rows excluded from the 100 | measured | EF03 - N and N+1 posted per limit, list read between steps, cleanup to zero |
API refuses 8 MB with 413; CLI --use-api the same; CLI default lands 8 MB and refuses 24 MB with 413 | measured | EF04 - random-payload sources, each acceptance proven by GET plus invoke returning the byte count |
| 24 API deploys 8-wide: 24 x 201, 10 present; 8 CLI processes: 8 x exit 0, 9 present; no 429 | measured | EF05 - per-slug GET after the run, cleanup listing minutes later as the second read |
OpenTofu with the supabase/supabase provider 1.11.0, 24 functions plus the secrets resource at -parallelism=24: Apply complete with 0 errors in 4 of 4 runs, 3 to 5 of 24 listed at +10 s and +70 s; in the two runs that probed them, the unlisted ones answered 404 by slug and 200 on invocation. At -parallelism=1: 24 of 24 listed and serving, in 31 to 41 s | measured | TF01a, TF02a (4 runs) |
| Same-slug race: 201 x3, 409 x3, versions monotonic, function healthy; one delete 150 ms into a deploy left the function ACTIVE (first wave, superseded by the EF11 rows) | measured | EF05 - three rounds of two concurrent deploys; one delete 150 ms into a deploy; first project |
| HTML rewrite GET-only; 25 hangs, 587 and 465 connect; Worker and node:vm unavailable; static via API 201 then missing; static via CLI works; sharp 500s at run time | measured | EF06 - one function per restriction, responses recorded verbatim, two runs for ports and static |
CPU 3 s and memory 400 MB both 546 WORKER_RESOURCE_LIMIT | measured | EF07 - busy loop and allocation function with in-limit controls |
150 s idle timeout -> 504 IDLE_TIMEOUT; cold start shape | measured elsewhere | edge-resilience W13 / W18, cited above |
| Log line kept to 10,000 characters plus a marker; first 100 of 150 burst events kept | measured | EF08 - console.log from a function, read back via the logs endpoint (source = 'function_logs', time window required); second project |
| A single stream on a fresh worker, last tick at 395 s, cut before 400 s on a paid project | measured | EF09 - a stream ticking every 5 s asked for 450 s; second project; the shared-worker case not run |
110,607 nested calls per minute with 27 429 RATE_LIMIT_EXCEEDED (0.02 per cent) and no cap | measured | EF10 - one minute of depth-2 self-calls at concurrency 100; second project |
| Delete-during-deploy x10 and 4-wide same-slug x5: no corruption signature | measured | EF11 - GET and invoke after every round, a redeploy after each delete round; second project |
| 5 redeploys of one slug, 4 pollers: 0 old-build answers after the first new-build answer, 0 non-200, first new-build answer 781-987 ms after the call returned; the earlier 3 to 6 s window withdrawn | measured | ER02 - build hash returned by the function, polled at 100 ms pauses, Management API deploy path; 2026-10-10, own project (RUNLOG) |
functions.invoke made 1 attempt on injected 503 (with and without Retry-After), 502, 500, 429 | measured | ER02 - counting fetch on the client plus one table row per request received by the function, 3 trials per status; one client version |
| Edge Function p50 88 / p95 112 / p99 146 ms against an RPC twin p50 27 / p95 44 / p99 68 ms; 466 of 600 answers with module-load age under 2 s | measured | ER02 - the two paths called alternately at 1 request/s for 600 s from one machine |
| Wall clock 150 s on Free | documented | not run; only a paid project was used |
No static egress IPs; x-region pins per call and pinned calls are not re-routed | documented | the troubleshooting and regional invocation pages, cited above; not run in this battery |
| 138 of 237 functions absent after exit 0 at 8 parallel CLI processes | reported | public supabase/cli issue, not reproduced at that scale here |
Modules
Section titled “Modules”Related docs
Section titled “Related docs”- A backend-for-frontend fan-out on Supabase Edge Functions - the implementation of these ceilings: one function serving a screen from four upstream APIs, with the per-call timeouts and the partial-result path that keep the wall clock and the invocation limits below from being the binding constraint. The guide links back here for the numbers.
- Static site hosting on Supabase - what the HTML rewrite means for serving a site, with and without a custom domain.
- Supabase incident resilience - the invocation-time ceilings (idle timeout, cold start) and every other incident class a client can work around.
- Where should a tenant live - the placement decision behind “one secret per tenant” and “one function per tenant”.
- Data surface lockdown - what
verify_jwton an Edge Function does and does not gate. - Server auth modes - what each
@supabase/serverauth mode lets through on an Edge Function withverify_jwton and off; the source of theverify_jwtrow above.
References
Section titled “References”-
Supabase, “Limits,” Supabase Docs. https://supabase.com/docs/guides/functions/limits ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Supabase, “docs: correct function-to-function call rate limit for Edge Functions (#50806),” supabase/supabase commit 4998a13, 2026-09-23. https://github.com/supabase/supabase/commit/4998a137ecea9d0062edbecca10565cecffb5315 ↩
-
Supabase, “Recursive / Nested Function Calls,” Supabase Docs. https://supabase.com/docs/guides/functions/recursive-functions ↩ ↩2 ↩3
-
Supabase, “Edge Functions: Deploy More Functions at No Extra Cost,” Supabase Changelog, 2024-07-18. https://supabase.com/changelog/28062-edge-functions-deploy-more-functions-at-no-extra-cost ↩
-
Supabase, “docs(functions): update Pro and Team function limits (#49173),” supabase/supabase commit 7107a22, 2026-08-18. https://github.com/supabase/supabase/commit/7107a22a6707c06546ee6d62aedb77da9c75b16a ↩
-
Supabase, “Management API OpenAPI document,” api.supabase.com, as served on 2026-09-02 (the document changes without notice). https://api.supabase.com/api/v1-json ↩ ↩2 ↩3
-
supabase/cli, “functions deploy exits 0 without deploying under concurrent load (138 of 237 functions missing),” GitHub issue #6247, 2026-08-18. https://github.com/supabase/cli/issues/6247 ↩ ↩2
-
Supabase, “Management API - Rate limits,” Supabase Docs. https://supabase.com/docs/reference/api/introduction ↩
-
erfianugrah, “experiments/edge-runtime-auth RUNLOG.md, section ER02,” supabase-lab, 2026-10-10. https://github.com/erfianugrah/supabase-lab/blob/c621e0d/experiments/edge-runtime-auth/RUNLOG.md ↩ ↩2
-
Supabase, “CLI configuration -
functions.[function_name].static_files,” Supabase Docs. https://supabase.com/docs/guides/local-development/cli/config ↩ -
Supabase, “Why Supabase Edge Functions cannot provide static egress IPs for allow listing,” Supabase Docs (Troubleshooting). https://supabase.com/docs/guides/troubleshooting/why-supabase-edge-functions-cannot-provide-static-egress-ips-for-whitelisting-3d78b0 ↩ ↩2
-
Supabase, “Regional invocation,” Supabase Docs. https://supabase.com/docs/guides/functions/regional-invocation ↩ ↩2
-
Supabase, “supabase/server,” GitHub, package version 1.9.1 as tested on 2026-10-10. https://github.com/supabase/server ↩