Vol. 01 — 2026

Laravel 13 Performance 2026: 98 Lighthouse, No SPA

Laravel 13 Performance 2026: 98 Lighthouse, No SPA

Quick Answer: Laravel 13 reaches 98 Lighthouse performance in 2026 without an SPA by combining Octane workers, immutable cache headers, Blade fragment caching, and Vite 7 chunk splitting. On a ₹6K four-core VPS I measured 41ms P95 TTFB and a 1.8s mobile LCP across 30,000 monthly sessions.

Author: Deepak Bagada — founder of SaaS Next, creator of Curro, Laravel developer in Junagadh, Gujarat. I run this stack on deepakbagada.in and four client applications. The engineering journal carries the rest of the benchmarks.

Key Takeaways

  • 98 Lighthouse is a delivery problem, not a framework problem. The biggest wins came from cache headers and image sizing, not from replacing Blade.
  • Octane cut P95 TTFB from 180ms to 41ms by removing bootstrap cost from every request.
  • A 1.2MB JavaScript chunk was the real LCP blocker. Splitting it dropped mobile LCP from 4.1s to 1.8s.
  • An SPA adds a second runtime you must optimize. For content and catalog pages, server rendering wins on first paint.

The Baseline Nobody Talks About

Before any optimization, a default Laravel 13 install on this site scored 62 on mobile Lighthouse with a 4.1s LCP and 180ms P95 TTFB. The HTML was fine. The problem was everything attached to it: one 1.2MB JavaScript bundle loaded on every page, no cache headers on static assets, and a database query on each request for navigation data.

I did not rewrite the frontend. I measured, removed, and cached. That sequence matters. Teams that start with a framework migration usually trade one set of problems for another while shipping nothing for six weeks.

Metric Baseline After tuning Change
Lighthouse mobile 62 98 +36
LCP (mobile) 4.1s 1.8s −56%
P95 TTFB 180ms 41ms −77%
JS transferred 1.2MB 184KB −85%
Monthly VPS cost ₹6,000 ₹6,000 ₹0

Step 1: Move Bootstrap Out of the Request

PHP-FPM rebuilds the framework on every request. Octane keeps workers alive and boots the application once. On a four-core VPS with two Octane workers, the P95 TTFB fell from 180ms to 41ms with no application code changes.

<?php
// config/octane.php
return [
    'server' => 'frankenphp',
    'workers' => 2,
    'max_requests' => 500,
    'warm' => [
        ...Octane::defaultServicesToWarm(),
    ],
    'flush' => [
        // Do not flush the router between requests.
    ],
    'watch' => [
        'app/*.php',
        'routes/*.php',
        'resources/views/*.php',
    ],
];

max_requests is the line people skip. Workers accumulate memory from static caches and third-party clients. Recycling every 500 requests keeps RSS under 180MB; without it, my longest-running worker crossed 900MB in nine days and the OOM killer restarted the service during a client demo.

The second Octane pitfall is state leakage. A singleton that stores the current tenant in a property will serve tenant A's data to tenant B on the next request. I audit every custom singleton for request state before enabling Octane, and I reset the tenant container binding in a request-terminated listener.

Step 2: Cache Headers Before Cache Code

The cheapest performance work is telling the browser not to ask again. A small middleware adds immutable caching for hashed assets and revalidation for HTML.

<?php
// app/Http/Middleware/CacheHeaders.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CacheHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        if ($request->is('build/*') || $request->is('assets/*')) {
            $response->headers->set(
                'Cache-Control',
                'public, max-age=31536000, immutable'
            );
        }

        if ($response->headers->get('Content-Type') === 'text/html') {
            $response->headers->set(
                'Cache-Control',
                'public, max-age=60, stale-while-revalidate=300'
            );
        }

        return $response;
    }
}

Vite already fingerprints filenames, so immutable is safe for anything under /build. HTML gets only 60 seconds because publishing must appear quickly. That small window absorbs traffic spikes without serving stale content for minutes.

For shared cache, I use Valkey tags so a single publish invalidates the journal index, the sitemap, and the home feed at once. The Valkey cache tag guide has the full invalidation pattern.

Step 3: Kill the 1.2MB Bundle

The default bundle imported a charting library, a date library, and a UI kit on every page. Only the admin dashboard used the chart. I split routes with dynamic imports and loaded the chart only on the dashboard.

Chunk Before After Loaded on
app.js 1,204KB 138KB All pages
dashboard.js 0 210KB Admin only
journal.js 0 46KB Journal only
CSS 88KB 31KB All pages

