Why is my AI-built app slow and how do I fix it?
Measure first, then fix the usual causes in AI-built apps - huge images, one big JavaScript bundle, queries in loops, missing indexes and no caching.
The problem
For anyone whose Lovable, Bolt, Next.js or SvelteKit app feels slow on a phone or gets slower as data grows. AI tools rarely optimise images, bundles or queries unless you ask.
How to use it
- Download the file (or copy it).
- Paste it into your AI tool: Cursor, Claude Code or Lovable.
- Follow the checks it lists, one by one.
Context for the AI tool
You are finding out why this app is slow and fixing the biggest causes first.
- Do not change anything until you have measured, reported your findings and I have confirmed.
- Start with check 1 and keep its numbers: every fix is judged against them.
- Report findings as a table: page or query, problem, measured cost (KB, ms or request count), proposed fix, expected gain. Order by expected gain.
- Fix one cause at a time and measure again after each fix. Do not refactor code that is not on the slow path.
- Ask before adding a dependency, changing the database schema or changing hosting settings.
1. Measure before you change anything
What to look for: the real numbers for the 2–3 pages that matter most (landing page, the main screen after login, the slowest page users complain about). Test a production build, not the dev server: dev mode is always slower.
npx lighthouse https://<your-app>/<page> --only-categories=performance --output=json --output-path=./lh-before.json
Lighthouse simulates a mid-range phone by default. Note LCP (largest contentful paint), CLS (layout shift), TBT (total blocking time) and total JavaScript transferred. In the browser's Network tab, also note the request count and the slowest requests on each page.
How to fix: nothing yet. Write the numbers down in your report.
How to verify: you have a before-table with LCP, CLS, TBT, JS size and request count per page.
2. Images
What to look for: the most common cause we see. Photos uploaded as 3–5 MB PNG or JPEG and
shown at 400 pixels wide, no width/height (layout jumps), every image loading at once, or the
hero image lazy-loaded (which delays LCP).
find . -path ./node_modules -prune -o -type f \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.gif" \) -size +200k -print
Also check images coming from storage or a CMS in the Network tab (filter: Img, sort by size).
How to fix:
- Resize to about twice the largest displayed width and convert photos to WebP or AVIF.
- Give every
<img>awidthandheight(or a fixed aspect ratio) to stop layout shift. - Add
loading="lazy"anddecoding="async"to images below the fold. - For the main above-the-fold image: no lazy loading, add
fetchpriority="high". - Use
srcsetandsizesso phones get small files. In Next.js, usenext/imagewith a correctsizesprop and mark the hero image for early loading (preloadin Next.js 16 and later,priorityin older versions: check the installed version). For images from Supabase Storage, the image transformation API can resize on the fly (paid plans).
How to verify: Lighthouse no longer lists "Properly size images" or "Serve images in modern formats" for these pages, and total image bytes per page dropped.
3. One big JavaScript bundle
What to look for: Lovable and Bolt apps are single-page apps: by default every screen,
library and chart ships on the first visit. Common culprits: chart and editor libraries, full
lodash or moment, whole icon packs, PDF or Excel libraries, admin screens loaded for visitors. See what is in the bundle:
- Vite apps (Lovable, Bolt) and SvelteKit: add
visualizer()fromrollup-plugin-visualizerto thepluginsinvite.config, runnpm run buildand open the generatedstats.html. For a plain Vite single-page app,npx vite-bundle-visualizerdoes the same without a config change. - Next.js: builds use Turbopack by default from Next.js 16. Use the bundle analyzer built into
your Next.js version if it has one (see "Analyzing bundles" in the Next.js docs), or build with
next build --webpackand use@next/bundle-analyzer(ANALYZE=true).
How to fix:
- Split by route:
React.lazy(() => import('./pages/Admin'))with<Suspense>,next/dynamicfor heavy components, or dynamicimport()when a feature is used (export to PDF, charts). - Import only what you use (
import debounce from 'lodash/debounce', named icon imports). - Replace
momentwithdate-fnsor the built-inIntlAPI. Remove unused dependencies. - Keep third-party scripts (chat widgets, trackers) off the critical path: load them after the page is interactive.
How to verify: run the analyzer and Lighthouse again: JavaScript transferred on the first page and TBT both went down.
4. Queries in loops and fetching everything
What to look for: N+1 queries (one query for a list, then one per item), select('*') on wide
tables, no limit or pagination, data filtered or counted in JavaScript after loading the whole
table, and requests that wait for each other when they don't need to.
// N+1: one request per post
for (const post of posts) {
const { data } = await supabase.from('profiles').select('name').eq('id', post.author_id);
}
How to fix:
- Fetch related data in one query. Supabase:
.select('id, title, author:profiles(name)'). SQL: ajoin. Or batch with oneinquery:.in('id', authorIds). - Select only the columns the screen shows and paginate:
.range(0, 19)orlimit 20. - Count and filter in the database (
{ count: 'exact', head: true },where), not in the browser. - Run independent requests in parallel with
Promise.all.
How to verify: the Network tab shows fewer requests for the page and the slowest request is faster than in the before-table.
5. Missing database indexes
What to look for: queries that scan the whole table. They are fast with 100 rows and slow with
100,000. Columns used in where, order by, joins and RLS policies (like user_id) need an index.
On Supabase or any Postgres with pg_stat_statements:
select calls, round(mean_exec_time::numeric, 1) as mean_ms,
round(total_exec_time::numeric) as total_ms, left(query, 120) as query
from pg_stat_statements
order by total_exec_time desc
limit 15;
Foreign key columns without an index (single-column keys):
select c.conrelid::regclass as table_name, a.attname as column_name
from pg_constraint c
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = c.conkey[1]
where c.contype = 'f'
and not exists (
select 1 from pg_index i
where i.indrelid = c.conrelid and i.indkey[0] = c.conkey[1]
);
Run explain analyze <query> for the slowest ones: Seq Scan on a large table is the sign.
How to fix: add an index per hot column or column pair in a migration, for example
create index if not exists notes_user_id_idx on public.notes (user_id);. On a large live table
use create index concurrently (it cannot run inside a transaction, so run it on its own).
Supabase's Performance Advisor also lists unindexed foreign keys.
How to verify: explain analyze shows an Index Scan and mean_ms for that query drops.
6. Caching
What to look for: hashed static files (/assets/index-a1b2c3.js) served without a long cache,
the same public data fetched again on every navigation, and public pages rendered from scratch
on every request.
curl -sI https://<your-app>/assets/<file>.js | grep -i cache-control
How to fix: hashed assets: Cache-Control: public, max-age=31536000, immutable (most hosts do
this, check yours). Public data that changes rarely: cache it on the server or CDN (for example
revalidate in Next.js, setHeaders with s-maxage in SvelteKit) or in a client query cache
such as TanStack Query with a sensible staleTime. Never cache responses that contain one
user's private data on a shared CDN.
How to verify: a second visit loads assets from cache (Network tab: "disk cache" or 304),
and the after-table from check 1 shows the gain for each page.
Did this fix it?
Email me new fixes
One short email when we publish a new fix. Nothing else, and you can unsubscribe anytime.
Still stuck? Book a call
A WDDW developer looks at your app with you. Free 15-minute call, no strings.