Material UI Performance: Fix Slow Renders and Large Bundles

Your MUI app worked great in development. Then it hit production: 300kb+ bundles, 3-second load times, and PageSpeed scores in the red.

Material UI is the most popular React component library—and one of the easiest to accidentally make slow. The good news: most MUI performance problems come from three fixable patterns. This guide shows you exactly how to fix them and recover your Core Web Vitals.

The three things slowing down your MUI app

Every slow MUI app I've debugged had one (or more) of these problems:

  1. Bundle size - The full library is 300kb+ gzipped. Icons alone can add another 50kb+ if imported wrong.
  2. Dev server speed - Barrel imports cause your bundler to process thousands of files on every change.
  3. Runtime performance - Emotion (the CSS-in-JS engine) computes styles at runtime, and the sx prop adds overhead.

Let's fix each one.

Fix your imports first (biggest win)

This single change can cut dev server startup by 60% and shave 100kb+ off your bundle. How you import MUI components affects everything.

The problem with barrel imports

// Slow - processes entire MUI library
import { Button, TextField, Card } from '@mui/material';
import { Delete, Edit, Save } from '@mui/icons-material';

When you import from @mui/material, your bundler has to process the barrel file that re-exports every component. During development, this means slower startup and hot module replacement.

For @mui/icons-material, it's worse - named imports can be 6x slower than path imports because there are thousands of icons.

Use path imports instead

// Fast - only processes what you need
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Card from '@mui/material/Card';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import Save from '@mui/icons-material/Save';

Yes, it's more verbose. But your dev server will thank you.

Framework-specific fixes

Next.js 13.5+ handles this automatically with optimizePackageImports:

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ['@mui/material', '@mui/icons-material'],
  },
};

Vite users should still use path imports - there's no automatic optimization yet. See our Vite + React performance guide for more Vite-specific optimizations.

Lazy load heavy components

Some MUI components are massive. Don't load them until needed.

DataGrid

The DataGrid alone is 90kb+ gzipped. If it's not visible on initial load, lazy load it:

import { lazy, Suspense } from 'react';

const DataGrid = lazy(() => import('@mui/x-data-grid').then(mod => ({ default: mod.DataGrid })));

function AdminDashboard() {
  return (
    <Suspense fallback={<TableSkeleton />}>
      <DataGrid rows={rows} columns={columns} />
    </Suspense>
  );
}

DatePicker and other pickers

Same approach for date/time pickers:

const DatePicker = lazy(() => import('@mui/x-date-pickers/DatePicker').then(mod => ({ default: mod.DatePicker })));

function BookingForm() {
  const [showPicker, setShowPicker] = useState(false);

  return (
    <div>
      <Button onClick={() => setShowPicker(true)}>Select Date</Button>
      {showPicker && (
        <Suspense fallback={<Skeleton width={200} height={40} />}>
          <DatePicker />
        </Suspense>
      )}
    </div>
  );
}

This directly improves LCP by reducing initial JavaScript.

Optimize runtime performance

MUI uses Emotion for CSS-in-JS. Styles are computed at runtime, which has overhead.

sx prop performance

The sx prop is convenient but not free. For a single component, the overhead is negligible. But it adds up.

MUI's own benchmark: Rendering 1000 elements with sx adds ~200ms compared to static styles. That's 0.2ms per component - small individually, but significant for lists.

// Slower - sx computed for each item
{items.map(item => (
  <Box key={item.id} sx={{ p: 2, mb: 1, bgcolor: 'grey.100' }}>
    {item.name}
  </Box>
))}

// Faster - styles computed once
const ItemBox = styled(Box)({
  padding: 16,
  marginBottom: 8,
  backgroundColor: '#f5f5f5',
});

{items.map(item => (
  <ItemBox key={item.id}>{item.name}</ItemBox>
))}

Rule of thumb: Use sx for one-off styling. Use styled() for repeated elements or reusable components.

Avoid dynamic sx values in loops

// Bad - creates new style object every render, for every item
{items.map(item => (
  <Box sx={{ color: item.isActive ? 'primary.main' : 'text.secondary' }}>
    {item.name}
  </Box>
))}

// Better - conditional className
const activeClass = { color: 'primary.main' };
const inactiveClass = { color: 'text.secondary' };

{items.map(item => (
  <Box sx={item.isActive ? activeClass : inactiveClass}>
    {item.name}
  </Box>
))}

// Best - use CSS classes for truly large lists

Theme access in sx

Callback functions in sx are invoked on every render:

// Invoked every render
<Box sx={(theme) => ({ color: theme.palette.primary.main })} />

// Static - resolved once
<Box sx={{ color: 'primary.main' }} />

Use the shorthand string syntax ('primary.main') when possible.

Emotion cache setup

MUI uses Emotion for CSS-in-JS, and the order styles are injected into the DOM matters. If you're seeing style conflicts or your custom styles aren't overriding MUI defaults, your Emotion cache might be misconfigured.

