DEV Community

Cover image for Why Your Next.js App Is Slower Than Your Old Express Server (And How to Actually Fix It)
Emma Schmidt
Emma Schmidt

Posted on

Why Your Next.js App Is Slower Than Your Old Express Server (And How to Actually Fix It)

You migrated to Next.js for performance. Six months later your Time to First Byte is worse than the monolith you replaced. You're not alone, and you're not doing anything obviously wrong. You just hit the problem nobody warns you about until it's in production.

Here's the real issue, why it happens, and the actual fix.

The Problem Nobody Talks About in the Marketing Docs

Server Components were supposed to make everything faster by default. In practice, a huge number of teams ship apps where every navigation triggers a full server round trip because of one misunderstanding: not knowing where the client/server boundary actually sits in their component tree.

The symptom looks like this. You add "use client" to fix a hydration error, and suddenly half your tree that should've stayed on the server is now shipping JavaScript to the browser and re-fetching data client side. Your bundle grows, your TTFB stays high, and you have no idea why because the app "looks" like it's using Server Components correctly.

Step 1: Actually Audit Your Client Boundary

Most people guess where their client components start. Stop guessing.

npx next build
Enter fullscreen mode Exit fullscreen mode

Look at the build output. Every route shows you whether it's static, dynamic, or server rendered. If routes you expected to be static are showing up dynamic, that's your first clue something upstream is forcing the whole tree client side.
Route (app) Size First Load JS
┌ ○ / 142 B 87.3 kB
├ ● /dashboard 5.2 kB 194 kB
└ ƒ /dashboard/[id] 8.1 kB 201 kB

ƒ means server rendered on demand. If you expected static () and you're seeing ƒ, something in that tree is forcing dynamic rendering, often a single misplaced "use client" directive or an uncached fetch call.

Step 2: Find the Actual Culprit

The most common offender is putting "use client" at the top of a file that contains both interactive AND static content, instead of isolating just the interactive piece.

Bad:

"use client"

export default function Dashboard({ data }) {
  return (
    <div>
      <StaticHeader title={data.title} />
      <StaticChart data={data.chartData} />
      <InteractiveFilters onFilter={handleFilter} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This forces the entire component, including the static header and chart, to render client side, even though only the filters need interactivity.

Fixed:

// Dashboard.jsx - stays a server component
export default function Dashboard({ data }) {
  return (
    <div>
      <StaticHeader title={data.title} />
      <StaticChart data={data.chartData} />
      <InteractiveFilters /> {/* this one is "use client" */}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
// InteractiveFilters.jsx
"use client"

export default function InteractiveFilters() {
  const [filter, setFilter] = useState('all')
  return <FilterControls onChange={setFilter} />
}
Enter fullscreen mode Exit fullscreen mode

Isolate the interactivity. Don't let one client component drag its parent tree down with it.

Step 3: Fix Your Caching Strategy Explicitly

The second biggest cause of slow Next.js apps is relying on default fetch caching behavior without understanding what it's actually doing. The caching defaults have changed across versions, and teams running mixed environments often have inconsistent behavior between dev and prod without realizing it.

Be explicit instead of trusting defaults:

// Force static caching for data that rarely changes
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 } // revalidate every hour
})

// Force dynamic for genuinely real-time data
const liveData = await fetch('https://api.example.com/stock-price', {
  cache: 'no-store'
})
Enter fullscreen mode Exit fullscreen mode

Audit every fetch call in your app. If you can't explain why it's cached the way it is, that's a bug waiting to surface under load.

Step 4: Measure, Don't Assume

Add real instrumentation instead of guessing where the slowness is coming from.

export async function generateMetadata() {
  const start = performance.now()
  const data = await getData()
  console.log(`Data fetch took ${performance.now() - start}ms`)
  return { title: data.title }
}
Enter fullscreen mode Exit fullscreen mode

Run this against your actual production data volume, not your local dev seed data. A lot of these problems only show up when payload sizes are realistic.

When This Gets Genuinely Hard

Once your app grows past a handful of routes, this stops being a quick audit and becomes an architecture problem: deciding which routes should be edge rendered, which need true server-side data fetching, and where your data layer needs restructuring entirely. That's usually the point where teams bring in dedicated engineering support with real production Next.js experience, since custom software development and performance optimization work at that scale needs more than a checklist, it needs someone who's debugged this exact caching and boundary mess before across different production environments.

The Real Lesson Here

Next.js isn't slow. Unaudited client boundaries and default caching assumptions are slow. The framework gives you the tools to be fast by default, but only if you're deliberate about where the server/client line sits in your tree.

Run the build output check from Step 1 right now on your own app. I'd bet you find at least one route rendering dynamic that has no reason to be.

What's your TTFB looking like after auditing this? Drop your build output in the comments, curious how common this actually is across different setups.

Top comments (0)