· frameworks  · 8 min read

Maximizing Performance in Remix: Best Practices You Didn't Know About

Explore advanced, lesser-known Remix performance techniques - from HTTP/CDN caching, ETag handling and edge caching to defer/Await streaming, parallel loaders, route-level lazy loading and avoiding data waterfalls - with real-world examples that yield faster load times and better UX.

Explore advanced, lesser-known Remix performance techniques - from HTTP/CDN caching, ETag handling and edge caching to defer/Await streaming, parallel loaders, route-level lazy loading and avoiding data waterfalls - with real-world examples that yield faster load times and better UX.

Why performance in Remix matters (and what’s different)

Remix ships with thoughtful defaults for route-based code-splitting, progressive enhancement, and server-first data loading. But to get consistently snappy real-world apps you need to combine Remix primitives with web-performance techniques most teams either forget or never discover.

This post focuses on practical, lesser-known optimizations you can add today: HTTP/CDN caching patterns, ETag handling, streaming/deferred data with defer + Await, parallel loaders to avoid request waterfalls, lazy-loading large components (and keeping server code off the client bundle), prefetch strategies, and real-world examples tying them together.

References used throughout:


Core principles before optimizing

  • Treat the server-rendered HTML as the most important resource. Get it fast.
  • Avoid large client bundles by keeping server-only code in loaders/actions and not importing it into components that run in the browser.
  • Reduce blocking network requests (waterfalls) by parallelizing and streaming data to the client.
  • Use HTTP caching (Cache-Control, ETag) and CDNs - they are the single biggest win for repeated visits.

1) HTTP + CDN caching strategies you may be missing

Remix gives you full control over response headers. Use that!

  • Use Cache-Control to tell CDNs and browsers how to cache assets and HTML. For cacheable HTML fragments, use s-maxage for CDN TTL and stale-while-revalidate to serve slightly stale content while revalidation happens in background.
  • Use ETag / Last-Modified to cheaply respond with 304 Not Modified.
  • Cache static assets aggressively at build time (fingerprinted filenames) and HTML at the edge when it makes sense.

Example: set caching headers from a loader (basic pattern)

// app/routes/posts.jsx
import { defer } from '@remix-run/node';
import { getPosts } from '~/models/posts.server';

export const loader = async ({ request }) => {
  const postsPromise = getPosts(); // do not await, allow streaming/parallel

  return defer(
    { posts: postsPromise },
    {
      headers: {
        // CDN caches for 60s, allow serving stale while revalidating for 5 minutes
        'Cache-Control':
          'public, max-age=60, s-maxage=60, stale-while-revalidate=300',
      },
    }
  );
};

Notes:

  • s-maxage targets shared caches (CDNs/edges), max-age targets browsers. stale-while-revalidate lets you be forgiving and fast.
  • Configure your CDN (Vercel/Cloudflare) to respect origin headers.

ETag example (manual 304 handling)

import crypto from 'crypto';
import { json } from '@remix-run/node';
import { getPosts } from '~/models/posts.server';

export const loader = async ({ request }) => {
  const posts = await getPosts();
  const body = JSON.stringify(posts);
  const etag = `W/"${crypto.createHash('md5').update(body).digest('hex')}"`;

  if (request.headers.get('if-none-match') === etag) {
    return new Response(null, { status: 304, headers: { ETag: etag } });
  }

  return json(posts, {
    headers: { ETag: etag, 'Cache-Control': 'public, max-age=60' },
  });
};

Why this matters:

  • CDNs and proper cache headers drastically reduce TTFB for repeat visitors and global users.

2) Stream and defer data for faster perceived loads

Remix’s defer (server) + <Await> (client) pattern lets you stream fast HTML with critical data while lower-priority data loads in the background. Use it to prioritize above-the-fold content.

Loader using defer:

// app/routes/dashboard.jsx
import { defer } from '@remix-run/node';
import { getProfile } from '~/models/user.server';
import { getFeed } from '~/models/feed.server';

