Why Next.js Feels Slow on a VPS — and the Six Things That Fix It
Cloud

Why Next.js Feels Slow on a VPS — and the Six Things That Fix It

The app that felt instant on Vercel goes home to a cheap VPS and forgets how to load fast. The framework didn't change. Six specific things underneath it did, and none of them are next/image.

Published November 28, 202512 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

Why does a Next.js app that felt instant on Vercel suddenly feel slow the moment it moves to a VPS?

Nothing about the framework changes. What disappears is everything Vercel was doing underneath it: a CDN in front of the origin, a warm process that never truly cold-starts, a distributed cache instead of a folder on disk, a proxy tuned for compression and HTTP/2, multiple cores actually in use, and a caching layer between the app and the database. Each of those is a specific, fixable configuration gap — not a reason to blame next/image, which works self-hosted with zero configuration under next start in Next.js 16.

  • No CDN in front of a single origin means every asset makes the full network trip for every visitor, every time
  • ISR's cache is a folder on local disk by default — fine for one instance, a bottleneck if that disk is slow and a correctness problem across more than one
  • A reverse proxy that isn't configured for compression and HTTP/2 ships larger responses over slower connections than it needs to
  • next start runs a single Node process; a 4-vCPU box running one container is using a quarter of what was paid for
  • next/image optimization already works self-hosted with zero config in Next.js 16 — replacing it is solving a problem that doesn't exist

Evidence notes

next/image self-hosted, zero config

Next.js's own self-hosting guide states Image Optimization through next/image works self-hosted with zero configuration when deploying with next start; the one caveat is a sharp memory-allocator setting on glibc-based Linux.

Cache-Control values by asset type

Immutable assets get public, max-age=31536000, immutable. ISR pages get s-maxage=<revalidate>, stale-while-revalidate. Dynamically rendered pages get private, no-cache, no-store, max-age=0, must-revalidate.

Single shared server cache, on-disk by default

Next.js documents that caching and ISR use one shared server cache stored on local disk per instance by default, and that multiple instances need a custom cacheHandler with cacheMaxMemorySize set to 0 to stay consistent.

Reverse proxy is the documented recommendation

Next.js's self-hosting docs recommend running a reverse proxy in front of next start to handle malformed requests, slow-connection attacks, and payload limits, freeing the Next.js process to spend its resources on rendering.

The app that felt instant on Vercel goes home to a $6-a-month VPS and forgets how to load fast. Nothing in the code changed. The framework didn't get slower overnight. What changed is everything underneath it that used to be somebody else's problem, and none of those things disappear just because next start still prints "Ready" in the terminal like everything's fine.

Before the six real causes, one thing that isn't on the list, because it gets blamed constantly and it's wrong: next/image. Next.js 16 optimizes images self-hosted with zero configuration under next start. The old advice — rip out next/image the moment you leave Vercel because image optimization is a Vercel-only feature — describes a framework version that hasn't been true for a while. If someone already tore next/image out of a self-hosted app, they deleted the one part of this stack that was never broken. The single real caveat is glibc-based Linux needing a memory allocator setting for sharp to avoid excessive memory use, which is a config line, not an architecture decision. Much of the judgment self hosting demands shows up as process design, which is what XenGrowth's revenue operations work publishes on.

So what actually got slow?

  1. No CDN sits between visitors and the one server, so every asset — HTML, CSS, JS, images — makes the full network trip to wherever the VPS physically lives, for every visitor, every time.

  2. The first request after a deploy hits a genuinely cold process: no ISR entries warmed, no in-memory route cache built, sometimes not even an OS page cache for the files it's about to read off disk.

  3. The ISR cache is a folder on local disk by default, and disk I/O on a budget VPS is not the storage layer Vercel puts under the same feature.

  4. The reverse proxy in front of the app — if there is one — usually isn't compressing responses or negotiating HTTP/2, so every response ships bigger, over a slower connection setup, than it has to.

  5. next start runs a single Node process. One process pins one core. A 4-vCPU box running one container is spending a quarter of what was paid for and leaving three cores idle.

  6. Every dynamically rendered page makes one more round trip to the database, because nothing marked that response cacheable in the first place.

No CDN: the part Vercel used to do without you noticing

A managed platform puts a CDN in front of every deploy by default. A raw VPS doesn't put anything in front of anything — the domain resolves straight to one IP, and that IP is doing TLS termination, request handling, and rendering all by itself, for a visitor who might be sitting on the other side of the planet from wherever the box is racked. That's not a Next.js problem, it's a geography problem, and no amount of framework-level optimization fixes distance. Cloudflare's free tier puts a CDN edge in front of a self-hosted origin for the cost of a DNS change, which is why it's usually the first fix on this list and the cheapest one.

