Skip to content
BlackFig

Why your AI-built app is slow, and what fixes it

AntPublished Updated 6 min read

Quick answer

AI-built apps are usually slow for a few specific reasons: data fetched in a chain of requests after the page loads, queries without indexes, access policies that re-run for every row, a server region far from the database, and too much JavaScript and image weight. Measure first, then fix the slowest step, which is usually the data.

On this page
  1. Symptom
  2. Why it happens
  3. The fix
  4. How to prevent it
  5. When to get help

Symptom

Slowness in an AI-built app has a few recognisable shapes:

  • The staircase. A blank page, then a spinner, then the header, then a list, then the numbers in the list, each arriving after the last.
  • The slow creep. Pages that were instant at launch get a little slower every week as tables grow.
  • Fine for you, slow for them. The app is quick on your laptop and sluggish on a customer's phone.
  • Laggy taps. Buttons take a beat to respond, especially on mobile.

At the extreme, requests start failing. Supabase sets default statement timeouts per role: 3 seconds for anonymous requests and 8 seconds for signed-in users. A query that runs longer is cancelled, and Postgres reports:

For targets, I use Google's Core Web Vitals: the largest piece of content on screen within 2.5 seconds, a response to interactions within 200 milliseconds, and layout shift of 0.1 or less, measured at the 75th percentile of real page loads.

Why it happens

Data is fetched in the browser, one request after another. The most common pattern in generated React code is a useEffect that fetches the user, then another that fetches their projects once the user arrives, then one request per project for its tasks. Each step waits for the one before. That is the staircase.

Queries have no indexes. Postgres doesn't create indexes on foreign key columns automatically, and AI tools rarely add them. With 50 rows nobody notices. With 50,000, every list scans the whole table.

Access policies run once per row. A Row Level Security policy written as auth.uid() = user_id can call the function for every row it checks. Supabase recommends wrapping it in a select so Postgres evaluates it once per statement.

The server and database are far apart. Vercel runs functions in Washington, D.C. (iad1) by default for new projects. If your Supabase project is in London, which is a natural choice for a UK product, every database query from a server function crosses the Atlantic and back. A page that makes five queries in a row pays that trip five times.

Too much is shipped to the phone. Whole charting, markdown and date libraries end up in client components, and full-size uploaded photos are served to 375-pixel-wide screens.

These overlap with the limits in why AI-built apps break after the first 100 users, and the indexes in particular depend on having a sane data model.

The fix

1. Measure before you change anything

Guessing wastes days. Find out where the time goes:

  • In the browser: open the developer tools, go to the Network tab, reload, and look at the waterfall. Requests that start only after another finishes are your staircase. Run Lighthouse in the same tools for the Core Web Vitals.
  • In the database: Supabase's CLI can list the queries taking the most total time and the ones called most often:
# --remote targets your linked project (older CLI versions use --linked)
supabase inspect db outliers --remote
supabase inspect db calls --remote

Then take the worst query and ask Postgres how it runs it. Look for sequential scans on big tables and a large "Rows Removed by Filter" count:

EXPLAIN ANALYZE
select id, name, created_at from public.projects
where owner_id = '00000000-0000-0000-0000-000000000000'
order by created_at desc
limit 50;

2. Fetch on the server, in parallel

If your app is Next.js, move data fetching out of useEffect and into Server Components, and start independent requests at the same time. Next.js's docs show the pattern with Promise.all:

// app/dashboard/page.tsx
import { createClient } from "@/lib/supabase/server";
 
export default async function DashboardPage() {
  const supabase = await createClient();
 
  // Both requests start now; neither waits for the other
  const [projectsRes, invoicesRes] = await Promise.all([
    supabase
      .from("projects")
      .select("id, name, tasks ( id, status )") // tasks come back in the same request
      .order("created_at", { ascending: false })
      .limit(50),
    supabase
      .from("invoices")
      .select("id, total_pence, status")
      .order("created_at", { ascending: false })
      .limit(10),
  ]);
 
  return <Dashboard projects={projectsRes.data ?? []} invoices={invoicesRes.data ?? []} />;
}

The nested tasks ( id, status ) replaces one request per project with a single request. Supabase works out the join from your foreign keys, which is one more reason to have them. Wrap slow sections in Suspense so the rest of the page appears straight away instead of waiting for the slowest query.

If your app is a Vite single-page app, the same ideas apply in the browser: start independent requests together, embed related rows, and don't fetch inside a loop.

3. Index what you filter and sort on

Add an index that matches how the page queries, and rewrite any per-row policy:

-- Matches "this owner's projects, newest first"
create index projects_owner_created_idx
  on public.projects (owner_id, created_at desc);
 
