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.
Photo by Brett Sayles on PexelsEvery 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:
// 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:
-- 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.
// 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,
});// 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:
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:
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:
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:
-- 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: 100Pair it with belt-and-braces server guardrails so a buggy client cannot stream a table into the network:
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:
| Type | Text format | Binary format |
|---|---|---|
bigint | up to 19 B | 8 B |
integer | up to 10 B | 4 B |
double precision | up to ~24 B | 8 B |
timestamptz | 26–35 B | 8 B |
uuid | 36 B | 16 B |
boolean | 1 B | 1 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.
// 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.
// 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.
| Change | Monthly DB egress | Delta |
|---|---|---|
Baseline (SELECT *, 500-row pages) | 41.6 TB | — |
| Explicit projections on 14 endpoints | 20.3 TB | −21.3 TB |
Payload shaping: dropped redundant joins, json_agg | 16.2 TB | −4.1 TB |
| Page cap 500→100 + keyset cursors | 13.0 TB | −3.2 TB |
Binary protocol (pgx) + type narrowing | 12.1 TB | −0.9 TB |
| ETag/304 + 60 s read-through cache | 11.7 TB | −0.4 TB |
| Total | 11.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_byteswith aquery_labelattribute. - 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_timeoutas 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
- PostgreSQL Global Development Group — Frontend/Backend Protocol: Message Formats
- PostgreSQL Global Development Group — Frontend/Backend Protocol: Flow (startup, simple/extended query)
- PostgreSQL Global Development Group — TOAST: The Oversized-Attribute Storage Technique
- PostgreSQL Global Development Group — Database Object Size Functions (`pg_column_size`, `pg_size_pretty`)
- PostgreSQL Global Development Group — The `pg_stat_statements` Module
- PostgreSQL Global Development Group — COPY and DECLARE (cursors)
- PostgreSQL Global Development Group — LIMIT and OFFSET
- Neon — Architecture Overview and Connection Pooling
- Neon — Read Replicas and Pricing
- PgBouncer — Configuration (`pool_mode`, `server_tls_sslmode`)
- jackc — pgx: PostgreSQL driver and toolkit for Go (binary protocol defaults)
- node-postgres — Pool and Client configuration
- IETF RFC 9111 — HTTP Caching
- IETF RFC 7932 — Brotli Compressed Data Format
- IETF RFC 8878 — Zstandard Compression and the `application/zstd` Media Type
- IETF RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3
- MDN Web Docs — `Content-Encoding`
- AWS — Overview of Data Transfer Costs for Common Architectures
- AWS — Querying Amazon VPC Flow Logs with Athena
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.