The Vercel-to-AWS Egress Bill Nobody Warned Us About
A Next.js app on Vercel talking to an AWS Postgres and S3 backend looked clean on paper. Then the data transfer line item showed up. Here's what we learned about egress, regions, and quiet architectural debt.

We shipped a Next.js app on Vercel with its data layer sitting in AWS — RDS Postgres, S3 for media, a couple of Lambda endpoints behind API Gateway. The architecture diagram was tidy, the Lighthouse scores were green, and the first month's bill was fine. Month three is when the data transfer line on the AWS invoice doubled, then doubled again.
This is the postmortem we wish we'd read before we built it.
The setup that looked reasonable
The app was a mid-sized B2B dashboard. Think a few hundred authenticated users, media-heavy (PDF reports, chart exports, some video), and a lot of server-rendered pages pulling fresh data on each request. The split looked like this:
- Frontend + API routes: Vercel, defaulting to
iad1(us-east-1 region). - Primary database: RDS Postgres in
eu-west-1, because the customer data had to live in the EU. - Object storage: S3 buckets in
eu-west-1, same reason. - Auxiliary services: A handful of Lambda functions in
eu-west-1behind API Gateway.
Spot the problem? We did, eventually. Every server-rendered page on Vercel in Virginia was reaching across the Atlantic to Ireland to fetch data, and then streaming S3-hosted PDFs through a Vercel serverless function back to the browser. Each byte paid egress twice — once leaving AWS, once leaving Vercel.
Why it didn't blow up immediately
Vercel's bandwidth allowance on the Pro plan absorbs a surprising amount of early traffic. AWS egress between regions and out to the internet is cheap per GB in isolation. The pain only shows up when both compound: a growing user base, bigger PDFs, more SSR pages, and a bucket policy that lets Vercel proxy everything.
How the bill actually broke down
After three months, we pulled the AWS Cost Explorer and the Vercel usage dashboard side by side. The dominant costs were, in rough order:
- S3 → internet egress (Vercel functions fetching objects to re-serve them)
- RDS → EC2/NAT egress (every SSR query crossed an AZ or region boundary)
- Vercel bandwidth overage (same bytes, counted again on the way out)
- API Gateway data transfer on the Lambda hops
In our experience, this pattern of double-paid egress is the single most common cost trap when teams mix a frontend-as-a-service platform with a self-managed cloud backend. It's not that any one number is huge — it's that four medium numbers stack.
Tracing where the bytes actually go
The first useful thing we did was stop guessing. We tagged every outbound request from Vercel functions with a destination label and shipped it to our observability stack. If you're using OpenTelemetry, something like this is enough to get started:
import { trace, SpanKind } from '@opentelemetry/api';
const tracer = trace.getTracer('egress-audit');
export async function fetchFromAWS(url: string, init?: RequestInit) {
return tracer.startActiveSpan(
'aws.egress',
{ kind: SpanKind.CLIENT, attributes: { 'http.url': url, 'net.peer.name': new URL(url).hostname } },
async (span) => {
const start = Date.now();
try {
const res = await fetch(url, init);
const bytes = Number(res.headers.get('content-length') ?? 0);
span.setAttributes({
'http.status_code': res.status,
'http.response.body.size': bytes,
'egress.duration_ms': Date.now() - start,
});
return res;
} finally {
span.end();
}
},
);
}
Sum http.response.body.size grouped by net.peer.name and you have a bytes-per-destination report that matches your AWS bill within a few percent. We found that roughly 60% of our outbound bytes were S3 objects being proxied through Vercel, not database rows.
That was the aha moment. We weren't database-chatty. We were acting as a very expensive CDN in front of S3.
VPC Flow Logs for the AWS side
On the AWS side, enabling VPC Flow Logs on the RDS subnet for a week gave us the mirror view: which IPs were pulling what volume. Vercel's egress IPs are documented, so filtering flows to those ranges confirmed the cross-region query pattern and the sheer number of connections per minute. If you need a reference for setting this up alongside Terraform-managed infrastructure, we've covered patterns like this on the 72Technologies blog.
The fixes, in order of impact
We didn't rewrite everything. We staged fixes by expected ROI, and the order mattered more than the elegance of any individual change.
1. Stop proxying S3 through Vercel
The biggest single win. We switched to pre-signed URLs generated server-side and let the browser pull directly from S3 (with CloudFront in front). Vercel's role shrank to "produce a short-lived signed URL" — a few hundred bytes instead of a 4 MB PDF.
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'eu-west-1' });
export async function signedReportUrl(key: string) {
const cmd = new GetObjectCommand({ Bucket: 'reports-eu', Key: key });
return getSignedUrl(s3, cmd, { expiresIn: 300 });
}
This alone cut our Vercel bandwidth by over half and S3-to-internet egress by about a third (CloudFront's egress tier is cheaper than raw S3, and caching helped on repeat downloads).
2. Co-locate the SSR compute with the database
Vercel lets you pin serverless functions to specific regions. We moved anything that did heavy database work from iad1 to dub1 (Dublin), matching the RDS region. Latency to end users went up slightly for North American visitors, but SSR response times improved because the query round-trips dropped from ~90ms to ~3ms. Transatlantic egress on the RDS side effectively disappeared.
The tradeoff: your static assets still serve from Vercel's edge globally, so TTFB for the HTML shell was the only thing that got slower, and only for users far from Dublin. For a B2B app whose users were 80% in Europe anyway, this was a trivial price.
3. Introduce an edge cache for read-heavy routes
A chunk of our SSR pages were dashboards that didn't actually need per-request freshness. We added Cache-Control: s-maxage=60, stale-while-revalidate=300 on the routes where a one-minute staleness was acceptable, and let Vercel's CDN absorb the repeat hits. Database egress dropped another meaningful slice without any architectural upheaval.
4. Collapse chatty API calls
Some pages were making 6-8 separate fetch calls from server components to our Lambda endpoints. Each one incurred API Gateway data transfer and a cold-start risk. We consolidated them into a single BFF-style endpoint that did the fan-out inside AWS, where inter-service traffic is free or near-free within the same region.
What we'd do differently from day one
The uncomfortable truth: none of these fixes required exotic tooling. They required us to look at the network topology before we picked the platforms, not after. A whiteboard drawing with arrows labelled by expected byte volume would have caught the S3 proxying anti-pattern in about ten minutes.
A few rules we now apply on every new project that mixes Vercel (or similar) with a cloud backend:
- Draw the data path, not just the service diagram. Where does every byte originate, and where does it exit a billed boundary?
- Default to direct-from-storage for media. Pre-signed URLs, CloudFront, or GCS signed URLs. Never proxy large objects through your SSR layer.
- Co-locate SSR compute with the primary data store. The latency win usually dwarfs the edge-serving loss for authenticated apps.
- Instrument egress from day one. A tiny OpenTelemetry wrapper on outbound fetches costs nothing and tells you the truth before the invoice does.
- Set a billing alarm on data transfer specifically, not just total spend. Egress tends to grow quietly until it doesn't.
Where we'd start
If you're reading this because your own invoice just spiked: don't refactor yet. Spend a day instrumenting outbound bytes by destination, then another day cross-referencing with Cost Explorer or your cloud's equivalent. Nine times out of ten, one route or one asset type dominates, and the fix is a pre-signed URL or a region change — not a rewrite. If you want a second pair of eyes on the architecture before you commit to a direction, our cloud and DevOps team does these audits regularly, and the pattern above shows up more often than it should.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

The Vercel Preview Deployment That DDoSed Our Staging Database
A single PR spun up 40 preview deployments overnight, each opening a connection pool to our staging Postgres. By morning, staging was dead. Here's what went wrong and the three-layer fix we landed on.

AWS NAT Gateway Costs Ate Our Margins: How We Cut Them 80% Without Rewriting the VPC
NAT Gateway data processing charges quietly became the second-largest line item on our AWS bill. Here's the audit, the fix, and the tradeoffs we hit along the way.

GCP Cloud Run Min-Instances vs Startup CPU Boost: Which One Actually Fixes Cold Starts
We ran a Node and a Java service on Cloud Run through both knobs — min-instances and Startup CPU Boost — to see which one actually earns its keep. The answer depends on your runtime, your traffic shape, and how honest you are about idle cost.
