Edge Function returned a non-2xx status code is not a Supabase error. It is the client library telling you that your function ran and answered with something outside the 200 to 299 range, and then throwing away the useful part. The actual reason is in two places: the response body the function sent back, and the function logs in the dashboard. Read both before you touch the code, because the message covers a bad request you sent, an exception your function threw, a function that never booted, a CORS preflight that failed, and a timeout, and each of those has a different fix.
Most of the time spent on this error is spent staring at the wrong thing. The wrapper text is identical whether the function returned a deliberate 400 or crashed with an unhandled exception, so the first job is always to recover the real status and the real body, and only then decide what to change.
Table of contents
- Why the message is so useless
- The 5xx family: crashed, never booted, or ran out of time
- The 4xx family: the request was wrong, and that is fine
- CORS: the failure that never reaches your handler
- A debugging order that ends the guessing
- When the function is fine and the architecture is the problem
- How this fits the rest of the stack
- FAQ
Why the message is so useless
The Supabase JavaScript client wraps every call to supabase.functions.invoke(). When the function responds with a status outside 2xx, the client rejects the promise with a FunctionsHttpError whose message is the generic string you searched for. The status code, the headers and the body are all still there, attached to the error object as error.context, which is the raw Response. The client just does not put them in the message.
That design choice is why so many threads about this error go in circles. The person reporting it has a 400 from their own validation code, or a 500 from a null dereference, or a 401 because the anon key never reached the request, and every one of those looks the same in the console. The fix is a habit rather than a patch: always read the body.
const { data, error } = await supabase.functions.invoke("send-invoice", {
body: { invoiceId },
});
if (error) {
// error.context is the raw Response when the function actually answered
const status = error.context?.status;
const body = error.context ? await error.context.text() : null;
console.error("edge function failed", status, body);
}
Once you log the status and the body the error usually explains itself. A JSON body with your own validation message means the client sent something wrong. An HTML body or an empty body with a 5xx means the function crashed or never started. Two different problems, previously indistinguishable.
The 5xx family: crashed, never booted, or ran out of time
When the status is 500 and you did not write a return new Response(..., { status: 500 }) yourself, the runtime produced it. The function logs in the Supabase dashboard, under the function name and the Logs tab, name the category, and the category tells you where to look.
- WORKER_ERROR means the function booted and then threw an uncaught exception during the request. The stack trace is in the logs. Typical causes are reading a property of
undefinedafter a failed database call, callingJSON.parseon an empty body, or awaiting a fetch that rejected. - BOOT_ERROR means the function never started. That is almost always an import that does not resolve, a syntax error, or a top-level
awaitthat throws. Runsupabase functions servelocally and the same failure appears on your machine within a second, with a readable message. - A timeout means the function started, did work, and did not respond within the wall-clock limit. Long-running third-party calls without a timeout of their own are the usual cause. Wrap outbound fetches with an
AbortControllerand return early with a clear error.
The one habit that removes most WORKER_ERRORs is wrapping the handler body in a try/catch and returning a JSON error with an explicit status. It does not make the bug go away, but it turns a mystery 500 into a 500 with a message, and it stops the runtime from producing the generic one.
Deno.serve(async (req) => {
try {
const { invoiceId } = await req.json();
if (!invoiceId) {
return json({ error: "invoiceId is required" }, 400);
}
// ...work...
return json({ ok: true }, 200);
} catch (err) {
console.error(err);
return json({ error: String(err) }, 500);
}
});
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
The 4xx family: the request was wrong, and that is fine
A 400, 401, 403 or 404 that your own code returned is not a bug. It is the function doing its job. The problem is only that the client treats it as a thrown error, so a validation failure lands in the same catch block as a crash. Decide deliberately how your frontend should react to each status, and read error.context.status to route it.
The 401 deserves its own note. Edge Functions verify the JWT by default, so a request without an Authorization: Bearer header, or with an expired session token, is rejected before your code runs. The client library adds the header automatically when a session exists. When you call the function from a server, a cron job or a webhook, nothing adds it for you, and the function answers 401 with no body worth reading. Either pass the service role key from a trusted server, or deploy with --no-verify-jwt and do your own authentication inside the function for endpoints that must accept anonymous callers, such as payment webhooks.
A 404 usually means the function name in invoke() does not match the deployed folder name, or the function was never deployed to the project you are pointing at. supabase functions list settles that in one command.
CORS: the failure that never reaches your handler
Browsers send an OPTIONS preflight before any cross-origin request that carries JSON or custom headers, and the Supabase client sends both. If your function does not answer that preflight with the right headers, the browser never sends the real request, the client reports a failed invocation, and the function logs show nothing at all because nothing hit your handler.
The fix is a few lines at the top of every function that browsers call. The Authorization, apikey and Content-Type headers must be allowed explicitly.
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// ...handler, and spread corsHeaders into every response...
});
Missing CORS headers on the error responses is a second, subtler version of the same problem. If the success path includes them and the catch block does not, a crash produces a response the browser refuses to expose, and you are back to a message with no body.
A debugging order that ends the guessing
- Log
error.context.statusand the body text in the client. If there is nocontext, the request never got a response: check CORS and the function name. - Open the function logs in the dashboard. Filter by the last few minutes. A
BOOT_ERRORorWORKER_ERRORline points at the runtime, a request line with your own status points at your code. - Reproduce locally with
supabase functions serveandcurl. Local runs print the full stack trace to the terminal and skip the JWT check if you pass--no-verify-jwt, which isolates authentication from logic. - Check secrets.
Deno.env.get()returnsundefinedfor a secret that was set locally but never pushed withsupabase secrets set, and an undefined API key produces a very confident 500 from whichever third party you called. - Only then change the handler. Most fixes at this point are one of: validate input and return 400, wrap the body in try/catch, add CORS, or add a timeout to an outbound call.
This order matters because steps one to four cost nothing and cover almost every case. Rewriting the function first is the expensive path, and it is the one most people take.
When the function is fine and the architecture is the problem
Some of these errors are the function honestly reporting that it is the wrong tool. A handler that needs to run for forty seconds, hold a database connection open across a batch, or keep state between requests is fighting the platform. Edge Functions are short, stateless request handlers, and the timeout that produces a non-2xx is the platform enforcing that.
Work that is long, scheduled, or stateful belongs in a small always-on service next to the database. A Node or Python process on a modest plan, deployed from the repository with its own logs, runs the batch job, the queue consumer or the PDF generator without a wall-clock limit. The Edge Function can still be the thin public endpoint that validates the request and enqueues the job, which is the shape that stops timing out.
How this fits the rest of the stack
The edge function is rarely the whole application. There is a Postgres instance behind it, a frontend in front of it, often a queue or worker for anything the function cannot finish in a few seconds, and the bill for those parts is what actually decides the architecture. The RunxBuild hosting calculator puts the service, the database and the storage on one screen with real plan prices, so the decision about where the long-running work should live is made with numbers rather than by the next timeout. A managed Postgres beside a Node service is the usual landing place for the work that outgrows a request handler.
Useful related references:
- rmdir: Directory Not Empty — Removing Non-Empty Directories in Linux
- scp Port: How to Copy Files Over a Non-Standard SSH Port
- FastAPI in Docker: A Production Dockerfile That Fits in 200MB, Runs as Non-Root, and Survives a Real Outage
- Services on RunxBuild
FAQ
What does Edge Function returned a non-2xx status code mean?
It means the function executed and responded with an HTTP status outside 200 to 299. The Supabase client wraps that in a FunctionsHttpError with a generic message. The real status and body are on error.context, which is the raw Response object, so read error.context.status and await error.context.text() to see the actual reason.
Why do I get a 500 when my function logic returns 200?
Because the 500 came from the runtime, not from your return statement. Check the function logs for WORKER_ERROR (an uncaught exception before your return was reached) or BOOT_ERROR (the function never started, usually a bad import). A CORS preflight failure can also look like this from the browser, with nothing in the logs.
How do I read the error body from supabase.functions.invoke?
The error object carries context, which is the underlying Response. Call await error.context.json() or .text() to read the body your function sent. If context is undefined, no response arrived at all, which points at CORS, a wrong function name, or a network-level failure rather than your handler.
Why does my Edge Function return 401 from a webhook or cron job?
Edge Functions verify the Supabase JWT by default, and a webhook sender does not have one. Either call the function with the service role key from a trusted server, or deploy it with --no-verify-jwt and verify the caller yourself inside the function, for example by checking the webhook signature.
Should long-running work run in an Edge Function?
No. Edge Functions are short, stateless request handlers with a wall-clock limit, and a timeout is reported as a non-2xx status. Keep the function as a thin endpoint that validates and enqueues, and run the long job in an always-on service with its own logs and no request deadline.