All ArticlesDevOps & Cloud•October 29, 2026•11 min read

Postgres Egress Cost Optimization: Cutting Network Bandwidth by 70% in High-Throughput APIs

Most high-throughput Postgres bills are dominated by bytes you never needed to send — wide SELECT * projections, detoasted JSONB, and text-format wire payloads. This is the measurement-and-fix playbook that cut database egress 72% on a 1.8M-request/day API.

From below of long thin blue cables connected to row of small white connectors on system block in data centerPhoto by Brett Sayles on Pexels

Every high-throughput Postgres deployment eventually produces a line item that surprises the team: data transfer. Not storage, not CPU, not IOPS — the bytes your database pushed over a socket to satisfy queries that returned far more than anyone consumed. In one representative workload we audited (1.8M requests/day across 14 hot endpoints), database egress ran 41.6 TB/month. Six weeks of measured, surgical changes brought it to 11.7 TB/month — a 72% reduction — while request volume grew 34%.

This article is the playbook: how to measure wire bytes accurately, why SELECT * is an egress weapon, how TOAST and the wire protocol conspire against you, and the seven levers that compound into a step change.

Two Budgets, Not One

The single most common analytical error is conflating two distinct billing surfaces.

The DB → app budget

Every byte Postgres writes to a client socket. This is what managed providers meter: Neon's data transfer line item, Amazon RDS/Aurora inter-AZ and internet transfer, Supabase egress. Critically, it is uncompressed by default. The PostgreSQL v3 frontend/backend protocol has no general-purpose result-set compression, and TLS-level compression is disabled in OpenSSL and every mainstream server stack because of the CRIME attack class.

The app → internet budget

Bytes your API returns to clients. This one you can Brotli, ETag, and push to a CDN.

Most teams optimize the second budget and are then blindsided by the first, because no amount of gzip at the edge removes a single byte from the Postgres socket. If your API tier sits in a different AZ or region than the database, you are paying for uncompressed, un-cacheable, per-row-framed traffic on every single request.

Step 1: Measure Wire Bytes, Not Query Time

You cannot manage this without a byte counter. Latency observability tells you nothing about volume: a 4 ms query returning 3 MB of JSONB is invisible to p99 dashboards.

Per-query byte accounting from the driver socket

node-postgres exposes the underlying socket. Snapshot bytesRead around each query and you get ground truth including protocol framing:

ts
// src/db/measured-pool.ts
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from 'pg';
import type { Socket } from 'node:net';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  keepAlive: true,
  statement_timeout: 15_000,
});

export async function measuredQuery<R extends QueryResultRow>(
  label: string,
  text: string,
  params: readonly unknown[] = [],
): Promise<QueryResult<R>> {
  const client: PoolClient = await pool.connect();
  try {
    // node-postgres stores the net/tls socket at client.connection.stream
    const socket = (client as unknown as { connection?: { stream?: Socket } })
      .connection?.stream;
    const startBytes = socket?.bytesRead ?? 0;
    const startNs = process.hrtime.bigint();

    const result = await client.query<R>(text, params);

    const wireBytes = (socket?.bytesRead ?? 0) - startBytes;
    const durationMs = Number(process.hrtime.bigint() - startNs) / 1e6;

    // emit() pushes to your metrics pipeline (OTel counter: db.wire_bytes)
    emit(label, { rows: result.rowCount ?? 0, wireBytes, durationMs });
    return result;
  } finally {
    client.release();
  }
}

Two caveats worth internalizing. First, bytesRead on a TLSSocket counts post-handshake traffic — but the first query on each new connection absorbs the certificate chain exchange. A three-cert RSA-2048 chain is roughly 3.5 KB; at 50 connection-churns/second that alone is ~1.3 TB/month. Exclude warm-up queries from averages and separately track new_connections/sec. Second, bytesRead includes RowDescription, CommandComplete, and ReadyForQuery frames, which is exactly what you want — that is what the invoice reflects.

Estimating bytes in SQL before you deploy

You rarely need to run a query to know it is expensive. Emulate the text-format wire size of a candidate projection:

sql
-- Approximate v3 text-format wire cost for a proposed result set
WITH q AS (
  SELECT id, customer_id, status, created_at, metadata
  FROM orders
  WHERE tenant_id = $1 AND created_at >= now() - interval '1 day'
  LIMIT 5000
),
sized AS (
  SELECT
    11                                   -- DataRow: 1 type + 4 length + 2 ncols
    + 4 * 5                              -- 4-byte length prefix per column
    + octet_length(id::text)
    + octet_length(customer_id::text)
    + octet_length(status::text)
    + octet_length(created_at::text)
    + coalesce(octet_length(metadata::text), 0) AS row_bytes
  FROM q
)
SELECT count(*)                               AS rows,
       round(avg(row_bytes))                  AS avg_row_bytes,
       pg_size_pretty(sum(row_bytes)::bigint) AS sampled_payload
FROM sized;

Now drop metadata from the CTE and run it again. In our audit, that single edit moved avg_row_bytes from 1,940 to 268 — before any other change.

Why pg_stat_statements will not save you

pg_stat_statements tracks calls, rows, shared_blks_*, and timing. It does not track bytes sent to the client. Use it to find high-frequency queries; use the socket counter to find high-volume queries. They are frequently different queries, and the high-volume one is the one on your invoice.

Reconcile monthly: sum db.wire_bytes from telemetry, compare against the provider's reported data transfer, and investigate any drift beyond ~10% (drift usually means a second workload — a BI tool, a replica, a CDC consumer — you forgot about).

Step 2: Kill Query Overfetching at the Projection Layer

Query overfetching is the dominant term in almost every egress bill. A 12-column table where the API serializes 4 fields, selected via SELECT * because the ORM defaulted to it, ships 3× the necessary bytes and 100% of the TOASTed columns nobody reads.

ts
// BAD: ORM default — every column, including 1.2 KB of JSONB
const orders = await prisma.order.findMany({ where: { tenantId }, take: 500 });

// GOOD: an explicit projection contract
const orders = await prisma.order.findMany({
  where: { tenantId },
  select: { id: true, status: true, totalCents: true, createdAt: true },
  take: 100,
});
ts
// Drizzle: projection is structural, so it survives refactors
const rows = await db
  .select({
    id: orders.id,
    status: orders.status,
    totalCents: orders.totalCents,
    createdAt: orders.createdAt,
  })
  .from(orders)
  .where(eq(orders.tenantId, tenantId))
  .orderBy(desc(orders.createdAt), desc(orders.id))
  .limit(limit);

Enforce it mechanically. Add a lint rule or CI grep that fails on SELECT * in application code, and add a contract test that asserts the exact column set returned by each hot endpoint — otherwise a well-meaning refactor silently reintroduces a 1.2 KB column.

Step 3: Stop Shipping TOAST — Shape Payloads Server-Side

Here is the fact that reframes the whole problem: pg_column_size() reports the stored size of a datum. TOASTed values are reported at their compressed, out-of-line size. But the v3 protocol sends the fully detoasted, decompressed value. A 100 KB JSON document that compresses to 12 KB on disk still costs ~100 KB of egress on every row that includes it.

Audit which columns are the fat ones:

sql
SELECT pg_size_pretty(sum(pg_column_size(metadata))::bigint) AS metadata_stored,
       pg_size_pretty(sum(pg_column_size(notes))::bigint)    AS notes_stored,
       count(*)                                              AS rows
FROM orders;

Then stop sending the document at all. Two production patterns:

Generated columns for hot JSON paths. The API needs payload->>'plan_tier' — so precompute and index it, and never project payload:

sql
ALTER TABLE events
  ADD COLUMN plan_tier text GENERATED ALWAYS AS (payload ->> 'plan_tier') STORED;

CREATE INDEX CONCURRENTLY idx_events_plan_tier ON events (plan_tier);

Collapse N rows into one `json_agg` row. This eliminates N−1 DataRow headers and often 30–40% of framing overhead for small rows:

sql
SELECT json_agg(row_to_json(o) ORDER BY o.created_at DESC) AS orders
FROM (
  SELECT id, status, total_cents, created_at
  FROM orders
  WHERE tenant_id = $1
  ORDER BY created_at DESC, id DESC
  LIMIT $2
) o;

Trade-off: this materializes the whole result server-side and forfeits streaming. Cap $2 in the low thousands and never use it for unbounded exports — use COPY ... TO STDOUT for bulk extraction instead.

Step 4: Bound the Result Set — Keyset Pagination and Hard Caps

OFFSET 50000 LIMIT 500 is an egress and latency weapon: the server produces and discards 50,000 rows, then ships 500. Keyset pagination ships only what the client consumes, with a stable ordering that survives concurrent writes:

sql
-- Requires: CREATE INDEX CONCURRENTLY idx_orders_tenant_created_id
--             ON orders (tenant_id, created_at DESC, id DESC);
SELECT id, status, total_cents, created_at
FROM orders
WHERE tenant_id = $1
  AND (created_at, id) < ($2::timestamptz, $3::bigint)
ORDER BY created_at DESC, id DESC
LIMIT $4;  -- application cap: 100

Pair it with belt-and-braces server guardrails so a buggy client cannot stream a table into the network:

sql
SET LOCAL statement_timeout = '5s';
SET LOCAL work_mem = '16MB';
-- and a hard ceiling inside the query:  LIMIT LEAST($4, 100)

On our workload, capping page size from 500 to 100 and switching to cursor pagination removed 3.2 TB/month. Not because clients read less data — they read the same — but because the previous behavior was over-fetching 5× per view and re-fetching on every deep page.

Step 5: Shrink the Protocol — Binary Format and Type Discipline

The v3 protocol supports two result formats per column. Text is human-readable and, for numerics and timestamps, dramatically larger than the machine representation:

TypeText formatBinary format
bigintup to 19 B8 B
integerup to 10 B4 B
double precisionup to ~24 B8 B
timestamptz26–35 B8 B
uuid36 B16 B
boolean1 B1 B

The pg driver (node-postgres) requests text-format results. pgx (Go) defaults to binary for known types. Moving a numeric- and timestamp-heavy endpoint from text to binary typically saves 15–25% of payload — in our case 0.9 TB/month across the migrated services.

Type discipline complements it: store uuid as uuid (16 B binary) rather than text; use timestamptz rather than text; prefer integer over numeric when the domain allows. And do not confuse row shapes with wire size — rowMode: 'array' in pg reduces JavaScript allocation but changes zero bytes on the socket.

Step 6: Collapse Round Trips

Every round trip carries a Parse/Bind/Execute/Sync cycle, a RowDescription frame, and a ReadyForQuery frame. N+1 access patterns multiply all of it.

ts
// BAD: 1 + N round trips, each returning a full row
for (const id of orderIds) {
  const o = await measuredQuery('order_by_id', 'SELECT * FROM orders WHERE id = $1', [id]);
}

// GOOD: one round trip, one narrow projection
const { rows } = await measuredQuery(
  'orders_by_ids',
  'SELECT id, status, total_cents FROM orders WHERE id = ANY($1::bigint[])',
  [orderIds],
);

Use named/prepared statements for repeated shapes to eliminate redundant Parse traffic, and — the non-obvious one — kill connection churn. Put PgBouncer (or Neon's pooled -pooler endpoint) in front of the database in transaction mode. Serverless functions that open a TLS connection per invocation pay several kilobytes of certificate and handshake overhead per request; that cost is pure egress and pure latency.

Step 7: Cache and Compress at the Edge — Not in the Database

This is where the app→internet budget gets fixed, and where 304s are your best friend: they send headers and zero body.

ts
// server.ts — Fastify with Brotli + weak ETags (requires ESM top-level await)
import Fastify from 'fastify';
import compress from '@fastify/compress';
import etag from '@fastify/etag';
import { constants as zlibConstants } from 'node:zlib';

const app = Fastify({ logger: true, keepAliveTimeout: 72_000 });

await app.register(compress, {
  global: true,
  threshold: 1024,                                  // skip tiny payloads
  encodings: ['br', 'gzip', 'deflate'],
  brotliOptions: {
    params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 }, // latency-tuned, not q11
  },
});

