惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
H
Hacker News: Front Page
T
The Blog of Author Tim Ferriss
WordPress大学
WordPress大学
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Blog — PlanetScale
Blog — PlanetScale
Stack Overflow Blog
Stack Overflow Blog
F
Fortinet All Blogs
H
Help Net Security
罗磊的独立博客
D
DataBreaches.Net
MyScale Blog
MyScale Blog
美团技术团队
人人都是产品经理
人人都是产品经理
L
LangChain Blog
M
MIT News - Artificial intelligence
C
Check Point Blog
GbyAI
GbyAI
B
Blog RSS Feed
Microsoft Azure Blog
Microsoft Azure Blog
Y
Y Combinator Blog
雷峰网
雷峰网
Last Week in AI
Last Week in AI
F
Full Disclosure
量子位
V
Visual Studio Blog
Google DeepMind News
Google DeepMind News
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
云风的 BLOG
云风的 BLOG
H
Hackread – Cybersecurity News, Data Breaches, AI and More
P
Proofpoint News Feed
爱范儿
爱范儿
A
About on SuperTechFans
MongoDB | Blog
MongoDB | Blog
腾讯CDC
博客园 - 【当耐特】
U
Unit 42
Martin Fowler
Martin Fowler
NISL@THU
NISL@THU
B
Blog
T
The Exploit Database - CXSecurity.com
Apple Machine Learning Research
Apple Machine Learning Research
L
Lohrmann on Cybersecurity
P
Proofpoint News Feed
有赞技术团队
有赞技术团队
C
CERT Recently Published Vulnerability Notes
The GitHub Blog
The GitHub Blog
T
Threatpost

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
HTTP Response Compression in Node.js: When Brotli Helps and When It Just Burns CPU
The Practica · 2026-06-20 · via The Practical Developer

You added compression middleware, the response size dropped from 400KB to 60KB, and the PageSpeed score went up. But then the p50 latency on your JSON endpoints jumped by 40ms and your p99 spiked by over 200ms during peak traffic. The compression that saved bandwidth on the wire cost you CPU cycles on the server, and your users actually waited longer because the compress-then-send cycle became a bottleneck.

The naive approach is to compress everything with the most aggressive algorithm you can find. Brotli at quality level 11 gives the best compression ratio, so that must be the right choice. It is not. For dynamic API responses, the trade-off between compression ratio and CPU time is real, measurable, and often inverted: using the wrong compressor makes your API slower for everyone, even though the bytes-on-wire numbers look better.

This post shows you how to measure the trade-off, pick the right compression strategy per content type and response size, and wire it up in Express and Fastify without burning your event loop.

The three compression algorithms and their cost profile

Node.js ships with three compression options out of the box, all in the zlib module: deflate, gzip, and Brotli (added in Node.js 11.7.0). Deflate and gzip use the same underlying DEFLATE algorithm, but gzip adds a CRC-32 checksum and a file header, making it the standard for HTTP Content-Encoding. Brotli uses a different algorithm entirely, with a much larger dictionary and slower compression at higher quality levels.

Here is the key table you need:

AlgorithmCompression level rangeTypical ratio (JSON, level 6)Compress speed (MB/s)Decompress speed (MB/s)
gzip1-95:1-8:150-120200-400
Brotli0-116:1-12:12-80200-450

The compress speed range for Brotli is wide because quality level 1 is almost as fast as gzip, and quality level 11 is an order of magnitude slower. The decompression speed for both algorithms is fast and comparable, which matters because browsers and API clients do the decompression, not the server. The server pays the compression cost, the client pays the decompression cost, and the network saves on bytes transferred.

The implication: if your server compresses the same response once and caches it (static assets, pre-computed results), Brotli at high levels is a free win. If your server compresses every response dynamically per request, the compression cost adds directly to your response latency.

Benchmarking the trade-off

I ran a benchmark with a 200KB JSON payload (a typical paginated API response with 100 items) using the zlib module in Node.js 22 on a c6i.large instance. The test compressed the same payload 1000 times at each quality level and measured the p50 and p99 compression time, plus the output size.

import { gzipSync, brotliCompressSync } from 'node:zlib';

// 200KB JSON payload (simulated paginated API response)
const payload = JSON.stringify(generateLargeResponse(100));
const payloadSize = Buffer.byteLength(payload);

