A Lovable app is slow once real users arrive because the generated code was tested on ten rows. The usual causes are one query per row, select * with no limit, no index on the columns it filters by, and access policies that run a function on every row. Each is a one-line fix once you know which one you have.
The app felt fine in the preview, then slowed down as the tables grew. A list that took a moment with twenty rows takes seconds with five thousand. Founders tell me it got slow "for no reason", but the reason is always there in the network tab or the database. This guide is for the founder of a Lovable app on Supabase, or Lovable Cloud, and for the developer helping them. It goes from how to tell where the time goes, to the seven causes I find most, with the SQL or code fix for each. If the app doesn't slow down but stops after sign-in, that's a different problem: a Lovable app that hangs after login.
Is it the front end or the database? How to tell
Don't guess. Open the slow screen with Chrome's developer tools on the Network tab, reload, and type supabase.co into the filter. Three things tell you where the time goes:
- How many requests.A screen that shows a list and needs one or two requests is fine. A screen that fires fifty, each a few milliseconds apart, has an N+1 problem, whatever each one's timing says.
- How long each takes.Click a request and open the Timing tab. "Waiting for server response" is the database and the network. If one request waits two seconds, the query is slow. If every request waits 150 ms and there are forty of them, the query is fine and the code asks too often.
- How big the response is. The Size column. A list screen that downloads 4 MB of JSON is fetching the whole table and trimming it in the browser.
If the requests are few, quick, and small, and the screen is still slow, the time is in the browser: rendering, images, or the JavaScript bundle. Jump to the sections on filtering in the browser and on images.
The Supabase Query Performance report
For the database side, the Supabase dashboard has a Query Performance report under Reports. It lists queries by time consumed, with how often each ran, and Supabase's docs say that selecting a query and opening its indexes tab shows index suggestions from the index_advisor extension. The data comes from pg_stat_statements, which Supabase's docs describe as tracking planning and execution statistics of every SQL statement run on the database. You can query it yourself in the SQL editor:
select
calls,
round(mean_exec_time::numeric, 1) as avg_ms,
round(total_exec_time::numeric) as total_ms,
left(query, 120) as query
from extensions.pg_stat_statements
order by total_exec_time desc
limit 10;Sort by total time, not average. A 4 ms query called 30,000 times a day costs more than a 2 second report run twice. Both columns are worth reading: the slow one is a missing index or a policy problem, the frequent one is an N+1 or a fetch on every keystroke.
If the app is on Lovable Cloud
You may not have the Supabase dashboard. Lovable's docs say you can ask it to find slow queries: it reads the query statistics, ranks queries by total execution time with call counts, and can apply a fix such as adding an index. Under More, then Database, then Manage, the settings show CPU, memory, and disk pressure for the last 24 hours. A new or recently restarted database may have no statistics yet, so use the app for a day first.
Lovable app slow: symptom, likely cause, and fix
Match what you saw in the network tab or the report to a row. The sections after this one explain each fix.
| What you see | Likely cause | Fix |
|---|---|---|
| Dozens of small requests per screen | N+1: a query inside a loop or a map | One query with the related rows embedded |
| One request waits seconds; response is huge | select("*") with no limit, or the 1,000 row cap hiding data | Name the columns, add range(), paginate |
| One request waits seconds; response is small | Sequential scan: no index on the filtered column or foreign key | create index, confirm with explain analyze |
| Every query on one table is slow, even by ID | An RLS policy that calls a function per row or joins another table | Wrap the call in (select …), index the policy column, add to authenticated |
| Requests are quick; typing or scrolling stutters | Filtering or sorting a large array in the browser, re-renders | Filter in the query, memoize, virtualize long lists |
| A request on every keystroke in the search box | No debounce on the search input | Wait 300 ms after typing stops, cancel stale requests |
| Fast on your laptop, slow on phones | Large images or a large JavaScript bundle | Resize images, lazy-load, split routes |
| Slow at busy times, errors about connections | Free-tier compute or the connection limit | Fix the queries first, then upgrade compute |
| The first visit after a quiet week takes a minute | A free Supabase project was paused | Restore it; a paid plan can't be paused |
N+1 queries: one request for every row
This is the most common cause I see in generated code, and the one that only shows up with real data. The app loads a list of orders, then for each order asks for its customer. Ten orders is eleven requests and feels fine. Five hundred orders is 501 requests, each paying the full network round trip. A Supabase collaborator wrote in a GitHub discussion that normal REST calls from the US take 100 to 200 ms, almost all of it network time, so 500 of them is over a minute even when every query is instant.
const { data: orders } = await supabase.from("orders").select("*");
const withCustomers = await Promise.all(
orders.map(async (order) => {
const { data: customer } = await supabase
.from("customers")
.select("*")
.eq("id", order.customer_id)
.single();
return { ...order, customer };
}),
);Supabase can return the related rows in the same query when a foreign key links the tables. One request, one round trip:
const { data: orders } = await supabase
.from("orders")
.select("id, total, created_at, customers ( id, name, email )")
.order("created_at", { ascending: false })
.range(0, 19);Look for the pattern wherever a query sits inside map, forEach, or a for loop, and wherever a component that renders inside a list does its own fetch in a useEffect. That second form is the same bug hidden one file away. A DEV Community guide from October 2026 describes a January 2026 r/lovable thread where a client app slowed to 30-second page loads at around 1,000 users, and the causes were an endpoint returning every record, a database call per row, and no pagination. All three are in this post.
select * with no limit, and the 1,000 row cap
Generated code fetches with select("*")and no limit because that worked on the test data. In production it does two things. It downloads every column, including the long description, the JSON blob, and the base64 image someone stored in a text column. And it stops at 1,000 rows without telling anyone: Supabase's docs say projects return a maximum of 1,000 rows by default, a limit you can change in the project's API settings, and they recommend keeping it low and paginating with range().
So an app with 1,400 customers shows 1,000 and the founder thinks 400 are lost. Raising the cap makes the page slower, not the data safer. Paginate instead. Supabase's docs say range() is 0-based and inclusive, so range(0, 19) is the first 20 rows, and the count option returns the total for the page control:
const pageSize = 20;
const from = page * pageSize;
const { data, count } = await supabase
.from("customers")
.select("id, name, email, created_at", { count: "exact" })
.order("created_at", { ascending: false })
.range(from, from + pageSize - 1);Name the columns the screen shows. A list needs five fields, not forty, and the response often drops from megabytes to kilobytes with no other change. Keep the order clause: without it, Postgres can return pages in a different order each time and rows appear twice or go missing as the user pages.
Missing indexes on foreign keys and filtered columns
An index is a lookup structure that lets Postgres find rows without reading the whole table. Postgres indexes primary keys on its own and nothing else. Generated schemas add user_id, organization_id, and statuscolumns and filter on them on every screen, with no index on any of them. Supabase's docs say to index the columns in where, join, and order by clauses that queries use often, and that aligning indexes with common query patterns can speed up retrieval by an order of magnitude.
Confirm it with EXPLAIN ANALYZE
Take the slow query from the report and run it in the SQL editor with explain analyze in front. The line to look for is Seq Scan on orders, which means Postgres read every row, and the Execution Timeat the bottom. Supabase's RLS performance page shows a sequential scan over a 100,000 row table taking 171 ms, with Rows Removed by Filter: 100000 as the sign that a filter ran on every row.
explain analyze
select id, total, created_at
from orders
where user_id = '0b6d1f3e-...'
order by created_at desc
limit 20;
-- Seq Scan on orders ... Execution Time: 171.033 ms
create index concurrently orders_user_id_created_at_idx
on orders (user_id, created_at desc);
-- run the explain again:
-- Index Scan using orders_user_id_created_at_idx ... Execution Time: 0.2 msSupabase's docs say a plain create index locks the table against writes while it builds, and create index concurrently avoids that at the cost of taking longer, so use the second form on a live app. A composite index like the one above serves both the filter and the sort. The docs also warn against indexing everything: each index slows writes, so if the plan's cost doesn't drop, remove it.
Let Supabase find them
The Performance Advisor in the dashboard (Advisors, then Performance) runs checks that Supabase's docs list, including unindexed_foreign_keys for foreign key columns with no index and unused_index for ones no query uses. On a generated schema I expect the first list to be long. Index every foreign key the app filters or joins on, and every column a policy filters on, which is the next section.
RLS policies that call a function on every row
Row level security is the set of rules in the database that decide which rows each user can see. A policy like using (auth.uid() = user_id) is correct and slow, because Postgres may call auth.uid()once per row. Supabase's RLS docs say to write (select auth.uid()) instead, which lets Postgres run the function once per statement and cache the result. Their benchmark on a 100,000 row table shows what that is worth:
| Policy before | Policy after | Before | After |
|---|---|---|---|
auth.uid() = user_id, no index | Same, with user_id indexed | 171 ms | under 0.1 ms |
auth.uid() = user_id | (select auth.uid()) = user_id | 179 ms | 9 ms |
is_admin() with a table join | (select is_admin()) | 11,000 ms | 7 ms |
has_role() = role | (select has_role()) = role | 178,000 ms | 12 ms |
No to clause, anonymous user | to authenticated | 170 ms | under 0.1 ms |
The 178 second row is the one that catches generated apps. The schemas I see from Lovable usually have a has_role() or is_admin() helper used in every policy, and each policy then runs that helper, with its own query, for every row it checks. In a Supabase GitHub discussion, a developer whose functions called auth.uid() per row saw one call go from 59 seconds to 251 ms after caching the value once, and database CPU fall from 99% to about 30%.
-- before
create policy "Users see own orders" on orders
for select using (auth.uid() = user_id);
-- after
drop policy "Users see own orders" on orders;
create policy "Users see own orders" on orders
for select to authenticated
using ((select auth.uid()) = user_id);
create index concurrently if not exists orders_user_id_idx
on orders (user_id);Three changes: the function call is wrapped in select, the policy names the role so it doesn't run at all for anonymous requests, and the column it filters on has an index. Supabase's docs add that a column only counts as indexed when it is the first column of an index, so a primary key on (team_id, user_id) does not index user_id. The Performance Advisor flags unwrapped calls as auth_rls_initplan, "Slow security policy detected". Speed and safety are separate questions, though: a fast policy can still let the wrong people in, which is what the Supabase RLS checklist covers.
Filtering in the browser and fetching on every keystroke
The other half of the problem is on the front end. Generated code often fetches every row and then filters, sorts, and searches the array in JavaScript, because that was the shortest path to a working screen. With 300 rows nobody notices. With 30,000, every keystroke in the search box runs a filter over the whole array and re-renders the whole list, and the page freezes while the user types.
- Move the filter into the query.
.eq("status", "open"),.ilike("name", "%" + term + "%"), and.order()do in the database, with an index, what the array methods did in the browser without one. - Debounce the search box.Wait about 300 ms after the last keystroke before sending the request, and ignore a response that arrives after a newer request was sent, or the list flickers between results for "jo" and "john".
- Fix the effect that fetches too often. A
useEffectwhose dependency array contains an object or array created during render runs after every render, which means a fetch after every render. The network tab shows the same request repeating with nothing changing. Depend on the ID, not the object. - Virtualize long lists. A table of 5,000 rows is 5,000 rows of DOM. Render only the rows on screen.
React's Profiler, in the React DevTools browser extension, shows which components render and how often. If one list item renders every time the parent does, wrap it in memo and stop passing it a new function on every render.
Large images and bundles in a Lovable app
A Lovable app is a React app that runs in the browser, so the visitor downloads the JavaScript for every screen before the first one appears. If the network tab shows requests to supabase.co that are quick and few, and the app still takes seconds to show anything on a phone, this is where to look. The two usual causes are user-uploaded images served at full size, so a grid of twenty 3 MB photos is 60 MB, and a bundle that includes a charting library, a rich text editor, and a PDF generator on every page, including the login page.
Resize images on upload or on request, set width and height so the page doesn't jump, and load images below the fold only when they scroll into view. Split the bundle by route with React.lazy, so the dashboard's charts load with the dashboard. This is the overlap with ordinary website speed work; if the slow thing is a marketing site rather than an app, the causes and fixes are in why a business website is slow.
Supabase free tier: compute limits and pausing
Two things about the free plan look like slowness and aren't a code problem. Supabase's docs list the free plan's Nano compute at shared CPU, up to 0.5 GB of memory, and 60 direct database connections, with a recommended maximum database size of 500 MB. That is enough for a small app with good queries, and not enough to hide bad ones: a sequential scan over a table that doesn't fit in memory hits the disk every time. In a GitHub discussion a Supabase collaborator wrote that a free instance performs about the same as an entry Pro instance, so upgrading without fixing the queries changes little.
The second is pausing. Supabase's docs say free projects that show low activity over a 7-day period are paused, with a warning email about a week before, and that a few user requests to the database each day are usually enough to avoid it. A paused project doesn't respond at all until someone restores it from the dashboard, so the symptom is an app that "takes forever" on the first visit after a quiet stretch, then works. Paid projects can't be paused. On Lovable Cloud the equivalent is the instance size: Lovable's docs say the free plan gets the Tiny size only, and that a database can also be paused when credits run out.
Order of operations: fix the queries and policies first, because they cost nothing and the gains are a hundredfold. Then, if the CPU and memory cards in Supabase or Lovable still show pressure at busy times, or you see "remaining connection slots are reserved" errors, buy compute.
What fast enough looks like for a Lovable app
Founders ask me for a number. For a Lovable app on Supabase, these are the targets I work to, and they come from the numbers above rather than from a benchmark of my own:
For comparison, two vibe-coded apps I took over. The Chat Companycame to me as a buggy AI-generated demo; I kept about 20% of the code, rewrote the rest, and it now handles 100,000+ messages a day for 30+ businesses, with replies about three times faster than typical chatbot platforms. A personalization engine for a marketing technology company arrived slow and unable to handle large contact lists; after an eight-week takeover it processes around 100,000 contacts in roughly an hour, because the heavy work runs in background queues instead of inside a web request, and serves each generated page in about 60 ms. Both started from the kind of code this post describes. The difference after the fix is not a faster framework, it's fewer, better queries and heavy work moved off the request.
When to get help with a slow Lovable app
Fix it yourself if the network tab points to one of the clear cases: a loop with a query in it, a select("*") with no range, or one slow query that explain analyzeshows as a sequential scan. Each is an afternoon with the fixes above, or a precise prompt to Lovable. Get help when every table is slow and you can't tell which policy is doing it, when fixing one screen slows another, when the schema has the same data in three tables, or when paying users are waiting while you read this.
A Launch-Ready Review reads the queries, policies, and indexes alongside the logins and payments and tells you what is slow, why, and what it costs to fix. The fixes themselves, with the index changes run safely on a live database, are part of a Production Sprint. What each costs is in the cost to fix a vibe-coded app. Slowness alone is almost never a reason to start over; if someone has told you it is, read whether to fix or rebuild a Lovable app first. Otherwise, send me the link and the slowest screen, and I'll tell you which row of the table it is.
Sources
- Query optimization with Postgres indexes, Supabase Docs
- Row Level Security: add indexes, call functions with select, Supabase Docs
- RLS performance and best practices: benchmark on a 100K row table, Supabase Docs
- Slow performance with auth.uid() and RPCs: 59 seconds to 251 ms, Supabase on GitHub
- Is the free tier supposed to be very slow? 100–200 ms REST latency, Supabase on GitHub
- Database advisors: unindexed foreign keys, slow security policy, Supabase Docs
- index_advisor: suggested indexes in the Query Performance report, Supabase Docs
- Managing indexes: create index concurrently, Supabase Docs
- pg_stat_statements: planning and execution statistics, Supabase Docs
- Debugging performance: explain() on a query, Supabase Docs
- range() modifier: 0-based and inclusive, Supabase Docs
- Fetch data: projects return a maximum of 1,000 rows by default, Supabase Docs
- Compute and disk: Nano tier memory and connection limits, Supabase Docs
- Free project pausing after 7 days of low activity, Supabase Docs
- Database capacity: slow queries and instance sizes in Lovable Cloud, Lovable Docs
- How to fix a slow vibe-coded app: the r/lovable 30-second thread, DEV Community