Headless WordPress Performance: 3 Next.js Bottlenecks to Fix

I’ve been building a side project to work through headless WordPress properly – Next 16 on the Pages Router, WordPress serving content over the REST API. Every page rendered correctly. Nothing was broken. And it was still shipping several seconds of avoidable wait on the paths a real reader would take.

That combination is the interesting part. These weren’t crashes or visual regressions; they were three patterns that look perfectly reasonable in a diff and only show up when you read the config and the render mode together. A sequential-fetch waterfall, an image optimizer that was switched off in production, and hand-rolled client fetching with no caching.

Here’s each one, what it actually cost, and the fix.

Why this isn’t “just build time.” Every page here uses getStaticProps with revalidate, and the post page pre-builds only the 10 most recent slugs with fallback: 'blocking'. That matters a lot. Static generation makes slow data fetching invisible only for pages that are already in the cache. For the long tail – every post older than the newest ten – the first visitor after a cache miss waits for the full server render, live. With revalidate: 10, the regeneration path runs constantly on top of that. So “slow getStaticProps” is not a CI problem here. It’s a TTFB problem, aimed squarely at the readers arriving from search on an older post – which on a content site is most of your traffic.

A fourth, closely related issue – a homepage that fetched its data twice and then threw one copy away – got its own dedicated post because it deserved a deep dive.

1. Sequential awaits where nothing depends on anything

pages/posts/[slug].js renders a post together with its related posts, categories, tags, and comments. That’s four REST calls to WordPress on top of the post itself. Here’s how they were made:

PHP
// before
const relatedPostsResponse = await axios.get(`${API_URL}/posts`, {
  params: { categories: post.categories[0], exclude: post.id, per_page: 3, _embed: true },
});
const relatedPosts = relatedPostsResponse.data;

const categoriesResponse = await axios.get(`${API_URL}/categories`, {
  params: { include: post.categories.join(',') },
});

const tagsResponse = await axios.get(`${API_URL}/tags`, {
  params: { include: post.tags.join(',') },
});

const commentsResponse = await axios.get(`${API_URL}/comments`, {
  params: { post: id },
});
const comments = commentsResponse.data;

Each await blocks the next line from starting. None of these four requests need data from each other – they all only need post, which was already fetched. But written this way, total time is the sum of all four request durations.

Measured against a local WordPress instance, each call landed at roughly 1–2 seconds, so the render sat around 8 seconds. Your absolute numbers will differ – a local WP box is not a tuned production backend – but the shape holds regardless of environment: four sequential round-trips cost four round-trips.

The fix: run independent work concurrently

PHP
// after
const [relatedPosts, categories, tags, comments] = await Promise.all([
  getRelatedPosts(post.categories[0], post.id, 3),
  getCategoriesByIds(post.categories),
  getTagsByIds(post.tags),
  getComments(post.id),
]);

Promise.all starts all four immediately and waits for the slowest, instead of waiting for all four back to back. Total time collapses to roughly one request – about 2 seconds locally, a 3–4x improvement on the same hardware and the same backend.

This is the highest-impact, lowest-risk fix available in server-side data fetching: look for consecutive await statements that don’t consume each other’s results, and start them together.

Two things worth knowing before you apply it everywhere:

  • Promise.all fails fast. If the comments request errors, the whole page render dies – even though comments are decorative. The sequential version had the identical flaw, so this isn’t a regression, but since you’re touching the code anyway, Promise.allSettled is the better tool for genuinely optional data. Render the post; drop the comments section if that one call fails.
  • Parallel does not mean free. You’ve converted a 4-second serial load into four simultaneous hits on WordPress. On a shared host that’s a spike worth watching. It’s still strictly better than serial, but it’s a different shape of load.

While in here, the four inline axios.get calls were also replaced with named fetchers from a central lib/api.js. The performance win came from Promise.all; the readability win came from the fact that you can now see at a glance that these four things are independent.

2. An image optimizer that was switched off for everyone

next/image exists specifically to resize, re-encode (WebP/AVIF), and lazy-load images automatically. The config disabled all of it – not just locally, but in every environment, production included:

PHP
// next.config.ts (before)
module.exports = {
  images: {
    remotePatterns: [/* ... */],
    unoptimized: true, // Bypass image optimization for local development
  },
};

The comment says “for local development.” unoptimized: true applies everywhere the config is loaded – dev, build, and production. Every <Image> in the app was silently serving the full-size, un-recompressed original straight from WordPress. Which means:

  • More bytes transferred on every page with images.
  • No format negotiation – no WebP or AVIF for browsers that support them.
  • Slower Largest Contentful Paint on any page where an image is the biggest above-the-fold element, which on a blog is most of them.

This is the bug I’d most expect to find in someone else’s project, because the mechanism that hides it is so ordinary: a flag added for a real local reason, with a comment explaining that local reason, and nobody ever revisiting whether the flag was scoped to it.

The fix: scope the escape hatch to where it’s actually needed

PHP
// next.config.ts (after)
const hostname = process.env.SITE_DOMAIN || 'anamstarter.local';

module.exports = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname },
      { protocol: 'http', hostname },
    ],
    // Only bypass optimization locally; production must get resized/re-encoded images.
    unoptimized: process.env.NODE_ENV !== 'production',
  },
};

Local dev keeps the no-optimization path – genuinely useful when you’re developing against a WordPress instance with a self-signed cert and no CDN – while production builds get the full next/image pipeline. Note that the comment now describes the condition, not just the intent. If the next person changes this line, the comment will stop matching, which is the point.