await app.register(etag, { weak: true });

app.get('/v1/orders', async (request, reply) => {
  const { cursor, limit } = parseListQuery(request.query);  // limit capped at 100
  const { rows, nextCursor } = await listOrders(cursor, limit);
  reply.header('Cache-Control', 'private, max-age=15, must-revalidate');
  return { data: rows, next_cursor: nextCursor };
});

For JSON APIs, Brotli at quality 4 typically achieves 5–8× on repetitive array payloads. Add a 15–60 s read-through cache (Redis) on your top-5 read endpoints — in our run this removed 0.4 TB/month of database egress and far more at the edge.

The Numbers: 41.6 TB → 11.7 TB

Representative anonymized workload: 1.8M requests/day, 14 hot endpoints, managed Postgres with metered egress. Request volume grew 34% during the optimization window.

ChangeMonthly DB egressDelta
Baseline (SELECT *, 500-row pages)41.6 TB—
Explicit projections on 14 endpoints20.3 TB−21.3 TB
Payload shaping: dropped redundant joins, json_agg16.2 TB−4.1 TB
Page cap 500→100 + keyset cursors13.0 TB−3.2 TB
Binary protocol (pgx) + type narrowing12.1 TB−0.9 TB
ETag/304 + 60 s read-through cache11.7 TB−0.4 TB
Total11.7 TB−72%

At a blended $0.09/GB, that is roughly $3,830/month down to $1,080/month — about $33k/year — before counting the latency improvement, which was substantial: p99 dropped 41% because the serialization and TCP-copy path shrank along with the bytes.

Note that with Neon specifically, data transfer is metered independently of compute-hours and storage, and plan allowances have changed repeatedly — verify current inclusion and per-GB rates on the pricing page rather than budgeting from a blog post. The architectural lever is identical: fewer bytes off the compute node.

Rollout Plan and Operating Checklist

  • Deploy the socket-level byte counter behind a feature flag; emit db.wire_bytes with a query_label attribute.
  • Build a top-20 endpoint × bytes/day report from one week of data.
  • Rank by bytes/day, not by p99 latency. They are different lists.
  • Ship projection contracts endpoint by endpoint; add a CI lint banning SELECT * in service code.
  • Add pagination caps and statement_timeout as server-side guardrails before client changes.
  • Audit TOAST-heavy columns with pg_column_size; add generated columns for hot JSON paths.
  • Co-locate the API tier and database in the same AZ/region; route reads to same-region replicas.
  • Move PgBouncer (transaction mode) in front of the database; verify TLS session resumption is active.
  • Enable Brotli + weak ETags at the edge; confirm 304 rate > 30% on cacheable GETs.
  • Re-reconcile telemetry against the provider invoice monthly; investigate drift > 10%.

Pitfalls That Quietly Revert Your Gains

Compressing at the database layer. You cannot. TLS compression is disabled; the v3 protocol has no result compression. Budget for uncompressed DB→app traffic and compress only at the app→internet boundary.

`pg_column_size` as an egress proxy. It reports compressed/TOASTed storage size while the wire carries the detoasted value. Use it to find fat columns, never to estimate egress.

`LIMIT` without `ORDER BY`. Unstable pages cause clients to retry and overlap, multiplying fetch volume. Always pair LIMIT with a deterministic sort key.

*Views that hide `SELECT .** A view's column list is frozen at creation. SELECT * FROM wide_view` still ships all 40 columns; projection discipline must include views and materialized views.

Silent consumer breakage. Removing columns is a breaking API change for internal consumers. Contract tests + a deprecation window beat a 3 a.m. rollback.

`json_agg` on unbounded sets. It materializes the entire result in backend memory. Always wrap with a LIMIT; use COPY TO STDOUT for exports.

Cross-region read replicas. Reading a replica in another region converts cheap in-region traffic into inter-region transfer and adds RTT to every handshake. Replicate within the region unless the latency budget genuinely requires otherwise.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles