Skip to main content
All writing

Infrastructure / August 21, 2026 / 12 min read

I Left Vercel for a VPS, and the Bill Was Never Vercel's Fault

Day one on Vercel Pro cost $9.02 with fifteen real users, and the cause was a missing function rather than a pricing model. Seven weeks later the on-demand line was $208 a month, so I moved to a $7.92 box. It charged me in a currency I had less of.

Furkan Namlı

First day on Vercel Pro: $9.02. Next day: $1.69. PostHog said I had about fifteen real users that day. Cloudflare said the site served between 17,000 and 51,000 requests.

That gap is the whole story of the first half of this post, and the fifteen users are not the surprising part.

The bill was not a pricing problem

The line items were Image Optimization at $3.38, Cache Writes at $1.43, Fast Origin Transfer at $1.71, and Fluid Active CPU at $1.20. My first read was that Vercel is expensive at scale. That read was wrong, and it took a while to admit it, because "the platform is expensive" is a much more comfortable conclusion than the real one.

The real one: my Incremental Static Regeneration had never worked. Not once, not since launch.

In Next 16, a [param] route only becomes ISR if a generateStaticParams function exists. It can return an empty array. It just has to be there. export const revalidate on its own does nothing, and export const dynamicParams = true does even less than nothing, because it is already the default and reads like a switch you have turned on.

I had both of those on every catalogue route and no generateStaticParams anywhere. So all 200,000-odd pages, artists, albums, tracks, genres, years, charts, ran full server-side rendering on every single request, and answered with Cache-Control: private, no-store, which meant neither Vercel nor Cloudflare could cache any of it. Every crawler hit paid for CPU, an invocation, memory and origin transfer. Then the next crawler hit paid again.

The fix was thirty-four copies of this:

export function generateStaticParams() {
  return [];
}

Nothing is built at build time, every slug renders on first request and is served from cache after that.

The lesson I actually keep from this one is about verification rather than caching. Config exports are not evidence. The build table is: means static or ISR, ƒ means it runs on every request. In production, curl -sI <url> | grep -i x-vercel-cache. I had shipped a first attempt with dynamicParams = true, tested it with a shell variable that was silently empty, accidentally checked the homepage, and concluded it worked.

Then the cost moved instead of leaving

Image optimization went from $4.81 to $0.13. The bill did not go down. It relocated.

Turning SSR into ISR turned every crawler visit into an ISR Write, and ISR Writes became about 70% of the invoice: $2.08 one day, $3.62 the next, the only line climbing. Because revalidate was 3600, a page a bot visited hourly could be rewritten twenty-four times a day.

Two fixes. revalidate to 86400, so an actively crawled page is written once a day rather than twenty-four times. Then the one that actually mattered: a Cloudflare Worker that intercepts anonymous GETs, strips the Vary header, and serves them from caches.default. Bots never reach Vercel's ISR at all.

That last one had to be a Worker, and finding out why cost a day. Next stamps Vary: rsc, next-router-state-tree on App Router HTML, and a proxy.ts header override gets overwritten on an ISR cache hit, because Next re-applies its own headers downstream. Cloudflare will not cache a response with that Vary (it reports DYNAMIC), Transform Rules refuse to touch the Vary header specifically, and Snippets are Pro and up. A Worker owns the response outright, so it is the only layer with no seam for Next to reach through.

I lost most of that day to my own test command. The Worker had a if (method !== "GET") passthrough branch, and I verified everything with curl -sI, which sends HEAD. Every check bypassed the GET path and showed me the raw origin response. The Worker had been working since the first deploy. Real bot traffic is GET, so the behaviour was correct the whole time and only my instrument was wrong.

A header test on method-dependent edge code is curl -s -o /dev/null -D - <url>, not curl -I. And put your own proof header on it, so a hit is unambiguous.

The decision, once the panic was over

The fixes worked and the bill still climbed, because the catalogue kept growing and the crawlers kept finding it. By the end of July the Vercel on-demand line was $208 a month, on top of the $20 plan. ISR Writes alone were $110 of that, Edge Requests $37, Fast Origin Transfer $24.

So the question stopped being "is Vercel expensive" and became "is this workload a good fit for this pricing model". It is not, and that is a specific claim rather than a complaint. Two million ISR pages against per-write billing and metered egress is close to the worst possible match. On a box, those two line items are flat.

The replacement is a VPS at $7.92 a month. Plain SSD rather than NVMe, on purpose, because it only renders on a Cloudflare miss and IOPS was never the binding constraint. I picked amd64 over ARM for the boring reason that sharp breaks.

What made the move cheap was something built for an unrelated reason. Cloudflare already sat in front of everything, and the edge Worker was origin-agnostic: it does fetch(request) and passes through. Point the DNS at the box and the entire edge caching layer moves with it, unchanged.

The cutover took thirty seconds to undo

The plan was to move only the Next app and leave the managed pieces alone. Neon, Meilisearch, R2, Upstash stay where they are. Self-hosting Postgres is the thing most likely to end a solo project, so version one would not touch it.

First cutover, pages took 41 seconds.

The app was now in Europe. Neon was in us-east-1. That is 105 ms between the server and the database, multiplied by the several hundred queries a catalogue page makes. On Vercel it had been invisible, because the functions ran in the same region as the database and I had never had a reason to look.