Three related image issues got fixed in the same pass:

  • Missing sizes. Post grids rendered images at width={900} regardless of how big the grid cell actually was. Without sizes, the browser can’t know it’s allowed to request a smaller file, so it downloads the full 900px asset even into a one-third-width column. Added sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw", matching the actual grid breakpoints. Get these wrong and you’ve told the browser to fetch the wrong size with confidence, which is worse than not telling it anything.
  • Conflicting sizing classes. Thumbnails carried className='w-auto h-auto ...' alongside explicit width/height props – two sizing systems fighting each other, with layout shift as the tiebreaker. Changed to w-full h-auto.
  • Missing priority on the post page’s featured image. next/image lazy-loads by default, which is correct for below-the-fold images and exactly wrong for the one image guaranteed to be in the viewport on load and almost certainly the LCP element. Added priority, plus a matching sizes.

The priority caveat that bites people: it’s for the LCP element, singular. Adding it to a .map() over a grid of thumbnails preloads the whole grid and makes things worse, not better. One per page, at most.

3. Client-side fetching with no caching, ever

Four places fetched entirely on the client: the category listing, the tag listing, the site menu (WPMenu), and the logged-in-user reader. All four repeated the same shape:

PHP
// before (pages/tag/index.js)
const [loading, setLoading] = useState(false);
const [siteTags, setSiteTags] = useState([]);

useEffect(() => {
  fetchTags();
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const fetchTags = async () => {
  if (loading) return;
  setLoading(true);
  try {
    const response = await getTags();
    setSiteTags(response.length < 1 ? [] : response);
  } catch (error) {
    console.error('Error fetching tags:', error);
  } finally {
    setLoading(false);
  }
};

This works. It also costs:

  • No caching. Navigate away from /tag and back and it refetches the entire tag list – every time – for data that changes maybe monthly.
  • No de-duplication. Two components on one page wanting the same data fire two requests.
  • Hand-maintained boilerplate in four separate files, including an eslint-disable-next-line react-hooks/exhaustive-deps that silences a real dependency gap rather than closing it. The lint disable is the tell: it’s a comment admitting the pattern doesn’t fit the tool.

The fix: SWR

PHP
// after (pages/tag/index.js)
import useSWR from 'swr';
import { getTags } from '@/lib/api';

export default function Tag() {
  const { data: siteTags, isLoading } = useSWR('tags', getTags);

  return (
    <div className='container max-w-screen-md mx-auto my-10 inline-block'>
      <h2 className='text-2xl my-5 font-medium'>Tags</h2>
      {!isLoading && (siteTags || []).length < 1 ? <p>No tags found</p> : null}
      {isLoading ? 'Loading tags...' : (
        <ul>
          {(siteTags || []).map((tag) => (
            <li className='mb-1' key={tag.id}>
              <Link href={`/tag/${tag.slug}`}>{tag.name}</Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

useSWR('tags', getTags) handles loading state, caching by key, request de-duplication, and revalidation – all things the hand-written version did partially or not at all. The same pattern replaced the boilerplate in pages/category/index.js and components/WPMenu.js, the latter keyed on ['wp-menu', menuSlug] since the menu varies per instance.

One detail worth copying: SWR passes the key to the fetcher as its first argument. useSWR('tags', getTags) calls getTags('tags'). That’s harmless when the fetcher ignores arguments, as here – but the moment your fetcher takes a parameter, you want () => getTags() or a key array, or you’ll spend an afternoon on a very confusing bug.

Client caching also pairs with a server-side TTL cache in front of the category and tag fetchers in lib/api.js. SWR stops the same browser tab from refetching; the TTL cache stops every ISR regeneration from hammering WordPress for a list that changes monthly. They solve adjacent halves of the same problem, and neither one substitutes for the other.

The net effect

BeforeAfter
Post page data fetching4 sequential requests (~8s locally)4 parallel requests (~2s locally)
Who pays for itAny reader hitting a post outside the 10 pre-built slugsSame readers, ~4x less waiting
Production image optimizationDisabled everywhere, despite the “local dev” commentEnabled in production, disabled only in dev
Post-grid image sizingFull 900px asset regardless of viewportBrowser picks a size matched to the grid cell
Featured image on post pageLazy-loaded like any other imagepriority – eligible to load immediately
Category/Tag/Menu navigationRefetched from WordPress on every visitCached and de-duplicated via SWR

Find these in your own project

All three are greppable. Twenty minutes, worst case:

  1. Search your data-fetching functions for consecutive awaits. For each pair, ask whether the second uses a value produced by the first. If not, they belong in a Promise.all – or Promise.allSettled if one of them is optional.
  2. Open your next.config. Look for unoptimized, and for anything else whose comment mentions development but whose value doesn’t check NODE_ENV. A flag scoped by comment is not scoped.
  3. Grep for <Image. Every one needs sizes unless it renders at a fixed pixel width at all viewports. Exactly one per page should have priority – the one above the fold.
  4. Grep for useEffect next to fetch or useState. Each hit is a hand-rolled cache-less fetch. SWR or TanStack Query replaces the whole triplet.
  5. Grep for eslint-disable. Each one is a note from a past version of you about something that didn’t fit. Some are legitimate. Most are a deferred fix.

None of this changed what a single page looks like. It changed how much work the browser and the WordPress backend do to produce that same result – which is exactly the kind of improvement that’s invisible in a screenshot and impossible to miss in a performance trace.

Leave a comment

Your email address will not be published. Required fields are marked *

Are you human? Please solve:Captcha