[ Blog ] → Product development

How we make slow web apps fast

To speed up a website, measure real users first, then fix the server response, the database, the main image and the JavaScript, in that order.

Rainer Rodriguez

Co-founder, Nerdify

Sep 15, 2026 · 8 min read

To improve a website's speed, measure what real users experience, find the slowest step, and fix that step before anything else. In the web apps we get asked to speed up, the order is usually the same: the server takes too long to answer, the database does too much work per request, the largest image or block of text loads late, and the browser has to run too much JavaScript before the page responds. Compressing images helps, but it rarely fixes an app that takes two seconds to send its first byte.

This is the process we follow on Laravel and React apps, with the changes that usually make the difference.

Measure real users before you change anything

Lab tools like Lighthouse run one page load on one simulated device. They are good for catching regressions, and bad at telling you what your users feel. We start with field data: the Core Web Vitals report in Search Console, the "Discover what your real users are experiencing" section of PageSpeed Insights, or the browser's own numbers sent from the app with the web-vitals library.

Google's thresholds for a good experience, measured at the 75th percentile of page loads, are:

MetricWhat it measuresGood
LCP (Largest Contentful Paint)When the main content appears2.5 s or less
INP (Interaction to Next Paint)How fast the page reacts to clicks and typing200 ms or less
CLS (Cumulative Layout Shift)How much the layout jumps while loading0.1 or less

Then we look at Time to First Byte. If the server takes 1.5 seconds to answer, no front-end work will bring LCP under 2.5 seconds on a phone, and that tells us where to start.

Find out where the server time goes

For Laravel apps we open the slow page with Laravel Debugbar in a local or staging copy that has realistic data. It shows every query, how long each one took, how many models were hydrated, and the time spent rendering. A page that runs hundreds of queries is a frequent finding, and the cause is usually an N+1, a loop that loads a relation one row at a time.

We make Laravel refuse lazy loading outside production, so new N+1 queries fail in development instead of shipping:

php · app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

The fix is eager loading, and it is usually one line:

php
$orders = Order::query()
    ->with(['customer', 'items.product'])
    ->whereBelongsTo($request->user()->team)
    ->latest()
    ->paginate(25);

On the React side the same problem shows up as waterfalls: a component mounts, fetches, renders a child, and the child fetches again. With Inertia we send the data the page needs as props from one controller, so the browser makes one request instead of five.

Compressing images helps, but it rarely fixes an app that takes two seconds to send its first byte.

Give the database less to do

After the query count comes query cost. We run EXPLAIN on anything over a few tens of milliseconds and look for full table scans. Most of the time the fix is an index that matches how the query filters and sorts:

php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table): void {
            $table->index(['team_id', 'status', 'created_at']);
        });
    }
};

Column order in a composite index matters. The columns you filter on with equality go first, and the column you sort or range on goes last. An index on created_at, team_id would not help this query much.

We also check what the query selects. Loading 40 columns, including a large JSON column, to show a name and a date in a list wastes memory on both the database and PHP. select() the columns the page uses.

Cache what is expensive and rarely changes

Dashboards, counts, and reports that aggregate thousands of rows are good cache candidates. We use Cache::flexible, which serves the cached value while it refreshes in the background once it is stale, so no user waits for the recalculation:

php
use Illuminate\Support\Facades\Cache;

$stats = Cache::flexible("team:{$team->id}:dashboard", [300, 900], function () use ($team): array {
    return [
        'open_orders' => $team->orders()->where('status', 'open')->count(),
        'revenue_cents' => $team->orders()->where('status', 'paid')->sum('total_cents'),
    ];
});

The two numbers mean the value is fresh for five minutes. Between five and fifteen minutes, users get the stale value while it recomputes in the background, and after fifteen minutes it is rebuilt during the request.

Caching hides slow code. We cache only after the query is as fast as it can reasonably be, because a cold cache still has to run it, and the first user after every deploy pays for it.

Move slow work out of the request

Sending email, generating PDFs, calling a third-party API and resizing uploads don't need to finish before the user sees a response, so we dispatch a queued job and return.

php
GenerateInvoicePdf::dispatch($invoice);

return Redirect::route('invoices.show', $invoice);

Third-party calls deserve a timeout even when they stay in the request. Laravel's HTTP client waits 30 seconds by default, and one slow vendor can hold a PHP worker for all of it. We set Http::timeout(5) or less, and a retry with backoff where the call is safe to repeat.

Laravel Octane helps when framework boot time is a real share of each request, because the app boots once and serves many requests. It doesn't fix slow queries, and it brings rules about state that leaks between requests. We reach for it after the database work, not before.

Fix the main content first on the front end

LCP is usually an image or a hero heading. If it is an image, we make sure the browser finds it early and loads it first:

tsx
<img
  src="/images/hero-1200.avif"
  srcSet="/images/hero-800.avif 800w, /images/hero-1200.avif 1200w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width={1200}
  height={630}
  fetchPriority="high"
  alt="Order dashboard"
/>

Three details in that tag do the work. fetchPriority="high" moves the image ahead of other downloads. srcSet and sizes stop phones from downloading the desktop file. width and height reserve the space, which prevents the layout shift that hurts CLS. We use loading="lazy" on images below the fold and never on the LCP image, since lazy loading the main image delays the metric you are trying to improve.

Web fonts are the other usual suspect. We self-host them, subset them to the characters the site uses, preload the one weight above the fold, and use font-display: swap so text shows in a fallback font instead of staying invisible.

Ship less JavaScript

INP problems come from the main thread being busy when the user clicks. Large bundles take time to parse and run, and heavy components re-render on every keystroke.

We look at the bundle with a visualizer and split routes and heavy widgets so they load when needed:

tsx
import {lazy, Suspense} from 'react'

const ReportChart = lazy(() => import('./ReportChart'))

export default function Reports(props: ReportsProps) {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <ReportChart data={props.data} />
    </Suspense>
  )
}

With Inertia and Vite, each page is already its own chunk, so the common problems are a large library imported on every page, a date library with all its locales, or a rich text editor loaded on pages that only display text. For typing lag, we check what re-renders with the React DevTools profiler. Filtering a list of 5,000 rows on every keystroke is slow regardless of framework, and useDeferredValue or moving the filter to the server fixes it.

Third-party scripts count too. Chat widgets, tag managers and analytics add up, and they run on the same thread as your app. We list every one, ask who uses its data, and remove the ones nobody can name an owner for.

Keep it fast after the fix

Speed regresses quietly. A new feature adds a query in a loop, a new dependency doubles the bundle. Two things keep it in check for us: preventLazyLoading failing in development, and a Lighthouse run in CI on the most visited pages with a budget for LCP and bundle size, so a pull request that breaks the budget fails before review. Field data in Search Console takes about 28 days to reflect a change, so we compare it month over month rather than the day after a deploy.

Read also · Laravel

Database design decisions that save you later

Sep 1, 2026 · 8 min read

[ Read → ]

If your app is slow and you want someone to find out why and fix it, our product development team does this work on Laravel and React apps, either as a short engagement or as part of building the product.

Written by

Rainer Rodriguez

Co-founder, Nerdify

Rainer co-founded Nerdify in Managua, Nicaragua, in 2016 and works with the team on the client products it builds and maintains.

Service · Product development

Need a team that works like this?

More from the blog

[ All articles → ]
  • Product development

    Quality assurance in a small product team

    Quality assurance is the set of habits that keep defects out of a release, from the ticket to production. Here is how a small team runs it.

    Carlos Saldaña

    Aug 11, 2026 · 7 min read