# “Edge Function returned a non-2xx status code”: the fix

supabase-js hides the real error behind “Edge Function returned a non-2xx status code”. How to read it from error.context, and why a crash looks different.

Author: Formgong
Published: 2026-10-16
Language: en
Canonical: https://formgong.com/en/blog/supabase-edge-function-non-2xx-status-code/

supabase-js writes “Edge Function returned a non-2xx status code” for any 4xx or 5xx answer, and hides your function's own message in error.context, a Response. Read it with await error.context.text(), then fix what it says. In the browser, an uncaught crash without CORS headers shows up instead as “Failed to send a request to the Edge Function”.

## Where the message comes from

*Figure: A contact form shows an error after Send. The console shows FunctionsHttpError: Edge Function returned a non-2xx status code, and reading error.context reveals the function's own message, Email is required, with status 400.*

On 16.10.2026 we called a function with `supabase.functions.invoke()` from @supabase/supabase-js 2.118.0 and made it fail in different ways. For every 4xx and 5xx answer, `error.message` was the same sentence, `Edge Function returned a non-2xx status code`, and `error.name` was `FunctionsHttpError`. The function's own answer was in `error.context`, the raw `Response`:

What error.context held
What the function didStatus`await error.context.text()`

Returned a JSON error400`{"error":"Email is required"}`
Returned a text error400`Email is required` (so `.json()` threw)
Threw an uncaught error500`Internal Server Error`; the real message was only in the function's log

## Read the real error

Branch on the error class. `FunctionsHttpError` means the function answered; read the body as text first, because an error page or a crash is not JSON. `FunctionsFetchError` means there was no answer you can read.

```javascript
import { FunctionsHttpError, FunctionsFetchError } from "@supabase/supabase-js";

const { data, error } = await supabase.functions.invoke("contact", { body: { name, email, message } });

if (error instanceof FunctionsHttpError) {
  // The function answered with 4xx or 5xx. The real reason is in the body.
  const text = await error.context.text();
  console.error("contact function", error.context.status, text);
} else if (error instanceof FunctionsFetchError) {
  // No usable answer at all: network, wrong project URL, or a CORS block in the browser.
  console.error("could not reach the function", error.context);
}
```

## In the browser a crash looks different

We ran the same three calls in Chromium 152. The 400 answers came back as `FunctionsHttpError` like above. The crash did not: Deno's 500 answer to an uncaught error carries no CORS headers, so the browser blocked it, and supabase-js reported `FunctionsFetchError: Failed to send a request to the Edge Function`, with `Failed to fetch` inside. The console showed “blocked by CORS policy”.

So in a browser app, “Failed to send a request” after a deploy often means the function crashed, not that it is unreachable. Catch errors inside the function and return them with the CORS headers, as below. Our [Supabase CORS guide](/en/blog/supabase-edge-function-cors/) goes deeper into that case. A made-up project URL, for comparison, gave the same `FunctionsFetchError` with `ENOTFOUND` underneath.

```typescript
// supabase/functions/contact/index.ts
import { corsHeaders } from "npm:@supabase/supabase-js@^2/cors";

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
  try {
    const { email } = await req.json();
    if (!email) return Response.json({ error: "Email is required" }, { status: 400, headers: corsHeaders });
    // … send the email …
    return Response.json({ ok: true }, { headers: corsHeaders });
  } catch (err) {
    console.error(err); // shows up in the function's logs
    return Response.json({ error: "Could not send the message" }, { status: 500, headers: corsHeaders });
  }
});
```

## Then look at the function's log

An uncaught error's message never reaches the client. In our test it appeared only in the function's own output: `Error: RESEND_API_KEY is not set`. On Supabase, open the function in the dashboard and read its logs, or run it locally with the CLI. A missing secret is the cause we would check first, because secrets set in a local `.env` do not reach the deployed function.

## Do you need an Edge Function for a contact form?

If the function exists only to email you the form, it can go: the form can post to [Formgong](/en/) at `https://formgong.com/submit`, which answers every error with CORS headers and a JSON body that says what went wrong. See the [HTML guide](/en/docs/html/).

## Frequently asked questions

### What does Edge Function returned a non-2xx status code mean?

Your function answered with a 4xx or 5xx status. The sentence comes from supabase-js; the function's own message is in error.context, which you read with await error.context.text().

### How do I get the error message from a Supabase Edge Function?

Check error instanceof FunctionsHttpError, then read error.context.text(). If the function crashed, the message is only in the function's logs.

### What is FunctionsFetchError: Failed to send a request to the Edge Function?

There was no answer the client could read: a wrong project URL, a network problem, or, in the browser, a crash whose 500 answer had no CORS headers.

## Sources and documentation

- [Supabase: Invoking Edge Functions](https://supabase.com/docs/reference/javascript/functions-invoke)
- [Supabase: Error handling in Edge Functions](https://supabase.com/docs/guides/functions/error-handling)
- [Supabase: CORS for Edge Functions](https://supabase.com/docs/guides/functions/cors)
- [HTML contact form](https://formgong.com/en/docs/html/)

[Get a form key](https://formgong.com/en/#top)
