/ sagar_pansuriya
§ Laravel

INP for Laravel Apps: Fixing Slow Interactions Before Users Notice

Illustration of a click travelling through input delay, processing and presentation before the next paint

Picture a checkout page where nothing is broken. It loads fast, Lighthouse is green, and LCP is well under two seconds. But every time someone taps Add to cart on a mid-range Android phone, nothing happens for what feels like forever. It's actually about half a second. Then the badge updates.

That half second has a name: Interaction to Next Paint, or INP. It replaced First Input Delay as a Core Web Vital in 2024, and in my experience it's the metric Laravel apps fail most often, and most quietly. Lighthouse doesn't catch it, because Lighthouse doesn't click anything.

This post covers what INP actually measures, why Blade + Livewire + Alpine apps regress on it, how to find the slow interaction, and the fixes that actually move the number.

What INP actually measures

INP watches every click, tap and key press during a visit and reports roughly the worst one (for pages with lots of interactions it ignores a few outliers). For each interaction it measures the time from the user's input until the browser paints the next frame. That time has three parts:

Diagram of one interaction split into input delay, processing time and presentation delay, ending at the next paint
  • Input delay: the main thread was busy with something else when the user clicked, so your handler couldn't even start.
  • Processing time: your event handlers running. Alpine expressions, Livewire's client code, your own JavaScript.
  • Presentation delay: the browser recalculating styles, laying out and painting whatever you changed.

The thresholds: 200 ms or less is good, 200 to 500 ms needs improvement, and anything over 500 ms is poor. They're judged at the 75th percentile of real visits, so your fast laptop tells you very little.

Why Laravel apps are especially prone to it

None of this is Laravel's fault. The server renders HTML quickly and hands it over. The trouble starts with the patterns we pile on top.

1. Waiting on the server before painting anything

The biggest one. With Livewire, a wire:click sends a request, waits for the response, then morphs the DOM. If the button gives no visual feedback until the response arrives, the user's next paint is effectively blocked on your network round trip. On a 4G connection in the evening that can be 300 to 800 ms.

Strictly speaking, the browser can paint while the request is in flight. INP only counts until the next frame. But if nothing on screen changes, users experience the full wait, and any heavy morph when the response lands shows up as a second slow frame.

2. Heavy x-init and big x-data objects

Alpine initialises every component on the page when it boots. A product listing with 60 cards, each with its own x-data that formats prices, reads localStorage and sets up watchers, can easily produce a 150 ms long task. If the user taps a filter during that task, you pay it all as input delay.

3. Third-party scripts in the layout

Chat widgets, tag managers, heatmaps, A/B testing tools. They all live in layouts/app.blade.php, they all run on the main thread, and they all get loaded on every page.

4. wire:model.live on every keystroke

Live binding on a search input fires a request and a DOM diff per character. Type fast on a slow phone and the interactions queue up behind each other.

Step one: measure real users, not your laptop

You can't fix INP from Lighthouse. You need field data. Three sources, from cheapest to most useful:

  1. PageSpeed Insights / CrUX: if your site has enough traffic, the "Discover what your real users are experiencing" section shows your 75th-percentile INP per URL and origin. It's free, but it's aggregated over 28 days and won't tell you which interaction is slow.
  2. Chrome DevTools Performance panel: open it, set CPU throttling to 4× or 6× slowdown, and just use the page. The live metrics view shows INP as you interact and highlights the slowest interaction. Throttling matters. Your M-series Mac is not your users' phone.
  3. The web-vitals library with attribution: this is what I ship on client projects. It tells you exactly which element was interacted with and which phase was slow.

Install it with npm and add a tiny reporter to your Vite entry:

// resources/js/vitals.js
import { onINP } from 'web-vitals/attribution';

onINP(({ value, rating, attribution }) => {
    if (rating === 'good') return;

    navigator.sendBeacon('/vitals', JSON.stringify({
        metric: 'INP',
        value: Math.round(value),
        target: attribution.interactionTarget,   // CSS selector of the element
        type: attribution.interactionType,       // 'pointer' | 'keyboard'
        inputDelay: Math.round(attribution.inputDelay),
        processing: Math.round(attribution.processingDuration),
        presentation: Math.round(attribution.presentationDelay),
        url: location.pathname,
    }));
});