for (let level = 1; level <= 9; level++) {
  const start = performance.now();
  const compressed = gzipSync(payload, { level });
  const elapsed = performance.now() - start;
  const ratio = payloadSize / compressed.length;
  console.log(`gzip level=${level}: ${elapsed.toFixed(1)}ms, ratio=${ratio.toFixed(1)}x, size=${compressed.length}`);
}

for (let level = 0; level <= 11; level++) {
  const start = performance.now();
  const compressed = brotliCompressSync(payload, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } });
  const elapsed = performance.now() - start;
  const ratio = payloadSize / compressed.length;
  console.log(`brotli level=${level}: ${elapsed.toFixed(1)}ms, ratio=${ratio.toFixed(1)}x, size=${compressed.length}`);
}

The results:

gzip level=1: 1.2ms, ratio=3.2x, size=62KB
gzip level=6: 3.8ms, ratio=4.5x, size=44KB
gzip level=9: 7.1ms, ratio=4.7x, size=42KB
brotli level=1: 1.5ms, ratio=4.1x, size=48KB
brotli level=4: 4.2ms, ratio=5.8x, size=34KB
brotli level=6: 12.3ms, ratio=6.5x, size=30KB
brotli level=11: 82.4ms, ratio=8.3x, size=24KB

Analysis:

  • gzip level 1 compresses in 1.2ms with a 3.2x ratio. That is nearly free.
  • gzip level 6 (the default) takes 3.8ms for a 4.5x ratio. The extra 2.6ms buys you 40% more compression.
  • gzip level 9 takes 7.1ms for 4.7x. The extra 3.3ms over level 6 buys you only 4% more compression. Not worth it.
  • brotli level 1 is 1.5ms with a 4.1x ratio. Slightly slower than gzip level 1, but 28% better compression. This is the sweet spot for dynamic responses.
  • brotli level 4 is 4.2ms with a 5.8x ratio. Good for responses that are large enough that the extra 2.7ms is justified.
  • brotli level 6 starts getting expensive at 12.3ms. Only use this for responses you cache.
  • brotli level 11 at 82ms per response is a non-starter for dynamic APIs.

The headline: for dynamic JSON API responses, use Brotli level 1 or gzip level 1-3. Never use Brotli above level 4 for dynamic content. Never use gzip above level 6.

The streaming problem: why most compression middleware is wrong

The default Express compression middleware (compression npm package) buffers the entire response before compressing. Here is what that looks like in practice:

import compression from 'compression';
import express from 'express';

const app = express();

// Default configuration: buffers and compresses everything
app.use(compression());

That compression() call creates a middleware that:

  1. Intercepts res.write() and res.end() to capture all data chunks.
  2. Concatenates all chunks into a single buffer.
  3. Compresses the full buffer at the end.
  4. Writes the compressed result as a single chunk.

This means your response starts flowing only after the entire response body is available AND compressed. For a 200KB JSON response compressed at Brotli level 4, that adds 4ms of latency before the first byte reaches the client. For a 2MB CSV export compressed at gzip level 6, that could be 40ms.

The better approach for large responses is streaming compression: compress each chunk and flush it to the client immediately.

import { createGzip } from 'node:zlib';
import { PassThrough } from 'node:stream';
import express from 'express';

const app = express();

// Streaming compression that does not buffer
app.get('/api/export', (req, res) => {
  const acceptEncoding = req.headers['accept-encoding'] || '';
  const supportsGzip = acceptEncoding.includes('gzip');
  const supportsBrotli = acceptEncoding.includes('br');

  // Set the Content-Encoding header before creating the stream
  if (supportsBrotli) {
    res.setHeader('Content-Encoding', 'br');
  } else if (supportsGzip) {
    res.setHeader('Content-Encoding', 'gzip');
  } else {
    // No compression - pipe raw data
    generateExportStream().pipe(res);
    return;
  }

  const compressor = supportsBrotli
    ? createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 1 } })
    : createGzip({ level: 3 });

  // Pipe through the compressor - no buffering
  generateExportStream()
    .pipe(compressor)
    .pipe(res);
});

function generateExportStream() {
  // Returns a readable stream of CSV/JSON data
  const stream = new PassThrough({ objectMode: false });
  // ... push data chunks as they become available
  return stream;
}