This is not the same thing as the distance between a user and the server. That one is a single round trip per page and Cloudflare's edge already handles it. This was per query, and it was inside the page render.

DNS went back to Vercel in about thirty seconds, which is the only part of that evening I would repeat without changes. Then I did the thing I had explicitly ruled out and installed Postgres 17.10 on the box, matching Neon's version, and moved the database too.

I did try to keep it managed first. A European Neon instance turned out to be impossible: the account lives in a Vercel-managed organisation, and the API answers organization is managed by Vercel. Leaving the platform meant leaving the database with it, whether or not that was the plan.

The dump took nine minutes, the restore thirty-five seconds, and a catalogue page went from 41 seconds to 0.54. No user data was lost. Fifty-four lazily-loaded track rows stayed behind on Neon and refill themselves on first visit.

If the app moves, the database moves. Managed Postgres in another continent is a decision you make on purpose or not at all.

What the box charged instead

Money went flat. Attention did not.

The next morning, the app started returning internal server errors on login. ECONNREFUSED 127.0.0.1:5432. Postgres was not running. The disk was full and Postgres had shut itself down when it could no longer write WAL. A few hours earlier it had been under half.

Next's filesystem cache handler writes rendered pages into serverDistDir/app, which is inside the release directory. Only FETCH-type entries go to .next/cache. I had symlinked .next/cache to a shared directory and believed I had a persistent ISR cache surviving deploys. What I actually had was a 23 MB directory of fetch results, while every rendered page piled up inside the release.

The catalogue is 2.6 million pages and something was enumerating all of them. A fresh build is 1.3 GB. The live release reached 9.5 GB overnight. Deleting two old releases freed exactly 19 GB.

The fix is a cache-handler.js that runs two instances of Next's own FileSystemCache class rather than reimplementing its storage format. Reads check the shared directory first and fall back to the release, where the build's prerendered pages live. Writes always go to the shared directory. The release went from 9.5 GB to 137 MB and stayed there, and the ISR cache genuinely survives deploys now, which is the thing I had thought I already had.

Then deleting the old release took the site down in a different way. Hundreds of thousands of inodes, and while rm ran, profile pages took 40 to 67 seconds. I ruled out disk because IO wait showed 0%, which was wrong twice over: rm burns CPU too, and instantaneous %wa is a bad instrument. ionice -c 3 and renice 19 took load from 5.4 to 2.3 and profiles back to 1.1 seconds.

A large rm on a production box does not run at normal priority.

The next night, the same crisis, and a better lesson

It happened again the following day, and the site fell over twice overnight.

Nearly all of the used space turned out to be in shared/isr. The first diagnosis had been right. The hours went to refuting it three times, incorrectly, and this is the part of the night worth keeping.

Refutation one: "I deleted isr.old and the disk did not drop, so ISR is not the culprit." The mv isr isr.old had failed silently. The install -d .../isr/server that ran afterwards found the directory already there and only changed its owner, and chown updates ctime, so the directory looked freshly created. isr.old never existed, the rm -rf deleted nothing, and the whole of it sat untouched while I crossed it off the list. The tell was there: isr had a ctime of 14:23 while the server directory inside it had 09:58. A new parent with an old child means it was not created, it was chowned.

Check the exit code of every destructive step. "I deleted it and no space appeared" means it was not deleted, before it means anything else.

Refutation two: half-finished du output counted as evidence. With 5.7 million files, du runs for minutes and hits a timeout, and I kept writing the partial output into the accounted column. shared/isr was never measured at all. The prime suspect was the one blank cell in my own table.

On a full disk, do not walk the tree top-down. Measure candidates in parallel, each into its own file, and let them finish out of order. The small ones return in seconds. The one that never returns is the answer.

Refutation three: I read the wrong column of lsof +L1 and concluded there were no deleted-but-open files. That conclusion happened to be true, for a reason I had not actually established.

The rule underneath all three: when df and du disagree, the question is not which directory is large. It is why they disagree. Mount shadowing, deleted-open files, or a du that never finished. Separate those three first.

There was also a real bug. The pruning script looked for cached pages with -type f, but a cached page is four parts and .segments is a directory. The filter never saw roughly 40% of every page, so no matter how often it ran it could not keep up.

The ceiling is not the number of cores

Once it was stable I wanted to know when to buy a bigger box, so I load-tested the live one with random artist slugs at rising concurrency. The result was not the one I expected: latency climbed while more than half the machine sat idle.

Next standalone runs a single process, and React server rendering is single-threaded. It saturates about one core and the others watch. Adding cores changes nothing at all until you add processes, which is exactly the upgrade I would have bought on intuition and exactly the money it would have wasted.

If you are sizing a self-hosted Next box, measure before you buy, and measure the thing that actually saturates rather than the thing the dashboard shows you.

Was it worth it

Yes, but not for the reason I started.

The money did what I expected: $208 a month of metered line items became a flat $7.92. What I did not price in was that Vercel had been absorbing a category of problem I had never had to think about, and the bill for that arrives as attention, in the middle of the night, in units of "Postgres is down and I do not know why yet."

If you are considering the same move, the honest test is not whether your bill is high. It is whether your bill is high for a reason you understand. Mine was not. I would have carried the broken ISR onto the box and paid for it in disk instead of dollars, which is roughly what happened anyway.

Measure first. Then move, if the workload really is the wrong shape for the pricing. Vercel was not the problem. It was just the place where the problem happened to be visible.