A Lovable edge function not working after publish fails for one of three reasons, and the error name tells you which. A FunctionsHttpError means the function ran and returned an error, so read its logs. A FunctionsFetchError means the browser never reached it: CORS, a wrong URL, or a paused project. A FunctionsRelayErrormeans Supabase couldn't start it.
An edge function is the small piece of server code a Lovable app uses for anything that can't happen in the browser: calling an AI model, sending an email, talking to Stripe. It runs on Supabase, either inside Lovable Cloud or in your own Supabase project. The preview and the published app call the same function, which is why this is confusing: the code didn't change, the surroundings did. This guide goes from the error line to the setting that caused it. It's written for the founder, with enough detail for whoever helps them.
Which edge function error do you have?
Open the live app in Chrome, press F12, open the Console tab, and do the thing that fails. Then find the line that mentions the function. Supabase's client throws one of three error classes, and its reference docs show how to tell them apart with instanceof. The class is the fastest diagnosis you'll get.
| What the console says | What it means | Read |
|---|---|---|
FunctionsHttpError, "Edge Function returned a non-2xx status code" | Your code ran and returned 4xx or 5xx. The real message is in the response body and the function logs | Causes 1, 3, 5; then the logs section |
FunctionsFetchError, "Failed to send a request to the Edge Function" | The browser never got a usable response: CORS blocked it, the URL is wrong, or the project is paused | Causes 2 and 4 |
FunctionsRelayError | Supabase accepted the request but couldn't run the function: it isn't deployed or failed to start | Causes 4 and 6 |
| "blocked by CORS policy", "No 'Access-Control-Allow-Origin' header" | The preflight request wasn't answered, or an error response went out without CORS headers | Cause 2 |
| 401, "Missing authorization header" or "Invalid JWT" | Supabase rejected the call before your code ran | Cause 3 |
| 404 | No function with that name on that project | Cause 4 |
| 504 Gateway Timeout | The function took longer than 150 seconds to answer | Cause 5 |
| 500 with nothing useful in the browser | A missing secret, a crash, or an import that failed. The logs have the line | Causes 1 and 6 |
One thing to know before you start: the browser only shows a status code. The message your function wrote, like "OPENAI_API_KEY is not set", is in the response body and in the logs. Supabase's docs read it from a FunctionsHttpError with await error.context.json(), so if your app only logs error.message, you're debugging blind.
Edge Function returned a non-2xx status code: the real error
This line is not your function's error. It's the fixed text the Supabase client uses for every response outside 200 to 299. Your function may have written "OPENAI_API_KEY is not set" or "Missing authorization header" in its reply, and the client replaced it with this one sentence. That behaviour has been an open issue on the functions-js repository since April 2023, and the repository was archived in January 2026, so the message isn't going to get better. What you can do is read the response the client kept. It's on error.context, with the status code and the body.
import { FunctionsHttpError } from "@supabase/supabase-js";
const { data, error } = await supabase.functions.invoke("ask-ai", {
body: { prompt },
});
if (error instanceof FunctionsHttpError) {
const status = error.context.status;
const body = await error.context.text(); // .json() if the function returns JSON
console.error(`ask-ai failed with ${status}:`, body);
}Three lines, and the console goes from "something failed" to "401, Missing authorization header" or "500, OPENAI_API_KEY is not set". If you don't edit code yourself, this is the change to ask for, in these words, before asking for any fix:
The last sentence matters. When the status is 401, the fastest change a tool can make is to turn JWT verification off for every function. The error disappears and every function is now open to anyone on the internet. If a fix attempt did that, it belongs in the same category as Lovable breaking the same feature on every fix: roll it back and restate the problem with the status and body you now have. Once you have them, this table says which cause you're looking at. Supabase's status codes page gives each code a platform meaning, and the right column maps it onto this guide.
| Status | What Supabase says it means | Cause |
|---|---|---|
| 400 or 422 | Your own code returned it, usually a missing or malformed field in the request. The body says which | Read the body. Often a front-end change renamed a field |
| 401 | JWT verification is on and the token is missing, invalid, or expired | Cause 3 |
| 404 | No function by that name on that project, or the URL path is wrong | Cause 4 |
| 405 | An HTTP method the function doesn't accept | The app sends GET to a function written for POST, or the reverse |
| 500 | The function threw an uncaught exception, logged as WORKER_ERROR | Causes 1 and 6, then the logs |
| 503 | The function failed to start, logged as BOOT_ERROR: a syntax error, a bad import, or the platform itself | Cause 6 |
| 504 | No response within the 150-second request timeout | Cause 5 |
| 546 | Stopped for exceeding a resource limit, logged as WORKER_RESOURCE_LIMIT: 2 seconds of CPU time or the memory cap | Cause 5 |
A 503 deserves one extra check. Supabase's 503 page lists three sources: a boot failure, a 503 your own code returned (for example, passing through a provider's outage), or a platform problem. If the response body contains BOOT_ERROR, it's the first. If every function on the project started failing at the same minute with nothing in your logs, check Supabase's status page before touching code.
Failed to send a request to the Edge Function: what it means
This is the other generic line, a FunctionsFetchError. It means the browser never received a response it was allowed to read, so your function's code probably never ran. Three situations produce it, and one of them is disguised. Supabase's 404 troubleshooting page says browsers misreport a 404 as a CORS error, and the JavaScript client then shows only "Failed to send a request to the Edge Function". So a function that doesn't exist on this project and a function with a CORS bug look identical in the console.
- CORS blocked it. The
OPTIONSpreflight wasn't answered, or the response came back without theAccess-Control-Allow-Originheader. Cause 2. - The function isn't there.Supabase's 404 page names two platform codes:
NOT_FOUND, the name isn't recognised at all, andNOT_FOUND_FUNCTION_BLOB, the name is known but the deployed bundle is missing, which can happen while the dashboard still shows the function as active. Cause 4. - Nothing answered.The project is paused, or the browser is offline. Every function fails at once and the Network tab shows the request as failed rather than as a status code. When the whole app stops reading data too, it's a broken Supabase connection, not a function problem.
To tell them apart, open Chrome's Network tab (F12, then Network), filter on functions/v1, and trigger the failure. Click the red row. A 404 in the Status column is situation two. A status of "CORS error" or "(failed)" with an OPTIONS row above it that has no access-control-allow-origin header is situation one. No row at all, or one that stays pending, is situation three. The preflight curl commandfurther down gives the same answer from outside the browser. For situation two, Supabase's page says to check the name for typos, including capitalisation and a long dash typed in place of a hyphen, then redeploy the function, and to contact its support if a redeploy doesn't clear it.
Cause 1: secrets not set in the published backend
The most common cause, and the one with the vaguest symptom. The function needs an API key, reads it with Deno.env.get("OPENAI_API_KEY"), gets nothing, and either crashes with a 500 or sends an empty key to the provider and gets a 401 back from them. The browser shows a FunctionsHttpError and nothing else.
Where the key has to live depends on which backend the app uses. Lovable's Cloud docs describe two setups: Lovable's own hosted backend, which it provisions for you, and an external backend you connect yourself, such as your own Supabase project. They're separate places with separate secrets, and Lovable's docs say it doesn't move a project from one to the other in one step.
On Lovable Cloud
Secrets go in Lovable's Secrets manager. Lovable's secrets page says this manager is for backend values only, such as Stripe or Resend keys, that functions read them at runtime through Deno.env.get, and that once saved a value can never be viewed again, only replaced. Two things catch people. First, a value with a VITE_ prefix belongs in the project's .envfile and ships to the browser, so it isn't a secret at all and a function can't read it. If your AI key is in .envwith that prefix, it's both missing from the function and public, which is the situation in what to do when a Lovable API key is exposed. Second, the same page says only the names of your secrets are copied when you remix a project, not the values, so a remixed app starts with every key missing.
On your own Supabase project
Secrets go in the Supabase dashboard under Edge Functions, then Secrets, or from a terminal with supabase secrets set NAME=value. Supabase's guide says functions read a new secret immediately, with no redeploy. The guide also lists two rules worth checking: a secret name can't start with SUPABASE_, because that prefix is reserved and the dashboard rejects it, and only project Owners and Administrators can create or delete production secrets. If a developer added the key and it didn't stick, that's why.
The check is the same in both setups: list the secrets, and compare each name letter for letter with the name the code asks for. AI-generated code renames things between prompts. A function that reads OPENAI_KEY while the secret is called OPENAI_API_KEY fails exactly like a missing key.
Cause 2: CORS error and the OPTIONS preflight
CORS is the browser's rule that a page on one domain can't call a server on another unless the server says it's allowed. Your published app is on yourapp.com and the function is on yourref.supabase.co, so every call is cross-domain. Before the real request, the browser sends a small OPTIONSrequest asking for permission. Supabase's CORS guide says functions must handle that preflight, and its troubleshooting page lists the three usual failures: a missing Access-Control-Allow-Origin header, an unhandled OPTIONS request, and mismatched allowed methods or headers.
This shows up after publishing for a plain reason: the function was only ever tested from the preview, and the generated code answered the preflight for some paths and not others. The handler needs to return the headers on the OPTIONS request, on every success, and on every error. The last one is what most generated code misses. When an error goes out without the headers, the browser reports a CORS failure instead of the real message, which is why a missing secret sometimes looks like a CORS problem.
import { corsHeaders } from "npm:@supabase/supabase-js@^2/cors";
Deno.serve(async (req) => {
// 1. Answer the preflight with the headers and nothing else.
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { prompt } = await req.json();
const key = Deno.env.get("OPENAI_API_KEY");
if (!key) throw new Error("OPENAI_API_KEY is not set on this project");
const answer = await askModel(prompt, key);
// 2. Headers on the success response.
return Response.json({ answer }, { headers: corsHeaders });
} catch (err) {
// 3. Headers on the error response too, or the browser hides the message.
return Response.json(
{ error: (err as Error).message },
{ status: 400, headers: corsHeaders },
);
}
});Supabase's guide says the corsHeaders import exists from supabase-js 2.95.0 and keeps the allowed-headers list in step with what the client sends. On older versions it shows the same headers hardcoded in a shared _shared/cors.ts file, with Access-Control-Allow-Origin: *, the methods list, and the allowed headers including authorization, x-client-info, apikey, and content-type. If your copy of that file is older and the client has started sending a header it doesn't list, the preflight fails for that header alone.
Cause 3: 401 from JWT verification
By default Supabase checks every call to an edge function for a valid login token (a JWT) in the Authorization header before your code runs. Its troubleshooting page lists the three ways this fails: an invalid or expired token, a missing Authorization header, and a user without the right permissions. When the check fails you get a 401, and the function logs show nothing, because the function never ran.
In a Lovable app this appears after publishing in two ways. A page that calls the function for visitors who aren't logged in: in the preview you were always logged in, so the token was always there. Or code that calls the function with plain fetch instead of supabase.functions.invoke, so no header is sent at all. The Supabase client adds the apikeyheader and the user's session token for you, which is the reason to use it.
For a function that should be public, or one called by another service that has no Supabase login, turn the check off for that function only, and do your own check inside the code:
[functions.ask-ai]
verify_jwt = falsePut it in the config file rather than only in the dashboard, because a deploy from the repo can overwrite a dashboard setting. The Stripe webhook guide covers that case in detail, since Stripe never sends a login token and the webhook function has to verify Stripe's signature instead. The rule is the same for any function: turning the platform check off is fine only when the function checks something else.
Cause 4: function not deployed, or on the wrong project
Supabase's troubleshooting page puts three network causes together: the function isn't deployed, the URL is wrong, or there's a connectivity problem. In a Lovable app the first two have one root: the app and the function point at different places.
On Lovable Cloud, the edge functions page says Lovable writes and deploys functions when you describe a feature in chat, and the Cloud tab lists each function with its status, invocation count, and success rate. If the function you expect isn't in that list, it was never deployed, however much code for it exists in the project. The same page says functions are unavailable while a project is paused, which is why everything backend can stop at once.
On your own Supabase project, the function has to be deployed to the project the published app talks to. Open the Edge Functions list in the Supabase dashboard and confirm the function is there. Then compare the project reference in the app's VITE_SUPABASE_URL (the part before .supabase.co) with the reference in the dashboard address bar. They drift apart when someone creates a second project for production, when a developer deploys from a laptop logged into a different account, or part way through moving a Lovable app to your own hosting, when the front end has moved and the functions haven't. A 404 with the function's exact name in the URL is this cause until proven otherwise.
Cause 5: timeouts, cold starts, and compute limits
Supabase's limits page gives the numbers. A request that doesn't get a response within 150 seconds returns a 504 Gateway Timeout to the caller. Each request gets 2 seconds of CPU time, counting only work on the processor, not waiting on a network call. Memory is 256MB. A worker stays alive for 150 seconds on the Free plan and 400 seconds on paid plans, serving more than one request while it lasts.
The AI feature is where these bite. A function that calls a model, waits for the whole answer, and then replies can pass 150 seconds on a long prompt. The fix is to stream the response back as it arrives, or to accept the job, return immediately, and let the app poll for the result. CPU time catches a different pattern: code that parses a big PDF, resizes images, or hashes a large file inside the function. Two seconds is enough for most logic and not enough for that, and the error is a 546, and Supabase's page for that code says the logs will show either "CPU Time exceeded" or "Memory limit exceeded" to tell you which cap was hit.
Cold starts are the slow first call after a quiet spell, while Supabase starts a fresh worker. In the preview you were calling the function all afternoon, so it was always warm. The first customer of the morning gets the cold one. If the app's own timeout is shorter than that start, the call looks failed when it isn't. Set the client's timeout above a few seconds, and show a loading state rather than an error on the first attempt.
Cause 6: import errors that only show in the logs
A function that fails to start never answers a request, so the browser sees a relay error or a generic failure and nothing explains it. The explanation is in the Logs tab, which Supabase's troubleshooting page says shows platform events, including deployments and errors. The usual lines are a module that couldn't be fetched, a package that doesn't run on Deno, or a syntax error in a file the function imports.
AI tools are a frequent source of this. A prompt asks for an email feature, the tool imports a Node-only package, and the function deploys but can't boot. Or a shared file is edited for one function and breaks every function that imports it, which is why three features can fail from one change. The limits page adds a size rule: a bundle can be 20MB when built locally with the CLI and 5MB when built server-side through the dashboard, so a function that pulls in a large library can fail to deploy at all through one path and work through the other.
The fix is to read the first error line in the deploy log, swap the package for one that supports Deno (most packages on npm do, imported with the npm: prefix), and deploy again. If the function worked last week and the only change was a Lovable edit, roll that edit back first and confirm it boots, then ask for the change again with the error text in the prompt.
How to read Supabase edge function logs
Every cause above leaves a line in the logs, so this is the step to do before changing settings. Supabase's troubleshooting page describes two tabs on each function. Invocations shows the request and response for each execution, which is where you see the status code, how long it took, and the body the function sent back. Logs shows platform events, including deployments and errors, and the output of every console.log and console.errorin the code. In the current dashboard those two tabs are merged: Supabase's logging page says the Logs view shows invocations and log events in one list, and the separate Invocations and Logs tabs appear only if you turn the updated interface off. The platform's own lines carry the event names from the status table: WORKER_ERROR for an uncaught exception (a 500), BOOT_ERRORfor a function that couldn't start (a 503), and WORKER_RESOURCE_LIMIT for one that hit the CPU or memory cap (a 546). The logging page also says uncaught exceptions are logged automatically with their stack trace, so a 500 always has a line here even when the code has no logging of its own.
- On your own Supabase project, open the dashboard, then Edge Functions, then the function, then Invocations. On Lovable Cloud, Lovable's edge functions page says the list under More, Database, Manage, Edge functions shows each function with its status, invocation count, and success rate, and a View logs option that filters the Logs view to that function.
- Trigger the failure once from the live app, then refresh the list. If nothing appears, the request never reached the function: that is a CORS, 401, 404, or wrong-project cause, not a code cause.
- If a row appears with a 4xx or 5xx, open it and read the response body. That is the message your code wrote, or the uncaught error.
- Switch to Logs and read the lines around the same time. A missing secret, a failed import, or a provider's error response is here, usually in the first
console.errorline. - Paste that one line, not a screenshot of the browser, into Lovable or to whoever is fixing it. The browser line says something broke. The log line says what.
How to test an edge function with curl
The fastest way to separate "the function is broken" from "the app calls it wrong" is to call it from outside the app. curlruns in the Terminal on a Mac or in PowerShell on Windows. You need the project URL and the anon (publishable) key, both of which are already in the app's .env and safe to use here. Two commands cover most of this guide.
curl -i -X OPTIONS "https://YOURREF.supabase.co/functions/v1/ask-ai" \
-H "Origin: https://yourapp.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization, content-type"You want a 2xx with access-control-allow-originin the headers. A 404 is cause 4. A 2xx without that header, or a 401, is cause 2: the function isn't handling OPTIONS before anything else.
curl -i -X POST "https://YOURREF.supabase.co/functions/v1/ask-ai" \
-H "Authorization: Bearer YOUR_ANON_KEY" \
-H "apikey: YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"hello"}'A 401 here with "Missing authorization header" or "Invalid JWT" is cause 3. A 500 with a message in the body is cause 1 or 6, and the message tells you which. A 504 after a long wait is cause 5. A 200 with the right answer means the function is fine and the problem is how the app calls it: the wrong URL, a missing header, or a CORS header the app sends that the function doesn't allow. Note that this test uses the anon key, so a function that reads the logged-in user from the token will see no user. That's correct for a public function and the expected failure for a private one.
Why it worked in preview and fails after publishing
Nothing about the function's code changes when you press Publish. What changes is everything around the call, and each change maps to one cause above. This is the table to work through when the error class alone doesn't settle it.
| In the preview | After publishing | Cause |
|---|---|---|
| Page runs on a Lovable preview address | Page runs on your custom domain, a new origin | 2: CORS |
| You are always logged in | Visitors and new sign-ups call it logged out | 3: 401 |
| Function called every few minutes | Quiet overnight, cold start in the morning | 5: timeouts |
| Secrets added on the project you built in | Live app points at a second project, or a remix | 1 and 4: secrets, wrong project |
| Short test prompts and small files | Real prompts, real PDFs, real images | 5: CPU and memory limits |
| Last edit tested on one function | A shared file broke three others | 6: boot errors |
| Free project in daily use | Free project paused after inactivity | 4: nothing deployed answers |
When to get help with Lovable edge functions
If the error table pointed at one cause and the curl test confirmed it, the fix is a setting or a few lines, and you're done. Get help when more than one function is failing, when the fix has to touch a shared file that every function imports, when the function moves money or sends email to customers, or when you've been through three Lovable fix attempts and it still fails. The backend is where generated code is weakest, and it's where mistakes cost the most. On The Chat Company, an AI-generated demo I took over, I kept about 20% of the code and rewrote the rest before it could serve real businesses. Nine weeks later it was a SaaS used by 30+ businesses, and it now handles 100K+ messages a day.
Secret keys, logins, and Stripe webhooks are three of the six areas I check in a Launch-Ready Review: a fixed-price check of a vibe-coded app for $900–1,500 in three to five days, with a written report and a priced fix list. If the list is long, fixing it is a Production Sprint for $3,000–7,500, and the review fee comes off that price. Before launch, the free vibe-coded app launch checklist covers secret keys and Stripe webhooks alongside data access and backups. Either way, send me the app's linkand the error line from the console or the logs, and I'll tell you which cause it is.
Sources
- Error handling: FunctionsHttpError, FunctionsRelayError, FunctionsFetchError, Supabase Docs
- Non-200 status responses do not expose message (functions-js issue 55, open since April 2023), GitHub, supabase/functions-js
- Edge Function status codes: 401, 404, 405, 500 WORKER_ERROR, 503 BOOT_ERROR, 504, 546, Supabase Docs
- Edge Function 404 error response: browsers misreport 404s as CORS errors, Supabase Docs
- Edge Function 503 error response: boot error, your code, or the platform, Supabase Docs
- 546 - WORKER_RESOURCE_LIMIT Exceeded: CPU or memory limit hit, Supabase Docs
- Edge Function logging: uncaught exceptions, boot and shutdown events, the 10,000 character limit, Supabase Docs
- Unable to call Edge Function: CORS, authentication, network, and logs, Supabase Docs
- CORS in Edge Functions: handling the OPTIONS preflight and the shared headers, Supabase Docs
- Edge Function secrets: the dashboard, the CLI, default variables, and no redeploy, Supabase Docs
- Edge Function limits: 150s idle timeout, 2s CPU, 256MB, bundle size, Supabase Docs
- Lovable Cloud: edge functions, secrets, logs, and your own Supabase project, Lovable Docs
- Edge functions on Lovable Cloud: status, invocations, View logs, and paused projects, Lovable Docs
- Secrets on Lovable Cloud: backend values only, VITE_ in .env, not copied on remix, Lovable Docs