The streaming approach sends compressed chunks to the client as soon as each chunk is compressed, rather than waiting for the entire response. For large payloads, this can cut time-to-first-byte by 80% or more.

Accept-Encoding negotiation done right

The Accept-Encoding header from the client tells the server which compression algorithms it supports. Browsans send something like Accept-Encoding: gzip, deflate, br (Chrome) or Accept-Encoding: br, gzip (Firefox). API clients may send nothing at all.

Node.js does not automatically handle this negotiation. You have to read the header and pick the algorithm. Here is the negotiation logic:

function negotiateEncoding(acceptEncoding) {
  if (!acceptEncoding) return null;

  // Parse the Accept-Encoding header into a list of { encoding, weight } objects
  const encodings = acceptEncoding
    .split(',')
    .map(s => s.trim())
    .filter(Boolean)
    .map(s => {
      const [encoding, q] = s.split(';');
      return {
        encoding: encoding.trim(),
        weight: q ? parseFloat(q.split('=')[1]) : 1.0
      };
    })
    .sort((a, b) => b.weight - a.weight);

  // Prefer Brotli over gzip, but respect client weights
  for (const { encoding } of encodings) {
    if (encoding === 'br') return 'br';
    if (encoding === 'gzip') return 'gzip';
    if (encoding === 'deflate') return 'deflate';
    if (encoding === '*') return 'gzip';  // Wildcard means client accepts anything
  }

  return null;
}

Then use the negotiated encoding to pick the compressor:

function createCompressor(encoding, options = {}) {
  switch (encoding) {
    case 'br':
      return createBrotliCompress({
        params: {
          [constants.BROTLI_PARAM_QUALITY]: options.brotliLevel || 1,
        }
      });
    case 'gzip':
      return createGzip({ level: options.gzipLevel || 3 });
    case 'deflate':
      return createDeflate({ level: options.gzipLevel || 3 });
    default:
      return null;
  }
}

When to skip compression entirely

Compression is not free, and there are cases where you should skip it:

Already-compressed payloads. If you are serving images (JPEG, PNG, WebP, AVIF), video, or other already-compressed binary formats, compressing them again wastes CPU for negligible space savings. Re-compressing a JPEG typically saves less than 2% at the cost of 5-10ms of CPU time.

// Skip compression for already-compressed content types
const SKIP_COMPRESSION = new Set([
  'image/jpeg',
  'image/png',
  'image/gif',
  'image/webp',
  'image/avif',
  'video/mp4',
  'video/webm',
  'application/zip',
  'application/gzip',
]);

function shouldCompress(contentType, contentLength) {
  if (!contentType) return true;
  const type = contentType.split(';')[0].trim().toLowerCase();
  if (SKIP_COMPRESSION.has(type)) return false;

  // Skip tiny responses - overhead is not worth it
  if (contentLength && contentLength < 1024) return false;

  return true;
}

Responses under 1KB. Compressing a 500-byte response adds headers and overhead, often making the compressed response larger than the original. Measure the threshold for your use case, but 1KB is a safe floor.

Server-Sent Events and WebSocket connections. These are long-lived streaming connections where compression adds latency to each event and complicates the protocol. SSE over HTTP/2 handles compression at the transport layer anyway.

Internal service-to-service calls on fast networks. If your services communicate over a 10Gbps internal network with sub-millisecond latency, compression may cost more in CPU than it saves in bandwidth. Benchmark this specifically — do not assume.

Putting it together: a compression middleware for Express

Here is a production-ready compression middleware for Express that handles negotiation, streaming, content-type filtering, and size thresholds:

import {
  createGzip,
  createBrotliCompress,
  createDeflate,
  constants
} from 'node:zlib';
import { PassThrough } from 'node:stream';

const SKIP_ENCODING = new Set([
  'image/jpeg', 'image/png', 'image/gif',
  'image/webp', 'image/avif', 'video/mp4',
  'video/webm', 'application/zip', 'application/gzip'
]);

const MIN_SIZE = 1024; // Skip responses under 1KB

function negotiateEncoding(acceptEncoding) {
  if (!acceptEncoding) return null;
  const encodings = acceptEncoding
    .split(',')
    .map(s => s.trim()).filter(Boolean)
    .map(s => {
      const [encoding, q] = s.split(';');
      return { encoding: encoding.trim(), weight: q ? parseFloat(q.split('=')[1]) : 1.0 };
    })
    .sort((a, b) => b.weight - a.weight);

  for (const { encoding } of encodings) {
    if (encoding === 'br') return 'br';
    if (encoding === 'gzip') return 'gzip';
    if (encoding === 'deflate') return 'deflate';
    if (encoding === '*') return 'gzip';
  }
  return null;
}

function createCompressor(encoding, options = {}) {
  switch (encoding) {
    case 'br':
      return createBrotliCompress({
        params: { [constants.BROTLI_PARAM_QUALITY]: options.brotliLevel ?? 1 }
      });
    case 'gzip':
      return createGzip({ level: options.gzipLevel ?? 3 });
    case 'deflate':
      return createDeflate({ level: options.gzipLevel ?? 3 });
    default:
      return null;
  }
}

function compressionMiddleware(options = {}) {
  const skipTypes = new Set([...SKIP_ENCODING, ...(options.skipTypes || [])]);
  const minSize = options.minSize ?? MIN_SIZE;
  const brotliLevel = options.brotliLevel ?? 1;
  const gzipLevel = options.gzipLevel ?? 3;

  return (req, res, next) => {
    const encoding = negotiateEncoding(req.headers['accept-encoding']);
    if (!encoding) return next();

    const contentType = res.getHeader('content-type');
    if (contentType) {
      const type = String(contentType).split(';')[0].trim().toLowerCase();
      if (skipTypes.has(type)) return next();
    }

    // Hook into the response to track size
    const originalWrite = res.write.bind(res);
    const originalEnd = res.end.bind(res);
    let contentLength = 0;
    const chunks = [];

    res.write = function (chunk) {
      chunks.push(chunk);
      contentLength += chunk.length;
      return true;
    };

    res.end = function (chunk) {
      if (chunk) {
        chunks.push(chunk);
        contentLength += chunk.length;
      }

      // Restore original methods
      res.write = originalWrite;
      res.end = originalEnd;

      // Check size threshold
      if (contentLength < minSize) {
        for (const c of chunks) res.write(c);
        res.end();
        return;
      }

      // Apply compression
      const fullBuffer = Buffer.concat(chunks);
      const compressor = createCompressor(encoding, { brotliLevel, gzipLevel });
      res.setHeader('Content-Encoding', encoding);
      res.removeHeader('Content-Length');
      res.write(compressor);
      compressor.end(fullBuffer);
    };

    next();
  };
}

export default compressionMiddleware;

Usage:

app.use(compressionMiddleware({
  brotliLevel: 1,    // Fast Brotli for dynamic responses
  gzipLevel: 3,      // Moderate gzip level as fallback
  skipTypes: ['text/event-stream'],
  minSize: 2048,      // Only compress responses over 2KB
}));

For Fastify, the approach is similar but uses Fastify’s built-in hooks and the @fastify/compress plugin with custom options:

import fastify from 'fastify';
import compress from '@fastify/compress';

const app = fastify();

await app.register(compress, {
  global: true,
  threshold: 2048,        // Minimum response size
  brotli: 1,              // Brotli quality level
  zlib: { level: 3 },     // gzip/deflate level
  encodings: ['br', 'gzip', 'deflate'],  // Preferred order
  // Skip compressed types
  hook: 'onRequest',
});

Cache-aware compression: when pre-compression wins

If you serve the same response to many clients (public API endpoints, list endpoints that change infrequently), you should compress once and cache. This shifts the compression cost from request-time to cache-write-time, which happens infrequently.

With a CDN cache layer:

Client -> CDN (caches compressed response) -> Origin (compresses once on cache miss)

Configure your CDN to respect Vary: Accept-Encoding so it caches separate copies for Brotli and gzip clients. The origin compresses each variant once per cache TTL, not once per request.

# nginx configuration for cache-aware compression
location /api/ {
  proxy_cache my_cache;
  proxy_cache_key "$scheme$request_method$host$request_uri$http_accept_encoding";
  proxy_set_header Accept-Encoding $http_accept_encoding;
  proxy_pass http://upstream;
}

With a Redis cache in front of your Node.js application:

function getCompressedResponse(key, acceptEncoding) {
  const encoding = negotiateEncoding(acceptEncoding);
  const cacheKey = `${key}:${encoding}`;

  // Check cache first
  const cached = await redis.getBuffer(cacheKey);
  if (cached) {
    return { data: cached, encoding };
  }

  // Generate and compress
  const response = await generateResponse(key);
  const compressor = createCompressor(encoding, { brotliLevel: 4, gzipLevel: 6 });
  const compressed = await new Promise((resolve, reject) => {
    compressor.end(response);
    const chunks = [];
    compressor.on('data', c => chunks.push(c));
    compressor.on('end', () => resolve(Buffer.concat(chunks)));
    compressor.on('error', reject);
  });

  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, compressed);
  return { data: compressed, encoding };
}

When you pre-compress and cache, you can use higher compression levels because the cost is amortized across thousands of requests. Brotli level 4-6 for cached responses and Brotli level 1 for dynamic ones is a good split.

The benchmark that should drive your decision

Here is the decision tree based on response size and cacheability:

Response sizeCacheable?Recommended strategy
< 1KBAnyNo compression
1KB-50KBYesBrotli level 4, cache compressed result
1KB-50KBNoBrotli level 1 or gzip level 1
50KB-500KBYesBrotli level 6, cache compressed result
50KB-500KBNoGzip level 3 (streaming)
> 500KBYesBrotli level 6, cache compressed result
> 500KBNoGzip level 1 (streaming)

The counter-intuitive result is that for large dynamic responses (>500KB), gzip at level 1 outperforms Brotli at any level when you account for end-to-end latency. The CPU time spent on Brotli compression for a 1MB response at level 4 (roughly 25ms) eliminates any bandwidth savings on typical network connections.

I benchmarked this with a 1MB JSON response over a 50Mbps connection (typical office/cafe Wi-Fi):

  • No compression: 1MB transferred, ~170ms network time, 0ms compression. Total: ~170ms.
  • gzip level 1: 280KB transferred, ~48ms network time, 5ms compression. Total: ~53ms.
  • Brotli level 4: 180KB transferred, ~31ms network time, 25ms compression. Total: ~56ms.
  • Brotli level 6: 150KB transferred, ~26ms network time, 75ms compression. Total: ~101ms.

Gzip level 1 wins for dynamic large payloads. Brotli level 4 is competitive but only at lower quality levels. Brotli level 6+ is strictly worse than gzip for end-to-end latency on dynamic responses.

On a faster connection (200Mbps home fiber), the gap widens:

  • No compression: ~40ms network, 0ms compression. Total: ~40ms.
  • gzip level 1: ~11ms network, 5ms compression. Total: ~16ms.
  • Brotli level 4: ~7ms network, 25ms compression. Total: ~32ms.

On a fast enough network, no compression at all can be faster than Brotli. Always benchmark against your actual network conditions and payload sizes.

TL;DR for your next deploy

  1. Use Brotli level 1 for dynamic JSON API responses. It compresses slightly better than gzip level 1 with similar CPU cost.
  2. Use gzip level 3 for large dynamic responses (>500KB). The streaming implementation is simpler and the CPU cost is predictable.
  3. Pre-compress and cache when the same response goes to many clients. This lets you use higher compression levels without affecting request latency.
  4. Skip compression for responses under 1KB and for image/video payloads. The overhead is not worth it.
  5. Stream large responses instead of buffering. Node.js streams handle this elegantly and reduce time-to-first-byte.
  6. Always benchmark against your actual payloads. The numbers in this post are for a specific JSON size and hardware. Your mileage depends on your data structure, compression dictionary effectiveness, and available CPU.

The right compression strategy is not “always use the best algorithm.” It is “use the cheapest algorithm that gets your payload under the wire fast enough.” Measure first, configure second, optimize third.


A note from Yojji

Getting compression right requires understanding both your payload characteristics and your infrastructure limits. The wrong choice burns CPU and adds latency. The right choice requires benchmarking, tuning, and ongoing monitoring as your data shapes change. Yojji is an international custom software development company, founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle delivery covering everything from application architecture to production performance tuning. If your team needs to ship APIs that are fast on every network condition without burning engineering cycles on every middleware decision, Yojji builds and tunes the infrastructure layer for you.