A Lovable app that was fast in the demo and slow with real users almost always has a database problem. The usual causes are missing indexes, pages that download whole tables and filter them in the browser, and row-level security policies that do expensive work for every row. You can fix all of them without a rewrite.
Why your Lovable app is slow now and wasn't before
Lovable, Bolt, v0 and Cursor apps usually run on Supabase, which is a real Postgres database. What the AI tool doesn't do is plan for data volume. It won't add indexes for your queries, and it tends to write code that works with fifty rows and struggles with fifty thousand.
When we run our 48-hour health check on an app like this, the cause is nearly always one of the problems below. Check them in this order.
Missing indexes
Without an index, Postgres reads every row in a table to answer a query, even a simple one like "the bookings for this customer". With a few hundred rows nobody notices. With fifty thousand, each page waits for seconds, and it gets worse every week.
Start with the Performance Advisor in your Supabase dashboard (see Supabase's database advisors). It lists problems such as foreign keys without an index, so you don't have to go table by table.
To confirm which query is slow, run it with explain analyze in the SQL editor. "Seq Scan" next to a large "Rows Removed by Filter" means Postgres read far more rows than it returned.
The fix is usually one line:
create index idx_bookings_org_id on bookings (org_id);
Whole tables sent to the browser
AI-generated code often calls supabase.from('bookings').select('*') with no filter, then filters the result in JavaScript. With twenty bookings that's fine. With five thousand, every page load downloads the whole table first.
Ask the database for only what the page shows:
.select('id, customer_name, start_time')instead of*.eq('org_id', orgId)instead of filtering in the browser.range()or.limit()on every list
This one change often helps more than any front-end work.
RLS policies that run for every row
Keep row-level security on. It's what stops one customer reading another's data. But Postgres checks the policy for every row a query touches, so a policy that joins another table or calls a function can run thousands of times per query.
Supabase's guide to RLS performance lists the fixes:
- Index the columns your policies filter on.
- Wrap function calls in a select, like
(select auth.uid()), so Postgres runs them once per query. - Say which role each policy is for, with
to authenticated. - Don't join inside the policy. Look up the allowed IDs once and check them with
inorany.
When a policy has to check a membership table, a security definer function that reads that one table is often the cleanest answer. Our post on Supabase row level security explains how policies work.
Realtime and polling left running
If the app shows live updates, check what it subscribes to. A common leftover is a subscription to every change on a whole table, when the user only needs their own organisation's rows. Another is a two-second polling loop from an early prototype.
Filter each channel (org_id=eq.${orgId}) and unsubscribe when the component unmounts.
Images matter less than you'd think
Compress images and lazy-load anything below the fold. It's worth doing. But on a Supabase app with real data, images are rarely why a page takes four seconds to load. Fix the database and the queries first.
How we find the bottleneck
In a prototype rescue we start with Supabase's Performance and Security Advisors and clear what they flag. Then we run explain analyze on the three or four pages the client says are slowest. We also watch the browser's network tab for requests that return more rows or columns than the page shows. The client gets a written report, in order of risk, and a fixed quote for the fixes.
What to check yourself this week
- Open the Performance Advisor and add the indexes it says are missing. Each takes a few minutes.
- Open your slowest pages with the network tab open, and look for a request that pulls back a whole table.
- Read your RLS policies. Note any that join another table or call a function without a select around it.
- If you use realtime, check that each subscription is filtered to the rows the user needs.
The free launch check covers the other basics, from exposed keys to payments, in two minutes. If your list turns up more than you want to fix yourself, send us the details and we'll run the same health check on your app.