JavaScript shipped to the browser but never executed wastes bandwidth and parse time.
Unused JavaScript still costs network bandwidth, parse time, and CPU. The browser must download, parse, and compile every byte you ship — even if it never executes.
Resources in the head block first paint until they download. Defer or inline critical CSS.
Render-blocking resources in <head> delay first paint until the browser downloads and parses them. Every kilobyte adds latency, especially on slow networks.
Compress text-based responses with gzip or Brotli to reduce transfer size significantly.
Text-based resources (HTML, CSS, JS, JSON) compress 60-80% with gzip and even better with Brotli. Without compression every byte travels over the wire as-is, slowing FCP and LCP directly.
Typical savings: 60-80% transfer size on JS/CSS, 150-400ms LCP improvement
// Intervention Image v3 — optimize on upload
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
$manager = new ImageManager(new Driver());
$manager->read($path)
->toWebp(quality: 80)
->save(storage_path('app/public/images/hero.webp'));
Avoid
// Storing raw upload with no compression
$request->file('image')->store('images'); // may be a 4 MB JPEG
Preconnect to required origins
Info
uses-rel-preconnect
Add <link rel="preconnect"> for third-party origins so DNS/TCP/TLS handshakes happen early.
Each connection to a third-party origin (fonts.googleapis.com, cdn.jsdelivr.net) requires a DNS lookup + TCP handshake + TLS negotiation — often 100-300ms. `<link rel="preconnect">` initiates these during HTML parsing, before the resource is actually requested.
Typical savings: 100-300ms per third-party origin on initial load
{{-- No preconnect: browser discovers the origin only when parsing the font link --}}
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">
Prioritize the LCP image
Warning
prioritize-lcp-image
Add fetchpriority="high" to the hero image so the browser fetches it before lower-priority resources.
The LCP element is the most critical resource for user-perceived load speed. Adding `fetchpriority="high"` tells the browser to fetch it immediately, above other resources with the same priority, shaving 100-500ms off LCP.
{{-- Hero image — the likely LCP element --}}
<img
src="/images/hero.webp"
fetchpriority="high"
loading="eager"
decoding="async"
width="1200" height="600"
alt="Hero">
Avoid
{{-- No priority hint — browser treats it equally with other resources --}}
<img src="/images/hero.webp" alt="Hero">
Minimize main-thread work
Warning
mainthread-work-breakdown
Heavy JavaScript execution blocks rendering and interaction. Code-split and defer non-critical scripts.
Heavy JavaScript parsing, compiling, and executing on the main thread blocks all rendering and user interaction. Tasks over 50ms are "long tasks" that cause jank. Code-splitting and deferring non-critical JS are the primary fixes.
Typical improvement: reduces TBT by 200-800ms, INP drops to < 200ms
// Break up heavy work with scheduler.yield() (Chrome 115+)
async function processData(items) {
for (const item of items) {
process(item);
await scheduler.yield(); // yield between items
}
}
// Vite manual chunks to split vendor code
rollupOptions: { output: { manualChunks: { vendor: ['lodash', 'moment'] } } }
Avoid
// Heavy synchronous loop on page load blocks the main thread
const result = massiveDataSet.map(computeExpensive);
Avoid excessive DOM size
Warning
dom-size
A large DOM slows style calculation and layout. Paginate long lists or use virtual scrolling.
Pages with > 1500 DOM nodes suffer slower style recalculation, layout, and paint. Every queried selector must traverse the whole tree. Paginate large lists or use virtual scrolling.
// Paginate results in Laravel
$items = Item::query()->paginate(50);
// Livewire lazy loading for heavy components
#[Lazy]
class HeavyList extends Component {}
Avoid
@foreach ($allItems as $item)
{{-- Rendering 5000+ items at once bloats the DOM --}}
<div class="item">{{ $item->name }}</div>
@endforeach
Avoid multiple page redirects
Warning
redirects
Each redirect adds round-trip latency. Eliminate chains and enforce HTTPS at the server level.
Each HTTP redirect adds a full round-trip — typically 100-300ms on mobile. Redirect chains multiply this cost. The most common Laravel culprit is HTTP→HTTPS redirect that could be eliminated with HSTS or server-level HTTPS enforcement.
Typical savings: 100-300ms per eliminated redirect
# Enforce HTTPS at the server level (not in PHP)
# nginx:
server {
listen 80;
return 301 https://$host$request_uri;
}
# With HSTS, browsers skip the redirect entirely on repeat visits:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Avoid
// Multiple redirect hops:
// http://example.com -> https://example.com -> https://www.example.com -> /home
// Each hop adds a full RTT
Reduce server response time (TTFB)
Warning
server-response-time
High TTFB drags down LCP. Enable OPcache, config/route cache, and use Redis for sessions.
A TTFB > 600ms directly drags down LCP — the browser cannot start rendering until the first byte arrives. Laravel-specific causes include disabled OPcache, no config/route cache, slow database queries, and no Redis for sessions/cache.
Typical savings: 100-500ms TTFB with OPcache + config cache
# Full deploy-time optimization
php artisan optimize # config + route + view cache
; php.ini (critical for any PHP app)
opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
# Redis for sessions + cache
SESSION_DRIVER=redis
CACHE_STORE=redis
Avoid
# No caches, default file session/cache, APP_DEBUG=true
# Every request re-reads config files and re-registers routes
Use passive event listeners
Info
uses-passive-event-listeners
Touch and wheel listeners without { passive: true } block scrolling. Add the passive flag.
Touch and wheel event listeners without `{ passive: true }` block the browser from scrolling until the handler completes. This forces the browser to wait up to 50ms per scroll event, causing visible jank.
Typical improvement: scroll jank eliminated, INP improves by 30-100ms
// Non-passive — browser waits for handler before scrolling
document.addEventListener('touchstart', handler); // defaults to passive: false
Avoid the deprecated write() API
Warning
no-document-write
The deprecated parser-blocking API should be replaced with DOM manipulation methods.
The deprecated `document.write()` API blocks HTML parsing because the browser cannot continue until the injected content is processed. On slow connections this can pause rendering by seconds. Use DOM manipulation instead.
Removes potentially seconds of blocking time on 2G/3G connections
// Use DOM manipulation instead
const el = document.createElement('script');
el.src = 'analytics.js';
el.async = true;
document.head.appendChild(el);
Avoid
// Deprecated API that blocks HTML parsing
// document.write('<script src="analytics.js"></script>');
Serve static assets with an efficient cache policy
Info
uses-long-cache-ttl
Short cache TTLs force re-downloads on repeat visits. Use long TTL with hashed filenames.
Short cache TTLs on static assets force re-downloads on repeat visits, wasting bandwidth and slowing pages for returning users. Vite's content-hashed filenames make it safe to cache assets for a year.
Repeat visits load 90-100% from cache — near-instant for returning users
# No explicit cache headers — browsers re-validate every request
location /build/ {
# missing expires / Cache-Control
}
LCP image should not be lazy-loaded
Warning
lcp-lazy-loaded
Remove loading="lazy" from your LCP image — it delays the most important resource on the page.
The LCP image has `loading="lazy"` which intentionally delays its fetch. The browser won't load it until it becomes visible — but it's the most important resource on the page. Remove `loading="lazy"` from above-the-fold images.
{{-- Do NOT lazy-load your LCP image --}}
<img src="hero.webp" loading="lazy" alt="Hero"><!-- delays LCP! -->
Largest Contentful Paint element identified
Info
largest-contentful-paint-element
Optimise the LCP element: preload, fetchpriority="high", WebP format, and no lazy-load.
Knowing which element is the LCP lets you focus optimization efforts. Common fixes: preload the image, remove lazy-load, upgrade to WebP, preconnect to its origin, or inline its critical CSS.
{{-- Once you know the LCP element, apply these optimizations --}}
<img
src="/images/hero.webp"
fetchpriority="high"
loading="eager"
width="1200" height="600"
alt="Hero">
Avoid large layout shifts
Warning
layout-shift-elements
Elements causing CLS are identified. Reserve space with explicit dimensions or aspect-ratio CSS.
CLS is caused by elements shifting after initial render — typically images without dimensions, dynamically injected banners, or web fonts causing text reflow. Identifying the shifting element is the first step to fixing CLS.
Fixing the top shift element typically drops CLS from 0.2-0.4 to under 0.1
{{-- Reserve space before the image loads --}}
<img src="product.jpg" width="400" height="300" alt="Product">
{{-- Or with CSS aspect-ratio --}}
<div style="aspect-ratio: 4/3; overflow: hidden">
<img src="product.jpg" alt="Product">
</div>
Avoid
{{-- No dimensions — causes layout shift when image loads --}}
<img src="product.jpg" alt="Product">
Avoid non-composited animations
Info
non-composited-animations
Animate only transform and opacity to keep animations on the GPU compositor thread.
Animations that trigger layout or paint (animating `top`, `left`, `width`, `height`) run on the main thread and cause jank. Animating only `transform` and `opacity` allows the browser to offload work to the GPU compositor thread.
Typical improvement: animation FPS from 30-40 to 60fps
/* GPU-composited — runs off main thread */
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
Avoid
/* Triggers layout — runs on main thread, causes jank */
@keyframes slideIn {
from { left: -100%; }
to { left: 0; }
}
Serve images at the right size
Info
image-size-responsive
Use srcset and sizes to serve appropriately-sized images for each viewport.
Serving a 2000px image to a 400px mobile device wastes 4-5× bandwidth. Use `srcset` and `sizes` to let the browser choose the best-fit image for the current viewport.
A query pattern repeated above the configured threshold during this audit.
N+1 queries happen when accessing a relation inside a loop without eager loading. Each iteration triggers a separate SQL query — 100 items = 101 queries.
Typical savings: cuts query count by 90%, 200-1500ms TTFB improvement
Synthetic Lighthouse audits run on a clean machine with stable network. Real production traffic faces variable load, geographic latency, and contention — surfacing problems synthetic tests miss.
:count third-party origin(s) (:entities) block the main thread > 250ms. Defer or self-host where possible.
Third-party scripts (analytics, ads, chat widgets) often run synchronously on page load and steal main-thread time, delaying interactivity. Defer or self-host critical ones.
A single script takes :ms ms to evaluate. Code-split, lazy-load, or remove unused JavaScript.
A single script taking >500ms to evaluate blocks the main thread for that duration. Code-splitting separates critical and deferred logic so the page becomes interactive sooner.
Set explicit width/height attributes on images to reserve layout space. Browsers can compute the aspect ratio early and avoid layout shifts when the image loads.
When the browser parses an <img> without explicit dimensions, it has to wait until the image downloads before knowing how much space to reserve. The result: visible content jumps when the image arrives — high CLS.
Typical savings: cuts CLS from 0.2-0.4 to under 0.1
Use `font-display: swap` so the browser shows a fallback font immediately and swaps to the webfont when ready. Avoids invisible text (FOIT).
Without `font-display: swap`, browsers may show invisible text until the webfont loads (FOIT — Flash of Invisible Text). With `swap`, fallback text is visible immediately and the webfont swaps in when ready.
Add `<link rel="preload">` for resources discovered late in the page (e.g. Vite-emitted modules, hero images, critical fonts). The browser fetches them earlier.
The browser discovers resources by parsing HTML and CSS in order. Late-discovered resources (e.g. fonts referenced inside @font-face) start downloading too late. `<link rel="preload">` tells the browser to fetch them in parallel with the initial HTML.
Typical savings: 100-400ms LCP on font-heavy pages
Octane keeps the application bootstrapped between requests (Swoole / FrankenPHP / RoadRunner), eliminating the per-request bootstrap cost. Typical TTFB savings: 40-200ms.
Each Laravel request bootstraps the framework: loading config, registering service providers, and parsing routes. Octane keeps the application in memory across requests via Swoole / FrankenPHP / RoadRunner, slashing bootstrap time. Most useful for high-traffic apps where TTFB matters.
Links need accessible text so screen readers can describe their destination.
Links without discernible text (e.g. icon-only links, empty `<a>` tags) are announced as "link" by screen readers with no destination context. Add visible text, `aria-label`, or `aria-labelledby`.
<!-- Visible text -->
<a href="/dashboard">Go to dashboard</a>
<!-- Or aria-label for icon links -->
<a href="/settings" aria-label="Open settings">
<svg aria-hidden="true">...</svg>
</a>
Avoid
<!-- Empty link — screen reader says 'link' with no destination -->
<a href="/settings"><svg>...</svg></a>
Buttons have an accessible name
Warning
button-name
Buttons without a label are announced as "button" with no context. Add text or aria-label.
Buttons without accessible names are announced as "button" by screen readers. Users with visual impairments cannot tell what action will occur. Always provide text content or `aria-label`.
<!-- Screen reader says 'button' with no context -->
<button type="button"><svg>...</svg></button>
Has a viewport meta tag
Warning
meta-viewport
Add <meta name="viewport" content="width=device-width, initial-scale=1"> for proper mobile rendering.
Without a correct viewport meta tag, mobile browsers render the page at desktop width and scale it down. Users see tiny text and must pinch-to-zoom. `user-scalable=no` also fails accessibility requirements.
The lang attribute value must be a valid BCP47 language code (e.g. en, fr, pt-BR).
An invalid BCP47 language code (e.g. `lang="en-123"` or `lang="fr-INVALID"`) means assistive technologies fall back to default pronunciation. Use the standard code for your language.
Elements with ARIA roles must include all required attributes for assistive technologies.
ARIA roles require certain attributes to function correctly. For example, `role="checkbox"` requires `aria-checked`. Missing required attributes break assistive technology semantics entirely.
ARIA attribute values must come from the allowed set defined by the ARIA specification.
ARIA attribute values must be from the allowed set. An invalid value (e.g. `aria-live="sometimes"`) is ignored by screen readers, silently breaking the intended accessible behaviour.
Serve all resources over HTTPS to protect users and enable modern browser features.
HTTP pages cannot use many modern browser features (Service Workers, HTTP/2 push, geolocation). Browsers mark them as "Not Secure" — destroying user trust. All production Laravel apps should be served over HTTPS.
# nginx with Let's Encrypt
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
# Force HTTPS in Laravel
// AppServiceProvider::boot()
\URL::forceScheme('https');
Avoid
# Serving over plain HTTP — browser shows 'Not Secure'
server {
listen 80;
# no SSL block
}
Avoid requesting geolocation on page load
Warning
geolocation-on-start
Request geolocation only after a clear user interaction, not immediately on page load.
Requesting geolocation permission immediately on page load (before any user interaction) is considered intrusive. Browsers show a permission prompt that most users dismiss — then the feature is blocked forever. Request only after a clear user action.
Avoid requesting notification permission on page load
Warning
notification-on-start
Notification prompts shown on load are dismissed by nearly all users. Ask after demonstrating value.
Requesting push notification permission immediately on page load results in 90%+ rejection rates and trains users to deny all notifications. Request after demonstrating value — e.g. after a successful action.
// Request after user opts in
document.getElementById('enable-notifications').addEventListener('click', () => {
Notification.requestPermission();
});
Avoid
// Requesting on page load — dismissed by almost all users
window.addEventListener('load', () => Notification.requestPermission());
Allow users to paste into password fields
Warning
password-inputs-can-be-pasted-into
Blocking paste prevents password manager use and encourages weaker passwords.
Blocking paste on password fields prevents password manager use, forces manual typing of complex passwords, and drives users toward weaker passwords. Never prevent paste — it reduces security, not increases it.
<!-- Standard password input — paste is allowed by default -->
<input type="password" name="password" autocomplete="current-password">
Avoid
<!-- Never do this -->
<input type="password" onpaste="return false;">
Images have correct aspect ratio
Info
image-aspect-ratio
Ensure images are displayed at their natural aspect ratio to prevent distortion.
When the CSS display size has a different aspect ratio than the intrinsic image size, the image appears distorted. Setting `width` and `height` attributes and using `aspect-ratio` CSS prevents this.
# .env in production
APP_DEBUG=false
APP_ENV=production
Avoid
# .env in production — leaks stack traces!
APP_DEBUG=true
Enable OPcache
Warning
opcache-disabled
OPcache caches the compiled PHP bytecode and is essential for production performance.
Without OPcache, PHP recompiles every script on every request. With OPcache, compiled bytecode is cached in shared memory — typically 2-3x speedup on the application layer.
; In production php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
Pin a PHP version
Info
missing-php-version
Add an explicit php constraint in composer.json so deploys reject incompatible versions.
An explicit PHP constraint in composer.json prevents `composer install` from succeeding on incompatible production servers — fail fast at deploy time, not in runtime.
Use redis or database for sessions on multi-process production hosts.
File-based sessions create lock contention and disk I/O on every request. Multi-process production hosts (PHP-FPM with > 1 worker) need a centralised store like Redis.
Page has :count DOM elements. Large DOMs slow rendering and JS interactions. Aim for under 1500 elements.
Large DOMs (>1500 nodes) slow style recalculations and layout, causing jank during scroll and interactions. Often caused by deeply nested templates or rendering huge lists without virtualization.
// Paginate or virtualize large lists
$items = $query->paginate(50);
Avoid
// Rendering 5000 items at once
@foreach ($thousandsOfItems as $item)
<div>...</div>
@endforeach
Improve cache policy
Info
cache-policy-short
:count resource(s) have a TTL under 30 days. Long-term caching reduces repeat-visit load times.
Short cache TTL (< 30 days) on static assets forces repeat downloads on return visits. Long TTL combined with hashed filenames (Vite default) is safe and cuts repeat-visit load times to near zero.
# nginx — long cache for hashed assets
location ~* \.(js|css|webp|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Use HTTP/2 (or HTTP/3)
Info
uses-http2
HTTP/2 multiplexes requests over a single connection — faster than HTTP/1.1 for resource-heavy pages. Most modern hosts (Forge, Vapor, Cloudflare) enable it by default.
HTTP/1.1 establishes one connection per resource. HTTP/2 multiplexes many requests over a single connection, drastically reducing handshake overhead. Most managed Laravel hosts (Forge, Vapor, Cloudflare) enable HTTP/2 by default — but a misconfigured custom server may not.
Without content hashes (`app-Df8gK3p2.js`), you cannot cache assets aggressively — every change requires invalidation. Vite generates hashed filenames by default; verify your build output.
Hashed asset names (`app-Df8gK3p2.js`) let browsers cache assets forever — when content changes, the hash changes, the URL changes, the browser fetches the new version. Without hashes, you cannot use long cache TTLs without serving stale content. Vite's default config produces hashed filenames automatically.
<meta name="description" content="Buy authentic Nike Air Max sneakers — fast shipping, free returns.">
Document has a valid hreflang
Warning
hreflang
Use correct BCP47 hreflang values so search engines show the right language version.
For multilingual sites, `hreflang` tells search engines which page to show for which language and region. Incorrect or missing `hreflang` leads to the wrong language version appearing in search results.
<!-- Missing hreflang — search engines guess the target language -->
<!-- Or invalid language code -->
<link rel="alternate" hreflang="english" href="..."><!-- invalid BCP47 -->
Document has a canonical link
Warning
canonical
Add <link rel="canonical"> to declare the authoritative URL and prevent duplicate content indexing.
Duplicate content across URLs (with/without trailing slash, www vs non-www, UTM parameters) confuses search engines and dilutes PageRank. A canonical tag tells Google which is the authoritative URL.
<!-- In Blade layout -->
<link rel="canonical" href="{{ url()->current() }}">
<!-- Or with Laravel SEO packages -->
// In AppServiceProvider: URL::forceRootUrl(config('app.url'));
Avoid
<!-- No canonical — search engines may index many duplicate versions -->
<!-- /page, /page/, /page?utm_source=twitter all treated as separate URLs -->
robots.txt is valid
Warning
robots-txt
An invalid or missing robots.txt can block search engines from crawling your site.
An invalid or missing robots.txt can prevent search engines from crawling your site, or allow crawling of sensitive paths. Lighthouse flags when robots.txt is unreachable or syntactically invalid.
# Accidentally blocking all crawlers
User-agent: *
Disallow: /
# Or missing robots.txt — crawlers get no guidance
Tap targets are sized appropriately
Warning
tap-targets
Touch targets should be at least 48×48 CSS pixels to be easily tappable on mobile.
Touch targets smaller than 48×48 CSS pixels are hard to tap accurately on mobile. Users mis-tap neighbouring elements, triggering unintended actions. Google's mobile-friendliness criteria require adequate tap target sizes.
Improves Google mobile-friendliness score and reduces accidental tap errors