-- Evaluate auth.uid() once per statement, not once per row
drop policy if exists "Owners can read projects" on public.projects;
create policy "Owners can read projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = owner_id );

Supabase's index_advisor extension will suggest single-column indexes for a given query:

create extension if not exists index_advisor;
select * from index_advisor('select id from public.projects where owner_id = $1::uuid');

One more tip from Supabase's RLS guidance: policies act like hidden where clauses, but adding the same filter in your query, such as .eq("owner_id", userId), helps Postgres choose a better plan.

4. Put the server next to the database

Check which region your Supabase project is in, then run your functions in the same place. In Vercel, that is Settings > Functions > Function Regions, or in vercel.json:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "regions": ["lhr1"]
}

lhr1 is London. Pick whichever region is closest to your database. Static files are already served from the nearest location to each visitor, so this only affects server functions.

5. Ship less JavaScript

Find out what is in the bundle first. Next.js 16.1 and later include a bundle analyser for Turbopack builds:

npx next analyze

On earlier versions with webpack, use the @next/bundle-analyzer plugin instead.

Then deal with the big items:

  • Move rendering work to the server. Next.js's own example is syntax highlighting: done in a client component, the whole library ships to the phone; done in a Server Component, only the resulting HTML does. The same applies to markdown and many chart transformations.
  • Load heavy widgets when needed. A chart, rich-text editor or map that sits below the fold can load on demand:
"use client";
import dynamic from "next/dynamic";
 
// Only downloaded when this component renders, and never on the server
const RevenueChart = dynamic(() => import("./revenue-chart"), { ssr: false });
  • Trim big imports. For icon and utility libraries with hundreds of exports, optimizePackageImports in next.config.js loads only what you use.

6. Size images for the screen

Use next/image with a sizes prop that matches your layout. Without it, Next.js's docs note, the browser assumes the image is as wide as the viewport and may download a much larger file than it needs. Images lazy-load by default, which is right below the fold but wrong for the main image at the top of the page:

// Next.js 15: use priority on the main image. On Next.js 16, priority is deprecated:
// use loading="eager" or fetchPriority="high" instead.
<Image
  src={event.coverUrl}
  alt={event.title}
  width={1200}
  height={630}
  sizes="(max-width: 768px) 100vw, 50vw"
  priority
/>

In a Vite app, resize uploads when they are stored, set width and height on every img to stop layout shift, and add loading="lazy" to images below the fold. If your app is slow mainly on phones, the layout itself may be part of it; see the guide to fixing mobile layouts in AI-built apps.

How to prevent it

  • Test with real volume. Seed a staging database with tens of thousands of rows before you judge any page.
  • Test on a real phone. A mid-range Android on mobile data tells you more than any laptop.
  • Paginate from day one. Every list gets a limit and a next page. Supabase caps responses at 1,000 rows by default anyway.
  • Keep data fetching in one place. A small set of server functions that fetch for each page are easier to keep parallel than fetches scattered through components.
  • Re-run the advisors. Supabase's Performance Advisor flags unindexed foreign keys and per-row policy calls. Check it after each batch of AI changes.

When to get help

Measuring and adding indexes is a good afternoon's work for a founder. Get help when:

  • The slowness is in the structure. If pages need data from many tables that don't link properly, indexes won't save you. Start with fixing the data model.
  • The app has no server. Moving a browser-only app to server rendering is a bigger change than tuning queries.
  • You've tried the obvious and it is still slow. Some problems, like lock contention or a real-time feature flooding the database, need someone who has seen them before.

Performance is part of every Rescue Sprint, after security and data, because a fast app that leaks data is not a win. For the full order of work, see the guide to fixing vibe-coded apps, or get a quick read with the free health check.

Sources

The vibe-code rescue checklist, as a PDF

About 40 checks across auth, data, payments, errors, mobile and secrets, on two pages.

You get the PDF now and two short follow-ups over the next week. Reply “stop” and the rest never arrive.

Questions founders ask

Will upgrading my Supabase plan make the app faster?

Only if the database is genuinely short of resources. A query that scans a whole table because an index is missing is slow on any plan. Check the slow queries first; most of the time the fix is an index or fewer requests.

Why is the app fast for me but slow for my users?

You probably have little data and a fast connection. Users have more rows, older phones and mobile data. Test with a realistic amount of data on a mid-range phone over a mobile connection.

How fast should my app be?

Google's Core Web Vitals give useful targets: the main content within 2.5 seconds, a response to taps within 200 milliseconds and almost no layout shift, measured at the 75th percentile of real visits.

Ant

Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me