Why cache order matters

By default, Emotion appends styles to the end of <head>. MUI expects its styles to load first so your custom styles can override them. Without proper configuration, you end up with specificity battles:

/* MUI styles (loaded later, wins) */
.MuiButton-root { padding: 6px 16px; }

/* Your styles (loaded earlier, loses) */
.MuiButton-root { padding: 12px 24px; }

The fix

Create an Emotion cache with prepend: true:

import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';

const muiCache = createCache({
  key: 'mui',
  prepend: true, // Injects MUI styles at the START of <head>
});

function App() {
  return (
    <CacheProvider value={muiCache}>
      <ThemeProvider theme={theme}>
        {children}
      </ThemeProvider>
    </CacheProvider>
  );
}

Now MUI styles load first, and your styles (loaded later) naturally override them without !important hacks.

Next.js App Router setup

For Next.js with the App Router, you need additional configuration to prevent hydration mismatches:

// app/providers.tsx
'use client';

import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';

export function EmotionCacheProvider({ children }) {
  const [cache] = useState(() => {
    const cache = createCache({ key: 'mui', prepend: true });
    cache.compat = true;
    return cache;
  });

  useServerInsertedHTML(() => {
    return (
      <style
        data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
        dangerouslySetInnerHTML={{
          __html: Object.values(cache.inserted).join(' '),
        }}
      />
    );
  });

  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

This ensures styles are properly serialized during SSR and hydrated on the client without flicker.

MUI and Core Web Vitals

MUI can help or hurt your Core Web Vitals depending on how you use it. Here's how each metric is affected and specific fixes.

LCP impact

MUI affects LCP primarily through JavaScript bundle size. The browser can't render your page until it downloads, parses, and executes your JS. A bloated MUI bundle delays everything.

How MUI hurts LCP:

  • Large initial bundle blocks rendering (300kb+ if imported wrong)
  • Heavy components like DataGrid loaded eagerly
  • Emotion style computation delays first paint

Fix it:

  1. Lazy load below-fold components:
const DataGrid = lazy(() => import('@mui/x-data-grid').then(m => ({ default: m.DataGrid })));
const DatePicker = lazy(() => import('@mui/x-date-pickers/DatePicker').then(m => ({ default: m.DatePicker })));
  1. Use path imports to enable better tree-shaking:
// Each component is a separate chunk that can be split
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
  1. Server-side render critical content - MUI fully supports SSR. Your hero content renders as HTML before JS loads:
// This HTML is visible immediately, even before hydration
<Typography variant="h1">Welcome</Typography>
  1. Preload critical MUI chunks if you know they're needed:
<link rel="modulepreload" href="/_next/static/chunks/mui-core.js" />

CLS impact

MUI components have built-in dimensions, so CLS is usually not a major problem. But there are gotchas:

Common CLS causes with MUI:

  1. Skeleton loaders that don't match final size:
// Bad - skeleton is 40px, actual button is 36px
<Skeleton width={100} height={40} />
<Button>Submit</Button>

// Good - match dimensions exactly
<Skeleton width={64} height={36} variant="rectangular" />
<Button>Submit</Button>
  1. Icons loading after text:
// Bad - button width changes when icon loads
<Button startIcon={<SaveIcon />}>Save</Button>

// Good - reserve space with minWidth
<Button startIcon={<SaveIcon />} sx={{ minWidth: 100 }}>
  Save
</Button>
  1. Dynamic content in Dialogs/Drawers:
// Bad - dialog height jumps when content loads
<Dialog open={open}>
  <DialogContent>
    {loading ? <CircularProgress /> : <LongContent />}
  </DialogContent>
</Dialog>

// Good - set minimum height
<Dialog open={open}>
  <DialogContent sx={{ minHeight: 300 }}>
    {loading ? <CircularProgress /> : <LongContent />}
  </DialogContent>
</Dialog>
  1. Async Autocomplete options:
// Reserve space for dropdown
<Autocomplete
  loading={loading}
  options={options}
  ListboxProps={{ style: { maxHeight: 300 } }}
/>

INP impact

The sx prop's runtime overhead can affect INP if you're re-rendering many components on user interaction. Every keystroke or click that triggers a re-render pays the Emotion computation cost.

How MUI hurts INP:

  • sx prop recalculates styles on every render
  • Large component trees re-render on state changes
  • Unoptimized event handlers block the main thread

Fix it:

  1. Use styled() for components that re-render frequently:
// Bad - sx recalculates on every keystroke
function SearchResults({ query, results }) {
  return results.map(r => (
    <Box key={r.id} sx={{ p: 2, borderBottom: 1 }}>{r.name}</Box>
  ));
}

// Good - styles computed once
const ResultItem = styled(Box)({
  padding: 16,
  borderBottom: '1px solid',
});

function SearchResults({ query, results }) {
  return results.map(r => <ResultItem key={r.id}>{r.name}</ResultItem>);
}
  1. Memoize expensive component trees:
const MemoizedDataGrid = memo(DataGrid);

// Only re-renders when rows or columns actually change
<MemoizedDataGrid rows={rows} columns={columns} />
  1. Debounce rapid interactions:
const debouncedSearch = useMemo(
  () => debounce((query) => setSearchQuery(query), 150),
  []
);

<TextField onChange={(e) => debouncedSearch(e.target.value)} />
  1. Move heavy work out of render:
// Bad - filtering happens on every render
function FilteredList({ items, filter }) {
  const filtered = items.filter(i => i.name.includes(filter)); // Expensive!
  return filtered.map(i => <ListItem key={i.id}>{i.name}</ListItem>);
}

// Good - memoize expensive computation
function FilteredList({ items, filter }) {
  const filtered = useMemo(
    () => items.filter(i => i.name.includes(filter)),
    [items, filter]
  );
  return filtered.map(i => <ListItem key={i.id}>{i.name}</ListItem>);
}

Analyze your bundle

Before optimizing, measure. Guessing at performance problems wastes time. Bundle analysis shows exactly where your bytes come from.

webpack-bundle-analyzer

For Create React App or custom Webpack setups:

npm install --save-dev webpack-bundle-analyzer

Add to your webpack config or use the standalone script:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: true,
    }),
  ],
};