The mobile LCP dropped from 4.1s to 1.8s after the split, mostly because the main thread stopped parsing JavaScript before first paint. That single change moved Lighthouse from 71 to 92. The remaining points came from image sizing and font loading.

The Tailwind 4 upgrade notes cover the CSS side, including why the new engine produced a smaller output in my build.

Production War Story: The Octane Memory Leak

In August 2026 a client's Laravel 13 app on Octane started returning 502s every 36 hours. The pattern was regular enough to look like a cron job. It was not. A package cached a full Eloquent result set in a static array keyed by query string. Each unique filter combination added rows that were never released.

I found it with php artisan octane:status and a heap snapshot taken through FrankenPHP's admin endpoint. The static array held 410,000 rows after two days. Replacing the static cache with a tagged Valkey cache and setting max_requests to 500 fixed it. RSS now holds at 165MB and the 502s stopped.

The lesson: Octane turns every static cache into a leak candidate. Audit packages that cache in memory, and set a recycle limit even if you think you do not need one.

Production War Story: The Font That Blocked First Paint

The second regression came from a design refresh. A new variable font was loaded with @import inside the main CSS file. The browser had to download and parse 31KB of CSS before it even discovered the 96KB font, then wait for the font before painting the H1. LCP on a throttled 4G connection went from 1.8s to 3.4s overnight.

I moved the font to a <link rel="preload">, self-hosted the WOFF2 file, and added font-display: swap. I also removed the @import and inlined the critical CSS for the hero section. LCP returned to 1.9s. The font was never the problem; the discovery order was.

{{-- resources/views/partials/head.blade.php --}}
<link rel="preload"
      href="{{ asset('fonts/inter-var.woff2') }}"
      as="font"
      type="font/woff2"
      crossorigin>

<style>
  @font-face {
    font-family: 'InterVar';
    src: url('{{ asset('fonts/inter-var.woff2') }}') format('woff2');
    font-weight: 100 900;
    font-display: swap;
  }
  .hero { font-family: 'InterVar', system-ui, sans-serif; }
</style>

When NOT to Use This Architecture

Do not choose server rendering if your product is an editor, a dashboard with drag-and-drop state, or a real-time collaborative surface. Those need a client runtime, and fighting it with Blade costs more than shipping React or Vue.

Do not remove an existing SPA to chase a Lighthouse score. A migration is a quarter of work with real regression risk. Optimize the SPA first: code splitting, image sizing, and cache headers apply to both architectures.

Do not use Octane on shared hosting without process supervision. Octane needs a long-running process manager. On shared hosting, PHP-FPM with OPcache and route caching is the correct choice.

The Stack I Ship in 2026

Layer Choice Why
Runtime FrankenPHP + Octane, 2 workers 41ms P95 TTFB on 4 cores
Views Blade with fragment caching No hydration cost
Assets Vite 7, route-level splitting 184KB initial JS
Cache Valkey with tags One-key invalidation
Images AVIF with srcset 62% smaller than JPEG
Hosting ₹6K VPS, Nginx edge Predictable cost

The whole stack runs on hardware that costs less than a team lunch per month. The Laravel Cloud monorepo deploy guide covers the case where you do need a separate Next.js frontend alongside Laravel.

Frequently Asked Questions

Can Laravel 13 really score 98 on Lighthouse?

Yes, for content and catalog pages. My production site holds 98 mobile with 41ms P95 TTFB. The score depends on asset delivery and cache headers more than the framework. Interactive dashboards typically land between 85 and 92.

Is Octane worth it for a small Laravel site?

Octane is worth it above roughly 50,000 monthly requests or when P95 TTFB exceeds 100ms. Below that, OPcache plus route and config caching is enough. Octane adds a process supervisor and a state-leak audit to your maintenance load.

Do I need an SPA for a fast Laravel app?

No. Blade with fragment caching and route-level JavaScript splitting beats most SPAs on first paint. Choose an SPA when you need rich client state, offline behavior, or real-time collaboration.

How do I fix a slow Laravel LCP?

Preload the hero image and font, serve AVIF or WebP with explicit dimensions, remove render-blocking imports, and split JavaScript by route. In my last three audits, those four changes cut LCP by 40% to 56%.

Bottom line

Laravel 13 does not need an SPA to hit 98 Lighthouse. It needs Octane for bootstrap cost, immutable cache headers for repeat visits, and route-level splitting so the first paint is not blocked by JavaScript. I measured 41ms P95 TTFB and 1.8s mobile LCP on a ₹6K VPS with zero frontend framework. If you want the same audit on your app, book a performance review and bring your current Lighthouse report.

KEEP READING

← All journal articles Get in touch →