export const loader = async ({ request }) => {
  const profilePromise = getProfile(request); // critical
  const feedPromise = getFeed(); // heavy, can stream

  // Return profile immediately (await), stream feed (defer)
  return defer({ profile: await profilePromise, feed: feedPromise });
};

Client: consume with <Await> and show skeletons

import { useLoaderData, Await } from '@remix-run/react';
import { Suspense } from 'react';

export default function Dashboard() {
  const data = useLoaderData();

  return (
    <div>
      <Profile data={data.profile} />

      <Suspense fallback={<FeedSkeleton />}>
        <Await resolve={data.feed}>{feed => <Feed items={feed} />}</Await>
      </Suspense>
    </div>
  );
}

Why this matters:

  • Users see meaningful content sooner. Heavy lists, comments, or recommendation engines can stream later without blocking the initial render.

3) Avoid waterfalls - parallelize everything

A waterfall happens when your loader awaits several fetches sequentially. Instead, start all requests and await together.

Bad (waterfall):

const user = await getUser();
const posts = await getPostsForUser(user.id); // waits for user

Good (parallel):

const userPromise = getUser();
const postsPromise = getPostsForUser(someId);
const [user, posts] = await Promise.all([userPromise, postsPromise]);

Even better: combine with defer to stream noncritical promises.

Why this matters:

  • Network latency compounds across sequential requests. Parallelization cuts total wait time down to the slowest call.

4) Lazy-load heavy components and keep server code off the client

Remix’s route-based code-splitting is powerful, but you also get wins by lazy-loading big UI chunks and ensuring server-only code stays server-only.

  • Use dynamic imports + React.lazy for large widgets (graphs, WYSIWYG editors, analytics dashboards).
  • Put database or secret-dependent code only in loaders/actions. If you import server utilities into a module that also exports a React component, bundlers may include them in the client bundle.

Example: lazy-load a heavy comments UI

import React, { Suspense } from 'react';
const RichComments = React.lazy(() => import('~/components/RichComments'));

export default function Post() {
  return (
    <article>
      <PostContent />
      <Suspense fallback={<div>Loading comments...</div>}>
        <RichComments />
      </Suspense>
    </article>
  );
}

