Compressing messages between Node.js services: zstd for the big ones, plain JSON for the rest
Most messages between your services are a few hundred bytes. A handful are reports, exports or month-long lists that weigh megabytes, and they are the ones that fill Redis when a consumer falls behind. @imqueue can compress just those with zstd. Here is what it saves, what it costs, where the size threshold belongs, and the rollout order that matters: a consumer that can't read zstd drops those messages for good. Every number is measured.
Look at the messages your services send each other and most of them are tiny: an order ID, a status, a user. A few hundred bytes each. Then there are the others. The monthly report. The CSV export. The list of every order a customer ever placed. One of those can weigh more than ten thousand of the small ones.
Those big messages are what fill Redis when a consumer stops for a while, and what push a broker towards its memory limit. Compressing everything would help them, but it would also make every small message slower and, as we'll see, bigger.
@imqueue/core can compress only the big ones. A queue with
compressAbove set writes
any message longer than that many characters with zstd, a fast modern
compression format, and leaves the rest as plain JSON. This post shows what that
saves, what it costs, where to put the threshold, and the one rollout mistake
that loses messages.
Everything below was measured with @imqueue/core 3.6.0, @imqueue/rpc
3.11.0 and Redis 8 on Node.js 24, using realistic order-list messages from 130
characters to 4 MB.
The short version
- Set
compressAbove: 1024, orIMQ_COMPRESS_ABOVE=1024in the environment. Messages up to 1 KB stay plain JSON; larger ones are zstd. - Large messages shrank to about a quarter of their size on our data, and a backlog of 10,000 messages took 30 MB of Redis instead of 98 MB.
- Very small messages get bigger when compressed. The break-even point was around 300 to 450 characters, which is why a threshold exists.
- zstd beat
useGzipat every size, and cost about half as much time. - Upgrade every consumer first. A consumer that can't read zstd drops those messages permanently, even with safe delivery on.
- RPC replies are covered too. Setting the variable on a service compressed its large responses without any change to its callers.
Turning it on
In code, on the options every queue, service and client already takes:
import { IMQService } from '@imqueue/rpc';
const service = new Reports({
compressAbove: 1024, // characters of JSON
});
Or, with no code change at all, for every queue in the process:
IMQ_COMPRESS_ABOVE=1024 node dist/index.js
The rule is simple. Each message is turned into JSON as usual. If that JSON is
longer than compressAbove characters, it is compressed with zstd at its
fastest setting and written as text behind a one-letter marker. Otherwise it is
written exactly as before. The default is 0, which never compresses.
zstd is built into Node.js from version 22.15. On an older Node,
compressAbove does nothing and messages stay plain JSON.
What it saves
The bytes Redis actually stored for one message, by size:
| message JSON | plain | useGzip |
zstd | zstd vs plain |
|---|---|---|---|---|
| 130 characters | 212 B | 269 B | 249 B | 117% |
| 1.2 KB | 1.3 KB | 753 B | 661 B | 52% |
| 10 KB | 10.1 KB | 3.6 KB | 3.1 KB | 31% |
| 100 KB | 100 KB | 31.6 KB | 26.3 KB | 26% |
| 1 MB | 1.0 MB | 308 KB | 258 KB | 26% |
| 4 MB | 4.0 MB | 1.23 MB | 1.03 MB | 26% |
Two things stand out. Above a few kilobytes, zstd consistently cut messages to about a quarter. And it beat gzip at every size, partly because gzip's output gets larger again when the queue stores it as text.
How much you save depends on your data. Ours had a random ID and a random price in every record, which compress poorly. Data with more repetition does far better: the release notes for this feature report a 4.6 MB service description shrinking to 293 KB, about one sixteenth.
The saving that matters most in practice is memory while messages wait. We queued 10,000 messages of about 10 KB with no consumer running:
| setting | Redis memory for the backlog |
|---|---|
| plain JSON | 98 MB |
useGzip |
37 MB |
compressAbove: 1024 |
30 MB |
That is the difference between a consumer outage that fits in your Redis and
one that reaches maxmemory. What happens at that limit is covered in
what survives a Redis restart.
Where to put the threshold
Compression has a fixed cost: the format's own header, and the conversion to text, which adds about a third. For a small message that overhead outweighs the saving. We measured where the line falls:
| message JSON | plain | zstd |
|---|---|---|
| 212 characters | 289 B | 321 B (111%) |
| 448 characters | 525 B | 405 B (77%) |
| 737 characters | 814 B | 505 B (62%) |
| 940 characters | 1017 B | 577 B (57%) |
So compression starts paying somewhere between 300 and 450 characters, and is clearly worth it from about 1 KB. Note that the length counted is the whole message the queue writes, including about 80 characters the framework adds around your data.
1024 is a sensible starting point. Everything at or below it stays plain
JSON, which keeps the common small messages fast and readable in redis-cli.
Lower it if your typical message sits just above a kilobyte; there is no reason
to go below about 500.
What it costs
Compression takes CPU time on the sender, and decompression takes it on the
receiver. We timed one message from send() to the consumer's handler, both on
the same machine as Redis:
| message JSON | plain | useGzip |
zstd |
|---|---|---|---|
| 10 KB | 0.09 ms | 0.19 ms | 0.12 ms |
| 100 KB | 0.89 ms | 2.01 ms | 0.95 ms |
| 1 MB | 7.2 ms | 15.2 ms | 8.0 ms |
| 4 MB | 25 ms | 55 ms | 29 ms |
zstd added about 10 to 15 percent. gzip roughly doubled the time.
This is a test where the network costs nothing, so it shows only the expense. Between real machines, sending a quarter of the bytes also takes less time on the wire, and the memory saving above doesn't depend on the network at all.
Roll it out readers first
This is the part not to get wrong.
Since 3.6.0, every queue reads all three formats, plain JSON, gzip and zstd,
whatever its own settings are. Older consumers don't. We sent ten messages from
a writer with compressAbove: 1024 to consumers of each version, with safe
delivery on:
| consumer | small messages (plain) | large messages (zstd) |
|---|---|---|
| core 3.6.0 | 5 of 5 | 5 of 5 |
| core 3.5.4 | 5 of 5 | 0 of 5, deleted |
The older consumer read the small messages normally, failed on the large ones
with Unexpected token 'Z', and dropped them. Nothing was left in the queue.
Safe delivery doesn't help, because the message did arrive; it just couldn't be
read.
That makes this failure easy to miss. A quick test with small messages passes, and only the big ones vanish. So the order is:
-
Upgrade every consumer of the queue so it runs
@imqueue/core3.6.0 or later, on Node 22.15 or later. -
Check what is installed, not what is declared.
@imqueue/rpc3.11 and@imqueue/job3.3 accept older core versions, so a service can upgrade rpc and still run core 3.5.4 from its lockfile. Run this in each one:npm ls @imqueue/core -
Then turn compression on, one producer at a time if you like. Producers and consumers don't have to agree on any setting.
RPC replies are compressed too
An RPC call is a message to the service and a reply back, and both are queue
messages. We gave a service IMQ_COMPRESS_ABOVE=1024 and nothing else, and
called a method that returns about 1 MB of orders:
| service setting | bytes written to Redis for the call |
|---|---|
| none | 980 KB |
IMQ_COMPRESS_ABOVE=1024 |
256 KB |
The caller had no setting and read the reply normally, because every reader handles every format. This is usually where compression pays most: services that return reports, exports or long lists. The calls themselves are compressed only when the calling process has the setting too.
Replacing useGzip
useGzip is the older way to compress, and it had two drawbacks: it compressed
every message, small ones included, and every consumer had to have the same
setting. A consumer without it couldn't read gzipped messages at all: in our
test, one older consumer without useGzip lost all ten messages a gzipping
writer sent it.
With every consumer on 3.6.0, neither drawback remains. To move over, remove
useGzip and set compressAbove. Do it in that order: where both are set,
useGzip wins. Messages already queued in gzip are still read normally.
Looking inside a compressed message
A zstd message in Redis starts with the letter Z, followed by the compressed
data as base64 text. To read one while debugging, copy it out and decode it:
redis-cli LINDEX imq:Reports 0 > msg.txt
node -e "
const z = require('node:zlib');
const fs = require('node:fs');
const s = fs.readFileSync('msg.txt', 'utf8').trim();
const bytes = Buffer.from(s.slice(1), 'base64'); // drop the Z
const raw = z.zstdDecompressSync(bytes);
console.log(JSON.stringify(JSON.parse(raw), null, 2));
"
What you get back is the framework's envelope: an id, the sender in from,
and your data under message. Messages below the threshold are plain JSON and
readable as they are, which is another reason not to set it too low.
What this does not do
- It doesn't compress pub/sub. Events sent with
publish()are always plain JSON. The same is true ofuseGzip. - It doesn't encrypt anything. Compressed is not secret. For that, see TLS for the broker.
- It doesn't make huge messages a good idea. A 4 MB message still takes 29 ms to move, and still occupies a worker while it's handled. For really large data, store it elsewhere and send a reference.
FAQ
How do I compress messages in an @imqueue service?
Set compressAbove to a number of characters, for example 1024, on the
queue, service or client options, or set IMQ_COMPRESS_ABOVE=1024 in the
environment. Messages whose JSON is longer than that are written with zstd, and
smaller ones stay plain JSON.
Is zstd better than gzip for JSON messages?
In our measurements, yes: smaller at every size we tried, from 1 KB to 4 MB, and about half the time per message. A 1 MB message was 258 KB with zstd and 308 KB with gzip.
Should I compress small messages?
No. Below roughly 300 to 450 characters, a compressed message is larger than the original. A 130-character message grew to 117% of its plain size. A threshold of about 1 KB keeps small messages plain.
How much Redis memory does compression save?
It depends on the data. On our order lists, 10,000 queued messages of about 10 KB took 98 MB plain and 30 MB compressed. Data with more repetition compresses further.
Why did messages disappear after I turned on compression?
Most likely a consumer was still running @imqueue/core older than 3.6.0. It
can't read zstd, so it fails on every compressed message and drops it, even
with safe delivery. Small messages still arrive, which hides the problem. Check
with npm ls @imqueue/core in every consumer.
Do producers and consumers need the same compression setting?
Not since 3.6.0. Every queue reads plain JSON, gzip and zstd regardless of its own setting, so you can turn compression on one producer at a time.
Which Node.js version is needed for zstd?
Node.js 22.15 or later. On older versions compressAbove has no effect and
messages are written as plain JSON.
Reference
IMQOptions.compressAbove ·
IMQOptions.useGzip ·
IMQOptions.safeDelivery ·
IMQOptions ·
RedisQueue.send() ·
What survives a Redis restart ·
Talking to your Redis broker over TLS
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.