INP Remix 3+

Fix INP in Remix

Interaction to Next Paint (INP) is a critical Core Web Vital that measures how quickly the main content of your page becomes visible. In Remix applications, common issues include unoptimized images, render-blocking resources, and inefficient data loading patterns that delay the largest visible element.

This guide walks through five targeted fixes for INP in Remix, with real code examples and before/after performance comparisons. Each step addresses a specific bottleneck in the Remix rendering pipeline.

Expected results

Following all five steps typically produces these improvements:

Before

340ms

INP score (Needs Improvement) -- slow form submissions, heavy event handlers, blocking transitions, large DOM operations

After

95ms

INP score (Good) -- optimistic UI, debounced handlers, non-blocking transitions, efficient DOM updates

Step-by-step fix

Use optimistic UI for instant feedback on form submissions

Remix's useFetcher and useNavigation hooks enable optimistic UI patterns where the interface updates immediately upon user interaction, before the server responds. This dramatically reduces perceived INP because the visual update happens synchronously while the actual mutation runs in the background.

Common mistake: Always handle the error case with optimistic UI. If the server rejects the mutation, revert the optimistic update and show an error message. Remix automatically revalidates loader data after actions, so the UI will self-correct.
TSX -- Before (Bad)
// Blocking form -- user waits for server response
export default function LikeButton({ postId }: { postId: string }) {
  return (
    
); // Button does nothing until server responds (300-500ms) }
TSX -- After (Good)
import { useFetcher } from "@remix-run/react";

export default function LikeButton({ postId, liked }: {
  postId: string;
  liked: boolean;
}) {
  const fetcher = useFetcher();

  // Optimistic: assume success immediately
  const isLiked = fetcher.formData
    ? fetcher.formData.get("liked") === "true"
    : liked;

  return (
    
      
      
    
  );
  // UI updates instantly on click -- INP measured at ~16ms
}

Debounce search inputs and rapid-fire events

Search boxes, auto-complete fields, and filter inputs that trigger fetches on every keystroke create rapid successive interactions that compete for main thread time. Debounce these inputs to batch updates and reduce the number of network requests and DOM updates.

TSX -- Debounced search
import { useFetcher } from "@remix-run/react";
import { useRef, useCallback } from "react";

export function SearchInput() {
  const fetcher = useFetcher();
  const timeoutRef = useRef();

  const handleInput = useCallback((e: React.ChangeEvent) => {
    // Clear previous timer
    clearTimeout(timeoutRef.current);

    // Wait 300ms after last keystroke before fetching
    timeoutRef.current = setTimeout(() => {
      fetcher.submit(
        { q: e.target.value },
        { method: "get", action: "/api/search" }
      );
    }, 300);
  }, [fetcher]);

  return (
    
{fetcher.data && (
    {fetcher.data.results.map((result: any) => (
  • {result.title}
  • ))}
)}
); }

Break up long tasks with yield-to-main-thread patterns

Any JavaScript task running longer than 50ms blocks the main thread and delays the browser's ability to process user interactions. In Remix route components, break up expensive computations (data transformation, DOM manipulation, sorting large lists) by yielding to the main thread between chunks.

TypeScript -- Yield to main thread
// Utility: yield control back to the browser
function yieldToMain(): Promise {
  return new Promise((resolve) => {
    if (typeof scheduler !== "undefined" && scheduler.yield) {
      // Use scheduler.yield if available (Chrome 115+)
      scheduler.yield().then(resolve);
    } else {
      setTimeout(resolve, 0);
    }
  });
}

// Process large list in chunks
async function processItems(items: Item[]) {
  const CHUNK_SIZE = 50;
  const results: ProcessedItem[] = [];

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    results.push(...chunk.map(processItem));

    // Yield after each chunk so interactions can be processed
    if (i + CHUNK_SIZE < items.length) {
      await yieldToMain();
    }
  }

  return results;
}

Show navigation progress for route transitions

Remix route transitions can feel slow when navigating to pages with slow loaders. Use useNavigation to show a progress indicator immediately when the user clicks a link, providing visual feedback that reduces perceived latency. The interaction (click) gets a fast visual response even if the data takes time to load.

TSX -- Navigation progress bar
import { useNavigation } from "@remix-run/react";

export function GlobalProgress() {
  const navigation = useNavigation();
  const isNavigating = navigation.state !== "idle";

  return (
    
); } /* CSS */ /* .progress-bar { position: fixed; top: 0; left: 0; height: 3px; background: var(--color-accent); width: 0; opacity: 0; transition: width 0.5s ease, opacity 0.2s; z-index: 9999; } .progress-bar.active { opacity: 1; width: 80%; animation: progress 2s ease-in-out; } */

Minimize client-side JavaScript and third-party scripts

Every kilobyte of JavaScript adds to main thread processing time, which directly impacts INP. Audit your client-side JavaScript: remove unused dependencies, defer analytics scripts, and lazy-load components that are not needed for initial interactivity. Use Remix's server-side capabilities to move computation off the client.

TSX -- Deferred third-party scripts
import { useEffect } from "react";

export function DeferredAnalytics() {
  useEffect(() => {
    // Load analytics after page is interactive
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = "https://analytics.example.com/tracker.js";
      script.async = true;
      document.body.appendChild(script);
    }, 3000); // Delay 3 seconds after mount

    return () => clearTimeout(timer);
  }, []);

  return null;
}

// In root.tsx:
// 

Remix 2.x patterns for INP: defer, single-fetch routes, and SPA mode

The five steps above hold for any Remix version, but Remix 2.x added three primitives that change the INP math materially. If you are still on Remix 1.x, the patterns below also describe what you gain by upgrading the parts of your app that handle the busiest user interactions.

defer plus Suspense: stream the slow loaders, keep the interactive shell fast

Remix's defer helper lets a loader return a Promise that streams in after the document shell. The route renders immediately with a <Suspense fallback> placeholder, and the slow data resolves later without blocking hydration. For INP this matters because hydration competes with the first user interaction. If the loader returns a 600ms database query awaited up front, hydration on slower devices can be delayed by hundreds of milliseconds while React commits the full tree.

// app/routes/_index.tsx
import { defer, type LoaderFunctionArgs } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";

export async function loader({ request }: LoaderFunctionArgs) {
  const criticalData = await getHeroContent();        // fast: above-fold
  const slowData = getRecommendations(request);       // slow: defer, do NOT await
  return defer({ criticalData, slowData });
}

export default function Index() {
  const { criticalData, slowData } = useLoaderData<typeof loader>();
  return (
    <>
      <Hero data={criticalData} /> {/* renders immediately, hydrates first */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Await resolve={slowData}>
          {(recs) => <Recommendations data={recs} />}
        </Await>
      </Suspense>
    </>
  );
}

Two things this does for INP. First, the hero hydrates with a smaller component tree, so the main thread is free to handle the user's first click sooner. Second, when the deferred Promise resolves and Suspense commits the recommendations, that update is wrapped in a React transition under the hood, so it yields to interactions instead of monopolizing the main thread.

Single-fetch routes: one network round-trip per navigation

Remix 2.9 introduced single-fetch as an opt-in (and the default starting with Remix v3). In the old model, every loader on a route hierarchy was its own HTTP request -- a five-level nested route fired five parallel requests. Single-fetch collapses them into one _root.data request per navigation, which both reduces TCP overhead and -- more importantly for INP -- reduces the number of concurrent JSON parse jobs that compete with the user's interaction.

// remix.config.js (Remix 2.9+)
export default {
  future: {
    v3_singleFetch: true,
    v3_lazyRouteDiscovery: true,
  },
};

Pair single-fetch with v3_lazyRouteDiscovery so route manifests are fetched on demand instead of preloaded with the document. Real INP wins here come from cutting the JavaScript parse budget on the critical path; expect 30 to 80ms shaved off interactions on mid-tier Android devices, sometimes more on cold cache.

SPA mode trade-offs: when client-only is the right INP choice

Remix SPA mode (introduced in 2.5) skips server rendering entirely and serves a static index.html shell plus the client bundle. For INP this is a knife that cuts both ways. The shell ships instantly so first paint feels fast, but the first interaction has to wait for the full client bundle to download, parse, and execute before any event handler is attached. On a fast network with a heavy bundle, INP can actually get worse than SSR.

Use SPA mode when (1) your app is gated behind auth so SEO does not need SSR, (2) your bundle is under 150 KB compressed, and (3) your users are repeat visitors who benefit from the cache. Do not use SPA mode for content-discovery surfaces where users land cold from search -- there the SSR-plus-defer path almost always wins on INP, because at least the visible shell is interactive while the rest of the bundle streams.

If you have already adopted SPA mode and INP regressed, the first lever is route.lazy route-level code splitting. Split the routes that handle the highest-traffic interactions (search, product detail) into their own chunks so they are not paying the parse cost of the entire app's worth of routes on first interaction.

Quick checklist

  • Form submissions use optimistic UI via useFetcher
  • Search inputs debounced to 200-300ms
  • Long tasks broken into chunks with yield-to-main-thread
  • Navigation progress indicator via useNavigation
  • Third-party scripts deferred until after page load
  • Heavy computation moved to server (loaders/actions)
  • No synchronous localStorage/sessionStorage in event handlers

Frequently asked questions

A well-optimized Remix application should achieve INP under 150ms. Remix's server-first architecture means most data mutations happen on the server, reducing client-side JavaScript execution. With optimistic UI patterns and debounced inputs, INP under 100ms is achievable for most interactions.

Remix uses progressive enhancement with HTML forms as the primary interaction model. Form submissions are handled by server-side action functions, which means the heavy computation happens on the server rather than the client. Combined with optimistic UI via useFetcher, the browser can process the click event and update the UI in a single frame (~16ms).

Yes. Remix prefetches route data when a user hovers over a link (with intent prefetching), so by the time they click, the data may already be loaded. This makes navigation transitions faster. For routes with slow loaders, use the useNavigation hook to show a progress indicator immediately, ensuring the click interaction gets a fast visual response.

Use Chrome DevTools Performance panel to record interactions. Look for Long Tasks (marked with red corners) that overlap with your click/keypress events. The Interactions track shows the exact INP measurement. Filter by the interaction type (click, keypress) to identify which event handlers are slow. Check for expensive React re-renders using React DevTools Profiler.

Yes, this is one of Remix's core strengths. Move data processing, filtering, sorting, and validation to loader and action functions that run on the server. The client only receives the processed result. This reduces main thread work and directly improves INP. Use fetcher.submit to trigger server-side processing without full page navigation.

Use Chrome DevTools Performance panel with CPU throttling (4x slowdown) to simulate mid-range mobile devices. Interact with the page (click buttons, type in inputs, open menus) and look for long tasks in the flame chart. The Web Vitals Chrome Extension shows real-time INP scores as you interact. For Remix, pay attention to hydration-related interaction delays.

Continue learning