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?
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.
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.
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.
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.
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.
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
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
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.
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.













