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:
// ~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
// ✗ 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
// ✗ ~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.
<Image
src={product.thumbnail!}
alt={product.title}
width={800}
height={800}
priority // hero only
sizes="(max-width: 768px) 100vw, 50vw"
quality={80}
/>priorityon 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
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
| Metric | Target |
|---|---|
| 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:
- Is it static or dynamic? Static rendering removes the question.
- What does the API response weigh? Fix
fields. - Are fetches parallel? Look for sequential awaits.
- What is the LCP element? Usually an image.
- 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.
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.
import Script from "next/script"
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />| Strategy | Runs | Use for |
|---|---|---|
| `beforeInteractive` | Before hydration | Almost nothing |
| `afterInteractive` | After hydration | Analytics you need on every page |
| `lazyOnload` | During idle time | Chat, reviews, heatmaps |
| `worker` | Off the main thread | Experimental, 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:
| Asset | Budget |
|---|---|
| JS on a product page | 150KB gzipped |
| CSS | 30KB gzipped |
| Hero image | 150KB |
| Total page weight | 800KB |
| API response, product list | 50KB |
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.
Caching Medusa: What to Cache, Where, and What Never To
Four cache layers, one rule about carts, and the invalidation strategy that stops a price change taking twelve hours to appear.
Integrating Medusa with Sanity: Who Owns Which Field
The hard part of a commerce-plus-CMS setup is not the sync. It is deciding which system owns each field — and then never letting both own one.
Building a Medusa Storefront in Next.js: Architecture and Patterns
Server components, caching, cart state and the rendering decisions that determine whether your storefront is fast. The architecture we use on every Medusa build.



