Reduce TTFB in Laravel: A Practical Guide
If your Laravel app feels sluggish and Lighthouse or PageSpeed Insights flags "Reduce initial server response time", you are looking at a high Time to First Byte. Learning how to reduce TTFB in Laravel comes down to one discipline: measure where the server spends its time, then remove the biggest chunk first. Guessing and enabling random caches rarely works, and this guide walks through the fixes that actually move the number, in the order that pays off most.
Google's web.dev guidance rates a TTFB of 0.8 seconds or less as good and anything above 1.8 seconds as poor. A practical target for a Laravel app is under 200 ms for cached pages and under 500 ms for dynamic ones, measured from a real client, because every millisecond of TTFB is added directly to Largest Contentful Paint.
What Laravel TTFB Actually Measures
TTFB is the time from the browser sending a request to receiving the first byte of the response. It bundles several separate costs: DNS lookup, TCP and TLS handshakes, the web server queueing the request, PHP bootstrapping the framework, your controller logic and database queries, and the network trip back. Only the middle part is Laravel's problem, so the first job is separating framework time from network time.
Start with a quick command-line check that splits the phases:
curl -o /dev/null -s -w "connect: %{time_connect}s\ntls: %{time_appconnect}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\n" https://your-app.test/
If connect and tls are already large, the fix is a CDN, keep-alive, or a closer server region, not PHP code. If the gap between tls and ttfb is big, the time is spent inside your application, which is where the rest of this guide applies.
Measure Laravel TTFB With a Server-Timing Header
Before changing anything, add a tiny middleware that reports how long the application took. The browser shows it in the Network tab under "Timing", so you can see the effect of every change immediately.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ServerTiming
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$start = $request->server('REQUEST_TIME_FLOAT');
$ms = (microtime(true) - $start) * 1000;
$response->headers->set('Server-Timing', sprintf('app;dur=%.1f', $ms));
return $response;
}
}
Register it globally and compare the app duration with the ttfb you measured from outside. A large difference means the delay is in the web server, PHP-FPM queueing, or the network. A small difference means Laravel itself is slow.
Step 1: Cache the Framework Bootstrap
On every request, Laravel loads configuration files, builds the route table, and compiles views. In production you should cache all of it once at deploy time instead of on every request:
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
The php artisan optimize command runs the framework caches in one go. Two cautions apply. First, once you run config:cache, calls to env() outside of config files return null, so read environment values only through config(). Second, route:cache does not work with closure-based routes, so move them into controllers.
Step 2: Turn On and Tune OPcache
Without OPcache, PHP re-reads and re-compiles every file on every request. It is often the single biggest win on an untuned server. Check that it is enabled with php -i | grep opcache.enable, then use production settings like these in php.ini:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
Setting validate_timestamps=0 stops PHP from checking whether files changed on every request, but it means you must reload PHP-FPM on each deploy so new code is picked up. Make that reload part of your deployment script.
Step 3: Find and Fix Slow Database Queries
Once the bootstrap is cached, the database is the usual suspect. The classic cause of a slow Laravel page is the N+1 query problem, where a loop triggers one query per row. Make Laravel fail loudly in development so you catch it before production:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
DB::listen(function ($query) {
if ($query->time > 100) {
Log::warning('Slow query', ['sql' => $query->sql, 'ms' => $query->time]);
}
});
}
Then replace lazy loading with eager loading where the exception points you:
// Before: one query for posts plus one per post for the author
$posts = Post::all();
// After: two queries total
$posts = Post::with('author')->latest()->take(20)->get();
For anything the logger flags as slow, run EXPLAIN and add the missing index. Selecting only the columns you need with select() also reduces the data PHP must hydrate into models.
Step 4: Cache Expensive Results and Move Sessions Off Disk
Data that is expensive to compute but rarely changes should be cached with a fast store. Use Redis rather than the file driver for cache and sessions, since file-based sessions add disk locking to every request:
$stats = Cache::remember('dashboard.stats', 600, function () {
return Order::query()
->selectRaw('count(*) as orders, sum(total) as revenue')
->first();
});
In your .env, set CACHE_STORE=redis and SESSION_DRIVER=redis (older Laravel versions use CACHE_DRIVER). Remember to invalidate keys when the underlying data changes.
Step 5: Defer Work the User Does Not Need to Wait For
Sending emails, generating PDFs, and calling third-party APIs inside the request adds their full latency to your TTFB. Push them to a queue, or run them after the response has been sent:
SendOrderReceipt::dispatch($order); // queued to a worker
SyncCustomerToCrm::dispatch($order)->afterResponse(); // runs after the response is sent
Use a real queue worker for anything that can fail or take long, and reserve afterResponse() for small tasks.
Step 6: Consider Laravel Octane and PHP-FPM Tuning
With everything above in place, the remaining cost is booting the framework on each request. Laravel Octane keeps the application in memory between requests using FrankenPHP, Swoole, or RoadRunner, which removes most of that bootstrap time:
composer require laravel/octane
php artisan octane:install
php artisan octane:start --server=frankenphp
Octane changes how you write code: avoid storing request-specific data in static properties or singletons, because they now survive across requests. If you stay on PHP-FPM, make sure pm.max_children is high enough for your traffic. When every worker is busy, requests queue at the web server and TTFB climbs even though your code is fast.
Common Mistakes
- Running
config:cachelocally and then wondering why.envchanges are ignored. - Setting
opcache.validate_timestamps=0without reloading PHP-FPM after deploy. - Caching whole responses that contain per-user data, which leaks content between users.
- Optimizing PHP when the real delay is TLS or DNS. Always measure first.
Frequently Asked Questions
What is a good TTFB for a Laravel app?
Aim for under 200 ms on cached pages and under 500 ms on dynamic pages, measured from a real client rather than from the server itself.
Does Laravel Octane always lower TTFB?
It removes the per-request bootstrap cost, which usually helps a lot, but it will not fix slow queries or slow external API calls. Fix those first.
Why is TTFB high only on the first request after a deploy?
Cold OPcache and freshly cleared framework caches make the first request rebuild everything. Warm the caches in your deploy script by running the artisan cache commands and hitting a few key URLs.
How do I find which route is slow?
Use the Server-Timing header shown above for a quick view, and add Laravel Telescope or Debugbar in a staging environment to see queries and timings per route.
Conclusion
To reduce TTFB in Laravel, work in this order: measure with curl and a Server-Timing header, cache config, routes, and views, enable OPcache, eliminate N+1 queries, cache expensive results in Redis, and push slow work to queues. Only reach for Octane once those are done. Start today by adding the Server-Timing middleware to a staging copy of your slowest route, so you have a baseline number to beat.
0 Comments
No comments yet — be the first to share your thoughts.