3 MIN READ

Medusa Storefront Performance: Where the Milliseconds Actually Go

Over-fetching, waterfalls and unoptimised images account for most of a slow headless storefront. How to find them and what to do about each.

BY ANANYA IYERUPDATED
Illustration for “Medusa Storefront Performance: Where the Milliseconds Actually Go” — Performance

Headless storefronts are supposed to be fast. Plenty are not, and the reasons are boringly consistent: they fetch too much, they fetch sequentially, they render dynamically when they could render statically, and they ship enormous images.

In rough order of impact, here is where the time goes.

1. Over-fetching

A default product list returns far more than a grid renders:

ts
// ~180KB for 24 products
const { products } = await sdk.store.product.list({ limit: 24 })

// ~35KB, renders identically
const { products } = await sdk.store.product.list({
  limit: 24,
  region_id: region.id,
  fields: "id,title,handle,thumbnail,*variants.calculated_price",
})

Five times less data over the wire and through JSON parsing. Audit every list call before launch — see the SDK guide for the fields syntax.

The same applies to what crosses the server-client boundary. Passing a full product object into a client component serialises all of it into the HTML payload. Pass the four fields the component uses.

2. Dynamic rendering that should be static

tsx
// ✗ Dynamic on every request: an API round trip before the first byte
export default async function CategoryPage({ params }) { … }

// ✓ Static HTML, refreshed hourly
export const revalidate = 3600
export async function generateStaticParams() { … }

Anything identical for every visitor should be static: home, categories, products, content pages. Only cart, checkout and account need to be dynamic.

The usual accidental cause is calling cookies() or headers() in a layout, which opts the entire subtree into dynamic rendering. If your product pages went dynamic without you asking, look for a cookies() call in a shared layout.

3. Request waterfalls

tsx
// ✗ ~600ms — three sequential round trips
const region = await getRegion(countryCode)
const product = await getProduct(handle)
const related = await getRelated(product.collection_id)

// ✓ ~250ms — the independent pair runs in parallel
const [region, product] = await Promise.all([
  getRegion(countryCode),
  getProduct(handle),
])
const related = await getRelated(product.collection_id)

Only the genuinely dependent call stays sequential. On a page with four or five fetches this is routinely a 300–500ms saving, and it is free.

4. Images

Nearly always the LCP element on a product page.

tsx
<Image
  src={product.thumbnail!}
  alt={product.title}
  width={800}
  height={800}
  priority                                    // hero only
  sizes="(max-width: 768px) 100vw, 50vw"
  quality={80}
/>
  • priority on the hero and the first row of a grid. Nothing else — marking everything priority is the same as marking nothing.
  • Accurate sizes, or the browser downloads a desktop-sized image for a phone.
  • quality={80}. The visual difference from 100 is invisible; the size difference is not.
  • Serve from a CDN with a long cache lifetime.

5. Caching with precision

ts
export const getProduct = (handle: string) =>
  unstable_cache(
    async () => { /* … */ },
    ["product", handle],
    { tags: [`product-${handle}`, "products"], revalidate: 3600 },
  )()

Tags let a webhook invalidate exactly what changed rather than dropping the whole cache. Wire it to a subscriber on product.updated. Patterns in the storefront guide.

Backend-side, a Redis cache module helps repeated price and region computation. Caching strategy.

6. JavaScript

Headless storefronts accumulate client components. Two habits keep it in check:

Default to server components. Add "use client" only where there is interactivity, and push it to the leaves — a client-side quantity stepper does not require a client-side product page.

Watch what you import into client components. A date library imported for one format call ships 70KB to every visitor.

Targets and measurement

MetricTarget
LCP< 2.0s
INP< 200ms
CLS< 0.1
TTFB (static)< 200ms
Product page JS< 150KB gzipped

Measure with field data, not lab data. Lighthouse on a developer laptop over office wifi is a fiction; Search Console's Core Web Vitals report is what Google actually uses.

A diagnostic order

When a page is slow, check in this order:

  1. Is it static or dynamic? Static rendering removes the question.
  2. What does the API response weigh? Fix fields.
  3. Are fetches parallel? Look for sequential awaits.
  4. What is the LCP element? Usually an image.
  5. How much JavaScript ships? Find the accidental client component.

Four out of five slow storefronts are fixed by the first three.

Fonts

Usually the second-largest LCP contributor after images, and entirely avoidable.

app/layout.tsxts
import { Inter } from "next/font/google"

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-sans",
  // Only the weights you actually use.
  weight: ["400", "500", "700"],
})

next/font self-hosts the files at build time, which removes a DNS lookup and a connection to a third-party host from the critical path. display: "swap" means text renders immediately in a fallback rather than staying invisible.

Two more: subset to the character sets you need, and ship no more than three weights. Every additional weight is another file on the critical path for a difference most visitors cannot see.

Third-party scripts

The other place performance quietly goes. Analytics, chat widgets, review platforms and pixels each add JavaScript that runs before your page becomes interactive.

tsx
import Script from "next/script"

<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
StrategyRunsUse for
`beforeInteractive`Before hydrationAlmost nothing
`afterInteractive`After hydrationAnalytics you need on every page
`lazyOnload`During idle timeChat, reviews, heatmaps
`worker`Off the main threadExperimental, for heavy tags

Audit them quarterly. Every store accumulates scripts nobody remembers adding, and a chat widget loading on the checkout page is both a performance and a conversion problem.

A performance budget

Numbers in a CI check beat good intentions, because performance regresses one small addition at a time:

AssetBudget
JS on a product page150KB gzipped
CSS30KB gzipped
Hero image150KB
Total page weight800KB
API response, product list50KB

Enforce it with Lighthouse CI or a bundle-size check on every pull request, failing the build when a budget is exceeded. That converts "we should keep an eye on performance" into a conversation at the moment someone adds the dependency, which is the only time it is cheap to reconsider.

Review the budgets quarterly. They should get tighter as you remove things, not looser as you add them.


We do performance audits that end with a diff, not a slide deck. Ask.

Frequently asked questions

Why is my Medusa storefront slow?

Most often over-fetching — list endpoints returning far more than the page renders — combined with dynamic rendering where static would do, and sequential fetches that could run in parallel. Check payload size and rendering strategy before anything else.

Should product pages be statically generated?

Yes. Use `generateStaticParams` with a `revalidate` window, and invalidate by tag when products change. Static HTML with ISR is faster than any dynamic rendering optimisation and reduces load on the backend.

How do I reduce Medusa API response size?

Use the `fields` parameter to request exactly what you render. Requesting only id, title, handle, thumbnail and calculated price typically cuts a product list response by 70–80% against the default projection.

What causes a page to become dynamic unexpectedly in Next.js?

Usually a call to `cookies()` or `headers()` in a shared layout, which opts the whole subtree into dynamic rendering. Move per-user reads into the specific components that need them.

What are good Core Web Vitals targets for an ecommerce store?

LCP under 2.0 seconds, INP under 200 milliseconds and CLS under 0.1, measured on real user data rather than a local Lighthouse run. Product images are the usual LCP element, so start there.

Does Medusa itself limit storefront performance?

Rarely. The backend serves JSON in tens of milliseconds for typical queries; the time is nearly always spent in payload size, rendering strategy and images. If backend latency genuinely is the constraint, look at query complexity and caching.

[ Keep reading ]