And a route to log it. You don't need a dashboard on day one. A log channel and a weekly grep will already tell you where the problem is.

// routes/web.php
Route::post('/vitals', function (Request $request) {
    Log::channel('vitals')->info('inp', json_decode($request->getContent(), true) ?? []);

    return response()->noContent();
})->withoutMiddleware(VerifyCsrfToken::class)->middleware('throttle:60,1');
A week of this data usually tells a clear story: most poor INP events share one or two targets, and one phase dominates. Once you know the element and the phase, the fix is often ten minutes of work.

The fixes that actually move the number

Give feedback before the round trip

The single most effective change for Livewire apps. Paint something right away, and let the server catch up. Livewire gives you this for free with wire:loading and the data-loading attribute it adds to the element that triggered a request:

<button wire:click="addToCart({{ $product->id }})"
        class="btn data-loading:opacity-60 data-loading:pointer-events-none">
    <span wire:loading.remove wire:target="addToCart">Add to cart</span>
    <span wire:loading wire:target="addToCart">Adding…</span>
</button>

For things that don't need the server to decide, go further and be optimistic. Update the UI in Alpine first, then sync:

<div x-data="{ count: @js($cartCount) }">
    <button @click="count++; $wire.addToCart({{ $product->id }})">Add to cart</button>
    <span x-text="count"></span>
</div>

Yield to the main thread in heavy handlers

If a click handler does real work (filtering a big client-side list, building a chart), split it. Update the visible state first, yield so the browser can paint, then do the rest. scheduler.yield() is supported in Chromium-based browsers. Fall back to a setTimeout elsewhere:

const yieldToMain = () =>
    globalThis.scheduler?.yield
        ? scheduler.yield()
        : new Promise(resolve => setTimeout(resolve, 0));

Alpine.data('productFilter', () => ({
    active: null,
    async apply(filter) {
        this.active = filter;          // cheap: highlight the chip immediately
        await yieldToMain();           // let the browser paint that
        this.results = this.filterItems(filter); // expensive work after the paint
    },
}));

Stop sending a request per keystroke

Most search inputs don't need wire:model.live. Pick the cheapest modifier that still feels right:

DirectiveRequestsUse it for
wire:modelWith the next actionMost form fields
wire:model.blurOn leaving the fieldValidation-as-you-go
wire:model.live.debounce.400msAfter typing pausesSearch-as-you-type
wire:model.liveEvery input eventAlmost never

Make initialisation lazy

Components below the fold don't need to boot on page load. Alpine's official intersect plugin lets you defer the expensive part until the element is visible, and Livewire's lazy attribute does the same for whole components:

{{-- Livewire: render a placeholder, load the component when it scrolls into view --}}
<livewire:product-reviews :product="$product" lazy />

{{-- Alpine: only set up the carousel once it's on screen --}}
<div x-data="carousel" x-intersect.once="init()">…</div>

Also look at 60 identical x-data components. Often a single parent component with event delegation, or an Alpine.store, does the same job with a fraction of the boot cost.

Put third-party scripts on a diet

  • Audit the layout. Every script should have an owner and a reason to exist.
  • Load non-critical widgets after the user interacts or after load, not in the <head>. A chat widget can wait until the first scroll.
  • Scope scripts with @push('scripts') to the pages that need them instead of loading them globally.

Keep DOM updates small

Presentation delay grows with how much of the page you change. A few things that help: wire:key on list items so Livewire morphs only what changed, returning a smaller partial instead of re-rendering a whole table, and content-visibility: auto on long below-the-fold sections so the browser skips their layout and paint work.

A checklist to keep

  1. Ship web-vitals/attribution and log poor INP with the target selector.
  2. Test with 4× to 6× CPU throttling in DevTools, never on raw laptop speed.
  3. Every wire:click gives visual feedback instantly (data-loading or optimistic Alpine state).
  4. No wire:model.live without a debounce.
  5. Lazy-load below-the-fold Livewire components and Alpine widgets.
  6. Split expensive handlers with scheduler.yield().
  7. Every third-party script has an owner, loads late, and is scoped to the pages that need it.

For the checkout page from the start of this post, the fix is two small changes: an optimistic cart count in Alpine and a data-loading state on the button. No rewrite, no new framework. You just stop making the user wait for the server to tell them something they already know.

Discussion