Cutting a Vercel Bill in Half: What Actually Moved the Needle
A client's Vercel invoice tripled in a quarter. We spent two weeks tearing apart their Next.js app and shaved roughly 55% off the bill. Here's what mattered, what didn't, and what we'd do differently.

A client pinged us in Q3 with a screenshot of their Vercel invoice. It had gone from around $900/month to just over $3,100 in twelve weeks, with no comparable traffic spike. Two weeks of work later, the bill was sitting under $1,400 and the app was faster. This is what actually moved the needle — and what we wasted time on.
The setup, briefly
The app was a mid-sized B2B Next.js 14 project on the App Router, deployed to Vercel's Pro plan. Roughly 400k monthly active users, a mix of marketing pages, an authenticated dashboard, and a media-heavy product catalog. The team had migrated from Pages Router to App Router about six months earlier and adopted Server Components enthusiastically, which is relevant later.
The invoice breakdown looked roughly like this when we started:
- Function invocations & duration: ~38%
- Image Optimization: ~24%
- Edge Middleware invocations: ~14%
- Bandwidth: ~12%
- Build minutes & everything else: ~12%
If you don't know where your money is going on Vercel, stop reading and go open the Usage tab. The single biggest mistake we see is teams optimizing the wrong line item because they assumed bandwidth was the problem. It almost never is.
Where the money was actually going
Server Components calling APIs on every request
The team had built dozens of Server Components that did fetch() calls to internal APIs without any caching directives. In Next.js 14, the default fetch behavior shifted around — depending on your config, you can end up with dynamic rendering on routes you thought were static. A marketing page that should have been served from cache was instead invoking a serverless function on every visit, hitting an API, and rendering on the fly.
We found this by exporting the Vercel function logs and grouping invocations by route. The top five routes accounted for 71% of all function duration, and four of them had no business being dynamic.
The fix was unglamorous:
// app/products/[slug]/page.tsx
export const revalidate = 3600; // 1 hour ISR
async function getProduct(slug: string) {
const res = await fetch(`${process.env.API_URL}/products/${slug}`, {
next: { revalidate: 3600, tags: [`product:${slug}`] },
});
if (!res.ok) throw new Error('Product fetch failed');
return res.json();
}
We paired this with on-demand revalidation from the CMS webhook so editors didn't have to wait an hour for changes. Function invocations on those routes dropped by something like 90% within 48 hours.
Edge Middleware doing too much
The middleware ran on every request, including static assets in some configurations, and was doing geo-based redirects, A/B test assignment, and auth checks. Each invocation is cheap individually, but at scale it adds up fast — and edge middleware invocations are billed separately from function invocations.
We did two things:
- Tightened the
matcherconfig to exclude_next/static, images, and anything in/api/public/*. - Moved the A/B test cookie assignment to a client-side script for non-critical experiments. Auth and geo stayed in middleware because they actually need to gate the response.
export const config = {
matcher: [
'/((?!api/public|_next/static|_next/image|favicon.ico|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|css|js)$).*)',
],
};
Middleware invocations dropped roughly 60%.
The image optimization trap
This is the one that surprised us. Vercel's Image Optimization is billed per source image transformed, not per request served. So if you have a product catalog with 50,000 images and a thumbnail grid that requests 8 different sizes for each, you can blow through your included quota in days even with low traffic.
What we changed
First, we audited the actual sizes prop on every <Image> component. The team had been copy-pasting sizes="100vw" everywhere, which forces the optimizer to generate every size in the deviceSizes config. We tightened these to match real layout breakpoints.
Second — and this is the bigger lever — we moved the product catalog images to Cloudflare R2 with Cloudflare Images for transformations. Vercel's image optimization is genuinely good, but for a 50k-image catalog that rarely changes, paying per unique source image doesn't make sense. We kept Vercel's optimizer for editorial and dynamic content where the convenience is worth it.
For the user-uploaded avatars and editorial hero images that stayed on Vercel, we added a loader config to enforce a single quality setting and a constrained size list:
// next.config.js
module.exports = {
images: {
deviceSizes: [640, 828, 1200, 1920],
imageSizes: [64, 128, 256],
formats: ['image/webp'],
minimumCacheTTL: 2_592_000, // 30 days
},
};
The minimumCacheTTL bump alone reduced repeat transformations significantly. The default is 60 seconds in older Next.js versions, which is absurd for content that rarely changes.
Image Optimization costs dropped about 70%.
What didn't work (or didn't matter)
We spent about two days on things that turned out to be rounding errors:
- Bundle size optimization. We shaved ~80KB off the client bundle. Faster for users, sure, but bandwidth was 12% of the bill. The actual cost savings were trivial.
- Build minute reduction. We toyed with Turborepo remote caching tweaks. Builds got faster, which the team appreciated, but build minutes were never a meaningful line item on Pro.
- Switching some routes to Edge Runtime. We tried this expecting cost wins. Edge functions can be cheaper for short, simple responses, but for anything doing meaningful work — especially with database calls over a non-edge-friendly driver — you trade cold start improvements for higher per-invocation cost and worse cold connection behavior. We reverted most of these.
The Edge Runtime thing deserves a sharper note: don't migrate routes to edge because a blog post said it was faster. Measure. We had two routes where edge made sense (geo-aware redirects, a lightweight personalization endpoint) and four where it actively made things worse.
Observability is non-negotiable
We couldn't have done any of this without proper instrumentation. The Vercel dashboard tells you what's expensive in aggregate; it doesn't tell you which Server Component is causing a route to go dynamic, or which middleware branch is firing most.
We wired up OpenTelemetry through the @vercel/otel package and shipped traces to a self-hosted Grafana Tempo instance. Sentry was already in place for errors, but we added performance monitoring on the same routes we suspected were hot. Within a day we had a flame graph showing exactly which fetch calls were blocking which Server Components, and on which routes.
If you're operating at any scale on Vercel without distributed tracing, you're flying blind. The platform abstracts away too much for guesswork to be efficient.
If you want a deeper look at how we set up tracing on serverless platforms, we've written about that approach on our blog.
The tradeoffs nobody mentions
Vercel's pricing model rewards static and ISR-heavy apps. It punishes you for treating it like a generic serverless platform that happens to host Next.js. If your app is mostly authenticated, mostly dynamic, and mostly doing heavy server-side work, you should genuinely evaluate whether Vercel is the right host, or whether you're better off on a container platform with Cloud Run, Fly.io, or ECS Fargate. The developer experience tax of leaving Vercel is real, but so is a $3,000 monthly invoice that should be a $400 one.
For this client, Vercel still made sense after the cleanup. The marketing surface area benefits enormously from the platform, and the dashboard is dynamic enough to be interesting but not so dynamic that container hosting would have been cheaper after factoring in our time to operate it.
Where we'd start
If you're staring at a Vercel bill that feels wrong, do these four things in this order before you change anything:
- Open the Usage tab and write down the percentage breakdown by line item. Optimize the biggest line first.
- Export function logs and group invocations by route. Find the top 10 routes by duration × invocations. That's your list.
- Audit
revalidateand fetch caching on every dynamic-looking route. Most teams have at least three routes that should be ISR but aren't. - Check your middleware matcher and your
<Image>sizesprops. These are the two highest-leverage low-effort fixes we see, almost every time.
If you want help running this audit on a production Next.js app, that's the kind of work our DevOps and cloud team does regularly. But honestly, most teams can do 80% of this themselves in a week if they're willing to actually read the invoice.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a project