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.

Syntaxegress_GB_per_month = QPS * bytes_per_response * 2.6e6 / 1e9 ; max_connections = worker_processes * worker_connections

Bandwidth 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

Example · javascript
// 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.

Example · javascript
// 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.

Example · javascript
// 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.