The part that trips people up isn't adding the CDN — it's that a CDN only helps the responses it's allowed to cache. A dynamic page that ships private, no-cache, no-store, max-age=0, must-revalidate passes straight through, every time, and "I put it behind a CDN and it's still slow" is usually this exact header doing exactly what it's supposed to do. That distinction — and where each cache layer actually sits — gets its own full treatment in browser cache vs CDN cache vs Next.js cache.

Cold starts, and an ISR cache that lives on a slow disk

Caching and ISR share one server cache, stored on local disk by default. That's genuinely fine for a single instance with persistent storage — it's the documented, supported setup. It's also only as fast as the disk underneath it, and the cheap-tier SSD on a budget VPS is a different piece of hardware than whatever backs the same feature on a managed platform. The first request for any given page after a deploy or a cold cache is paying full render cost, then writing that result to disk before the next visitor gets to skip the wait. If the operations side of this is the part you are stuck on, The XenGrowth resource library is the better reference.

Response type

Cache-Control header Next.js sets

What it means for a VPS

Immutable static assets (hashed filenames)

public, max-age=31536000, immutable

Safe to cache anywhere, forever — a CDN handles these with no help needed

ISR / revalidated pages

s-maxage=<revalidate>, stale-while-revalidate

A CDN that respects this serves the stale copy instantly while revalidating in the background; one that doesn't respect it re-fetches from origin every time

Dynamically rendered pages

private, no-cache, no-store, max-age=0, must-revalidate

Never cached by a CDN or the browser, by design — every request is a real render, database call included

Multiple instances make this worse in a specific, documented way, not a vague one: without a custom cache handler, each instance keeps its own on-disk cache, so instance A can serve a stale page for a while after instance B's ISR revalidation has already run. The fix Next.js ships for this is a custom cacheHandler with cacheMaxMemorySize: 0 pointed at shared, durable storage instead of local disk — the in-memory default caps at 50 MB either way, which a real content site will blow past on its own.

The proxy isn't compressing anything, and nobody told it to

Running a reverse proxy in front of next start isn't optional polish — it's the setup Next.js's own docs recommend, specifically so the proxy absorbs malformed requests, slow-connection attacks, and payload limits, leaving the Node process to spend its cycles on rendering instead of request validation. A default nginx or Caddy config sitting between a domain and the app doesn't automatically gzip or brotli-compress responses, doesn't automatically negotiate HTTP/2, and if the app streams anything, doesn't automatically disable proxy buffering — which silently kills the point of streaming Suspense boundaries by holding the whole response until it's complete before forwarding a single byte.

None of that is a Next.js concern; it's proxy configuration, and it varies enough by proxy that it earns its own comparison rather than a paragraph here — see Nginx vs Caddy vs Traefik for self-hosting for which one actually gets this right with the least manual configuration.

One process, one core, three cores doing nothing

next start runs as a single Node.js process, and Node is single-threaded for anything CPU-bound. A 4-vCPU VPS running one container is using one of those four cores under real load and leaving the rest idle — which looks, from the outside, exactly like "the server can't keep up," because under concurrent traffic it genuinely can't, despite the box on paper having plenty of headroom. Sizing the box bigger doesn't fix this; it just buys a bigger idle margin. What actually helps is running multiple instances — several containers behind the same reverse proxy, or a process manager in cluster mode — so more than one core is doing rendering work at once. vCPU or RAM? How to Size a VPS goes into which resource actually runs out first once this is fixed, because it usually isn't CPU anymore. XenGrowth on governed AI marketing workflows works through AI agents and marketing automation in more operational detail.

A database call on every render, because nothing said not to

Dynamic rendering exists for a reason — some pages genuinely need a fresh database read on every request. The problem is when every page is treated that way by default, because nobody explicitly opted a route into ISR or a data cache, and the framework does exactly what it's told: render fresh, hit the database, every single time. If that database is on the same box as the app, this shows up as CPU contention rather than network latency; if it's on a different VPS entirely, it's a round trip over the network on top of the query itself. Either way, the fix isn't a bigger database — it's making the honest call about which pages actually need to be dynamic and letting the rest be cached, which is a decision made in code, not in infrastructure.

Symptom

Actual cause

Fix

First visitor after a deploy waits noticeably longer than the tenth

Cold ISR cache, nothing pre-warmed

Warm critical routes with a request right after deploy, or accept the one-time cost on low-traffic paths

Fast for one visitor, sluggish under concurrent load

Single Node process on a multi-core box

Run multiple instances behind the proxy, or a cluster-mode process manager

Static assets slow for visitors far from the server

No CDN, no edge cache

Put a CDN in front of the origin — Cloudflare's free tier is enough for one app

Still slow behind a CDN

Page is marked dynamic (no-store) or the proxy strips cache headers

Confirm the response is actually cacheable before blaming the CDN

Response is large and slow to start rendering in the browser

Proxy isn't compressing or is buffering a streamed response

Enable gzip/brotli and disable proxy buffering for streamed routes

Every page load hits the database, even ones that rarely change

Nothing opted the route into ISR or a data cache

Cache what's safe to cache; render dynamically only what actually needs it

Which of these six is actually worth fixing first?

Not all six cost the same to fix, and it's worth being honest about that instead of presenting them as a flat checklist. Putting a CDN in front of the origin is a DNS change and takes minutes; it's also the single highest-leverage fix here because it helps every visitor, on every request, immediately. Tuning the reverse proxy for compression and streaming is a config file edit and a restart — a bit more fiddly, still an afternoon at most. Neither of those two requires touching application code at all.

Running more than one process is the first fix that actually costs something in complexity: it means coordinating deploys across instances, and if ISR is in play, it means the disk-backed cache stops being trustworthy the moment there's more than one copy of it. That's the point where a custom cache handler stops being optional infrastructure and starts being a real requirement, not a nice-to-have for later. Fixing the database round-trip issue is the one that touches actual application logic — deciding, page by page, which routes can honestly be cached and which genuinely can't — and it's usually the slowest of the six to get right, because it means auditing what each route actually needs rather than flipping a setting. XenGrowth on building one SEO and GEO content system works through AI search, GEO and discovery in more operational detail.

  • Cheapest and highest impact: put a CDN in front of the origin — this alone often accounts for most of the perceived gap

  • Cheap, moderate impact: configure the reverse proxy for compression, HTTP/2, and non-buffered streaming

  • Moderate cost, moderate impact: warm critical routes right after a deploy instead of waiting for the first real visitor to pay that cost

  • Higher cost, high impact under real concurrent traffic: run multiple app instances and a shared cache handler instead of one process and a local disk cache

  • Highest cost, most durable payoff: go route by route and decide honestly which pages can be cached at all, instead of defaulting everything to dynamic

That ordering is also roughly the order these problems tend to get discovered in, which is not a coincidence. Nobody notices the single-process ceiling until the CDN and the compressed proxy have already stopped being the bottleneck, and nobody bothers auditing which routes actually need to be dynamic until everything upstream of the database is already fast enough that the database call is the last visible delay. Fixing them out of order isn't wrong, exactly — it's just usually a sign that the box was rebuilt around whichever fix was easiest to find on the internet that day, rather than whichever fix the actual bottleneck called for.

A VPS doesn't make Next.js slow. It just stops hiding the six things that were always your job.

Is any of this actually Next.js's fault?

No, and that's the part worth sitting with. Every item on this list is infrastructure that a managed platform bundles in behind the scenes, priced into the bill, and every one of them is something a self-hosted setup can also do — just not for free, and not by accident. That's a fair trade for the price difference, but only if the six things actually get done. Ship a Next.js app to a VPS with none of this configured and it will be slower than the same app on Vercel, and blaming the framework for that is blaming the wrong layer.

Ship it with a CDN, a proxy tuned for compression and streaming, more than one process actually using the box's cores, and a caching strategy that isn't "render everything fresh every time," and the gap mostly closes — not because the framework changed, but because the six things that were quietly making Vercel feel fast got done here too.

Further reading from XenGrowth

Where this work meets go-to-market

Working on self hosting inside a commercial team? XenGrowth's growth engineering practice publishes operator guides on the revenue side of this work.

Why is it slow here and fast there?

Five questions on the gap between a Next.js app on a managed platform and the same app on your own box. Most of it isn't the box.

1 / 5
What does the standalone output mode actually change?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

Next.jsVPSSelf-HostingPerformanceCachingReverse Proxycloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

What Cloudflare's Cache Actually Does for Speed and Bandwidth

Cache HIT and MISS aren't a minor speed difference — they're two different request paths entirely, one of which never reaches your server. Here's the mechanism, the free tier's real limits, and exactly how to check your own hit ratio instead of trusting a number nobody measured.

Navigate

How Far a €5.50 VPS Actually Scales: What the Numbers Say

Nobody's benchmark is your app's benchmark. Instead of quoting a number nobody ran, here's the actual mechanism that caps a 4 vCPU / 8 GB box, how caching changes the arithmetic by orders of magnitude, and exactly how to load-test your own workload.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate

My Rule for Deciding What to Self-Host and What to Keep Paying For

Self-hosting everything is a bad idea, and I can point to the exact service where I decided that on purpose. Here's the actual rule I use, not a survey of options — and the one counterexample that explains why the rule exists.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate

Moving a SaaS Off Vercel to a €5.50 VPS: What the Numbers Actually Look Like

Nobody publishes their real Vercel invoice, so most migration posts trade in vibes instead of arithmetic. This one builds the comparison from Vercel's and Contabo's own published rates, states every assumption out loud, and shows exactly where the two lines cross.

Navigate

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

Navigate

Should Your Database Live on the Same VPS as Your App?

The pitch for co-location is real: no network hop, no egress bill, one box to back up. So is the failure mode — one OOM event takes the app and the database down together, because they were never separate to begin with.

Navigate