Vite users: rollup-plugin-visualizer

npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({
      open: true,
      gzipSize: true,
      filename: 'bundle-stats.html',
    }),
  ],
};

Run npm run build and a treemap visualization opens automatically.

What to look for

1. Multiple copies of MUI packages: You might see @mui/material appearing multiple times if different dependencies pull in different versions. Fix with npm/yarn resolutions:

// package.json
{
  "resolutions": {
    "@mui/material": "5.15.0"
  }
}

2. The entire icons package: If you see @mui/icons-material taking 50kb+ but only use 5 icons, you're importing wrong. Each icon should be ~1kb.

3. Heavy components in the main chunk: DataGrid, DatePicker, or Charts appearing in your initial bundle means they're not lazy loaded. These should be in separate chunks.

4. Emotion duplicates: Multiple versions of @emotion/react or @emotion/styled bloat your bundle and can cause style conflicts.

Interpreting the treemap

The treemap shows rectangles sized by file size. Look for:

  • Large rectangles = optimization opportunities
  • Unexpected packages = accidental dependencies
  • node_modules dominating = too many dependencies or wrong imports

A healthy MUI app should show:

  • @mui/material: 80-150kb gzipped (depending on components used)
  • @mui/icons-material: 5-20kb gzipped (only icons you use)
  • @emotion/*: 10-15kb gzipped total
  • Your app code: varies, but should be smaller than dependencies

Quick wins checklist

Bundle size

  • Use path imports: import Button from '@mui/material/Button'
  • Use path imports for icons: import Delete from '@mui/icons-material/Delete'
  • Lazy load DataGrid, DatePicker, and other heavy components
  • Run bundle analyzer to find bloat

Dev server speed

  • Switch all barrel imports to path imports
  • Use Next.js optimizePackageImports if available
  • Check for duplicate MUI versions in node_modules

Runtime performance

  • Use styled() instead of sx for repeated elements
  • Avoid dynamic sx values in loops
  • Use string shortcuts ('primary.main') instead of theme callbacks
  • Set up Emotion cache with prepend: true

Frequently Asked Questions

Is MUI too heavy for performance-critical apps?

No, but you need to be intentional. Lazy load what you don't need immediately, use path imports, and avoid sx prop abuse. Many production apps serve millions of users with MUI.

Should I switch to a different component library?

Only if MUI's specific features aren't worth the bundle cost for your use case. Headless libraries like Radix or Headless UI are smaller but require you to style everything yourself. MUI's value is in the complete, accessible components.

Does tree-shaking work with MUI?

Yes, in production builds. But tree-shaking doesn't help dev server speed - that's why path imports matter for DX even though the production bundle would be the same.

Can I use MUI with React Server Components?

MUI components need client-side JavaScript. In Next.js App Router, mark them with 'use client'. You can still use Server Components for layout and data fetching, then render MUI components on the client.

What's next

Most MUI performance problems disappear with two changes: fix your imports and lazy load heavy components. Do these before anything else.

Your action plan:

  1. Search your codebase for from '@mui/material' and from '@mui/icons-material'
  2. Convert to path imports
  3. Lazy load DataGrid, DatePicker, and anything not visible on initial load
  4. Run bundle analyzer to verify the results

Want to pinpoint exactly what's slowing down your MUI app? Run your site through PageSpeedFix to identify the specific issues hurting your Core Web Vitals.