Bandwidth, Connections and Cost
Egress bills, connection counts against file descriptors, and the point where a design is affordable but not cheap enough to ship.
egress_GB_per_month = QPS * bytes_per_response * 2.6e6 / 1e9 ; max_connections = worker_processes * worker_connectionsBandwidth and connection counts are capacity limits like any other, and cost is a constraint a reviewer will actually enforce. A design can be correct and fast and still be rejected for spending a six-figure monthly sum on bytes leaving the building.
Bandwidth
1 Gbps is 125 MB/s; 10 Gbps is 1.25 GB/s. Multiply response size by QPS and compare. Two thousand requests per second of 200 KB responses is 400 MB/s, which exceeds a 1 Gbps link before anything else about the design matters.
Egress
Internet data transfer out of AWS us-east-1 is free for the first 100 GB a month, then $0.09/GB for the first 10 TB, $0.085 for the next 40 TB, $0.07 for the next 100 TB and $0.05 above 150 TB. Traffic between availability zones inside one region is $0.01/GB in each direction, so a gigabyte sent to a replica in another AZ is billed twice. Cloudflare R2 charges nothing for egress, with standard storage at $0.015/GB-month against S3 Standard's $0.023/GB-month. For an image or video product that difference is the design, not an optimisation.
Connections
Every connection is a file descriptor. nginx defaults to worker_processes 1 and worker_connections 512, and that count includes connections to proxied servers, so a reverse proxy spends two per client; worker_rlimit_nofile raises the per-worker descriptor limit. Outbound, Linux's default ephemeral range of 32768-60999 gives 28,232 ports per source-address, destination-address, destination-port tuple.
Example
// 86,400 * 30 = 2,592,000 seconds in a 30-day month. Round to 2.6e6.
const SECONDS_PER_MONTH = 2.6e6;
// AWS data transfer out to the internet, us-east-1 list price, per GB.
// The first 100 GB each month is free.
const EGRESS_TIERS = [
{ upToGB: 10_000, usdPerGB: 0.09 }, // first 10 TB
{ upToGB: 50_000, usdPerGB: 0.085 }, // next 40 TB
{ upToGB: 150_000, usdPerGB: 0.07 }, // next 100 TB
{ upToGB: Infinity, usdPerGB: 0.05 }, // above 150 TB
];
function egressCost(gbPerMonth) {
let billable = Math.max(0, gbPerMonth - 100); // 100 GB/month free
let consumed = 0;
let usd = 0;
for (const tier of EGRESS_TIERS) {
const inTier = Math.min(billable, tier.upToGB - consumed);
if (inTier <= 0) break;
usd += inTier * tier.usdPerGB;
billable -= inTier;
consumed = tier.upToGB;
}
return usd;
}
const monthlyEgressGB = ({ qps, bytesPerResponse }) =>
(qps * bytesPerResponse * SECONDS_PER_MONTH) / 1e9;
// 2,000 req/s of 200 KB responses, served straight from origin:
const gb = monthlyEgressGB({ qps: 2_000, bytesPerResponse: 200_000 });
gb; // 1,040,000 GB = 1.04 PB per month
egressCost(gb); // ~$55,795 per month
// That number is why "put a CDN in front of it" is a cost decision and not a
// performance one. At 95% offload the origin egress drops by 20x - though the
// CDN then bills for its own delivery, so compare, do not assume.When to use it
- Pricing the egress bill for a media-heavy feature before committing to a cloud provider.
- Explaining why a design that reads 200 KB per request cannot be served from one 1 Gbps origin.
- Sizing worker_connections and worker_rlimit_nofile for a reverse proxy before it starts dropping connections.
- Diagnosing intermittent connection failures from a service that holds many sockets to one upstream.
More examples
Connections against file descriptors, both ends
The arithmetic that connects a config number to an operating system limit. Use it whenever a proxy or long-lived-connection service is sized, because worker_connections alone is a promise the kernel has not agreed to.
// nginx upstream defaults: worker_processes 1, worker_connections 512.
// The 512 includes connections to proxied servers, not just clients - so as a
// reverse proxy each client costs TWO, one inbound and one to the upstream.
const nginx = { workerProcesses: 8, workerConnections: 16_384 };
const totalConnections = nginx.workerProcesses * nginx.workerConnections; // 131_072
const maxProxiedClients = totalConnections / 2; // 65_536
// Those descriptors must actually exist. Raise the per-worker limit to at
// least twice worker_connections:
//
// worker_processes 8;
// worker_rlimit_nofile 32768; # >= 2 * worker_connections
// events { worker_connections 16384; }
//
// Miss worker_rlimit_nofile and nginx stops at the inherited soft limit -
// often 1024 - and logs "too many open files" while worker_connections
// claims capacity it cannot use.
// The same ceiling applies to any process holding sockets: a Node service
// with 20k open WebSockets needs its own ulimit -n raised, and each socket
// is one descriptor on top of files, logs and DB pool connections.Outbound port exhaustion on one upstream
A ceiling that is invisible until a service crosses it, then looks like a network fault. Reach for this when connection errors appear under load from a client that creates a connection per request.
// A TCP connection is identified by the four-tuple
// (source IP, source port, destination IP, destination port).
// From one pod IP to one upstream IP:port, the only variable is source port.
const EPHEMERAL_LOW = 32_768; // Linux default: net.ipv4.ip_local_port_range
const EPHEMERAL_HIGH = 60_999;
const maxOutbound = EPHEMERAL_HIGH - EPHEMERAL_LOW + 1; // 28_232
// Worse in practice: a closed connection sits in TIME_WAIT for 2*MSL (60s on
// Linux) still holding its port. A service opening a fresh connection per
// request to one upstream burns ports at its request rate:
const portsHeld = (requestsPerSecond, timeWaitSeconds = 60) =>
requestsPerSecond * timeWaitSeconds;
portsHeld(1_000); // 60,000 ports needed - already over the 28,232 available
portsHeld(400); // 24,000 - surviving, with no margin
// Fixes, in order of preference:
// 1. keep-alive connection pooling, so ports are reused instead of churned
// 2. more destination addresses (several upstream IPs multiplies the tuple)
// 3. widen ip_local_port_range - the widest safe setting, 1024-65535, is
// 64,512 ports, about 2.3x the default, and still only treats the symptom
// This is why an HTTP client with no connection pool fails suddenly at scale
// rather than degrading: you are not out of CPU, you are out of port numbers.Affordable, but not cheap enough to ship
Shows the shape of the argument when a design is rejected on cost rather than on correctness. Use it when a feature's economics depend on bytes delivered, and put the egress line on the diagram alongside the boxes.
// Short-video feature: 1 million views a day, 15 MB per view.
const viewsPerDay = 1e6;
const bytesPerView = 15e6;
const gbPerMonth = (viewsPerDay * bytesPerView * 30) / 1e9; // 450,000 GB = 450 TB
// Option A: serve from S3 in us-east-1.
// storage, 50 TB of masters at $0.023/GB-month ......... $1,150
// egress, 450 TB: [email protected] + [email protected] + [email protected] +
// [email protected] ........................... ~$26,300
// total ................................................ ~$27,450/month
// Option B: serve from Cloudflare R2.
// storage, 50 TB at $0.015/GB-month .................... $750
// egress ............................................... $0
// total ................................................ ~$750/month
// Both work. Both are affordable for a funded company. One is 36x the other,
// and no amount of application-level tuning closes a 36x gap - which is the
// whole point: this is a storage-provider decision taken at design time, and
// it is nearly impossible to reverse once terabytes have accumulated.
//
// Do not stop here. Check request pricing too: S3 Standard bills $0.005 per
// 1,000 PUTs and $0.0004 per 1,000 GETs, so a workload of billions of tiny
// objects can be dominated by request count rather than by bytes.
Discussion