Tip: keep server code in app/models/ and only import those files from loaders/actions. Avoid importing models/* inside components that are rendered on the client.

Why this matters:

  • Lazy-loading reduces initial JS payload. Keeping server code out prevents accidental client bloat and leaking secrets.

Remix <Link> supports prefetch behavior. Use it wisely.

  • prefetch="intent" fetches data and module when the user indicates they’ll navigate (e.g., hover or focus). It’s a great balance between network use and UX.
  • prefetch="render" prefetches immediately - use carefully for high-probability navigations like primary CTAs.

Example:

import { Link } from '@remix-run/react';

// Prefetch data and route module on hover
<Link to="/posts/123" prefetch="intent">
  Read more
</Link>;

Also consider resource hints for critical assets (<link rel="preload">) and prefetching fonts/critical images.

Why this matters:

  • Prefetch on intent makes navigations feel instant without prefetching the entire site.

6) Efficient client-side fetching: useFetcher + dedupe + cache

Remix useFetcher is perfect for background data loads that shouldn’t cause navigation. Combine it with client caches or in-memory dedupe to avoid duplicate requests.

Pattern: use a small client-side cache keyed by URL/params, return cached value immediately and let useFetcher.load() revalidate in the background for SWR-style UX.

const cache = new Map();
function useCachedFetch(url) {
  const fetcher = useFetcher();
  const cacheKey = url;

  useEffect(() => {
    if (!cache.has(cacheKey)) {
      fetcher.load(url);
    } else {
      // optional: set local state from cache
    }
  }, [url]);

  useEffect(() => {
    if (fetcher.data) cache.set(cacheKey, fetcher.data);
  }, [fetcher.data]);

  return {
    data: cache.get(cacheKey),
    loading: fetcher.state !== 'idle' && !cache.has(cacheKey),
  };
}

Why this matters:

  • Avoids duplicate requests and yields snappy UX by reusing cached responses.

7) Images, fonts and other asset strategies

  • Use responsive srcset and modern formats (AVIF/WebP) via an image CDN.
  • Lazy-load offscreen images with loading="lazy" and decoding="async".
  • Preload hero images and critical fonts.

Example lazy image:

<img src="/images/hero.jpg" loading="lazy" decoding="async" alt="..." />

Why this matters:

  • Images are often the largest payload; optimizing them accelerates paint and reduces bandwidth.

8) Real-world example: Blog post page (posts + comments)

Goal: render the post content fast, stream comments, use CDN caching for posts, lazy-load rich comment UI.

Loader (combined techniques):

// app/routes/posts/$slug.jsx
import { defer } from '@remix-run/node';
import { getPostBySlug, getCommentsForPost } from '~/models/posts.server';

export const loader = async ({ params, request }) => {
  const postPromise = getPostBySlug(params.slug); // critical
  const commentsPromise = getCommentsForPost(params.slug); // heavy

  // Set CDN cache for post page (short TTL) but allow stale while revalidate
  return defer(
    { post: postPromise, comments: commentsPromise },
    {
      headers: {
        'Cache-Control':
          'public, max-age=30, s-maxage=60, stale-while-revalidate=300',
      },
    }
  );
};

Component (consume and lazy-load comments UI):

import { useLoaderData, Await } from '@remix-run/react';
import { Suspense } from 'react';
const RichComments = React.lazy(() => import('~/components/RichComments'));

export default function PostSlug() {
  const data = useLoaderData();

  return (
    <main>
      <article>
        <h1>{data.post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: data.post.body }} />
      </article>

      <section aria-live="polite">
        <h2>Comments</h2>
        <Suspense fallback={<div>Loading comments preview...</div>}>
          <Await resolve={data.comments}>
            {comments => (
              <Suspense fallback={<div>Loading full comment UI...</div>}>
                <RichComments comments={comments} />
              </Suspense>
            )}
          </Await>
        </Suspense>
      </section>
    </main>
  );
}

What this achieves:

  • The post content shows quickly because post is awaited while comments stream.
  • The heavy RichComments bundle is lazy-loaded on-demand.
  • Cache headers encourage CDN caching of the page for repeat visitors.

9) Measuring improvements and keeping regressions away

  • Use real-user metrics (RUM) like Largest Contentful Paint (LCP) and Time to First Byte (TTFB).
  • Lighthouse and WebPageTest for lab measurements.
  • Track bundle sizes (source-map-explorer or vite/webpack bundle analyzer) and monitor server response headers.

10) Quick checklist you can apply now

  • Move DB/secret logic into loaders/actions and never import server modules into client-rendered components.
  • Add Cache-Control + s-maxage + stale-while-revalidate to cache HTML and set CDNs to respect origin caching.
  • Implement ETag/If-None-Match for expensive-to-generate pages.
  • Use defer + <Await> to stream slow data and show skeletons for noncritical parts.
  • Parallelize network calls with Promise.all or by returning promises to defer.
  • Lazy-load heavy UI with React.lazy and Suspense. Keep widgets self-contained.
  • Use <Link prefetch="intent"> on high-probability navigations.
  • Optimize images (size, format, CDN) and lazy-load offscreen images.
  • Add RUM metrics and monitor bundle sizes.

Closing thoughts

Remix makes many performance patterns straightforward, but the real gains come from combining HTTP caching, streaming, parallelized loaders, and careful client-bundle hygiene. Start with caching and streaming - they yield immediate improvements - then focus on client JS reductions and smarter prefetching.

References

Back to Blog

Related Posts

View All Posts »