Optimizations

What Pigeon does to keep traffic small, and what it deliberately does not touch. None of this needs turning on. It is how the library already works.

One rule runs through all of it: your data is never rewritten. shop:Emit("Sold", id) puts id on the wire exactly as you passed it. Everything below is about the bookkeeping Pigeon wraps around it.

Metadata travels as a buffer

Every message carries some bookkeeping: what kind of message it is, which event it is for, and which request it answers. Sending that as separate strings is wasteful, so Pigeon packs it into one buffer and sends that instead.

A packet on the wire is two things:

One message

meta buffer kind, event, request id, which arguments were nil
your values a plain array, exactly as you passed them

What is in the buffer

FieldSizeWhat it is
flags1 byteThe kind of message in the low two bits, then a bit for whether a request id follows and a bit for whether there are arguments.
event length2 bytesHow long the event name is.
request id4 bytesOnly on a request or a reply.
argument count1 byteOnly when there are arguments. At most 255.
nil bits1 bit eachOne bit per argument, saying whether it was nil.
event namethe restThe channel name, a zero byte, then your event name.

What it saves

Measured as bytes of wire data for the bookkeeping alone. Your arguments are the same either way.

MessageBeforeNow
Emit("Sold", id) on a channel called Shop51 bytes14 bytes
Call("Request") on a channel called PlayerData96 bytes27 bytes
The reply to that call81 bytes9 bytes
Init() on a channel called Shop46 bytes7 bytes

Three things account for almost all of it.

The transformer id is not sent at all

It used to ride on every single packet, 38 characters of it, and the receiving side used it for exactly one thing: working out which remote to send the reply back on. But the receiver already knows that, because it knows which remote the message just arrived on. So the id is gone and the reply goes back the way the request came.

That is also more correct than it was. Recomputing the remote from an id could pick a different one if the pool had resized in between. Answering on the remote the request came in on cannot drift. See The Ref Pool.

Request ids are numbers

A request id used to be a 36 character string from GenerateGUID. It is now a 4 byte counter. An id only has to be unique on the machine that made it, because a reply carries back the id the caller generated, so a counter is enough.

Nil is one bit

Roblox drops a nil out of the middle of an argument list, so a library that wants to carry one has to say so some other way. Pigeon used to substitute a 14 byte marker string. Now it writes one bit per argument into the meta buffer and the far side puts the nil back.

Note

That also removed a sharp edge. With a marker string, a player who sent that exact string would have had it read back as nil. Bits cannot collide with anything, so no string value is reserved any more.

One pool of remotes

Pigeon does not make a RemoteEvent per channel. It keeps a small pool and hashes each channel onto one, so 200 channels fit in 13 remotes rather than 200 Instances. Full detail, including why hashing rather than assigning, is in The Ref Pool.

Table writes are batched

Writing five fields of a staged table in one frame sends one message, not five. The writes queue and flush together, so filling a table field by field costs one send.

Server
local view = stats:GetTable()

view.coins = 120
view.level = 4
view.combat.health = 100

-- One patch goes out, carrying all three.

Nothing is sent to someone who cannot use it

A guarded channel filters the recipient list before the packet is built, so a client that has not passed the guard costs nothing to skip. If a broadcast has nobody left after that filter, no packet is built at all. See Handshakes.

Messages for a client that has not called Init yet are held rather than sent and lost, and unreliable messages for that client are dropped instead of held, because replaying something that was allowed to be dropped is pointless. See Startup and Buffering.

The lossy lane

A carrier made with { Unreliable = true } sends on an UnreliableRemoteEvent, which is cheaper and may drop. Use it for things you send every frame and would not miss. Requests and replies always take the reliable lane whatever the carrier says. See Unreliable Sending.

What Pigeon does not do

Worth being clear about, so you know where the remaining cost is.

Not doneWhy
Compress your arguments They go across exactly as you passed them. If you send a large table every frame, that is the cost, and only you can make it smaller.
Diff arbitrary tables Only a staged table sends changes rather than the whole thing. A plain table passed to Emit is sent whole.
Merge separate sends Two Emit calls in one frame are two messages. Only staged table writes coalesce.
Shorten your event names The channel name and event name are on the wire so the far side can route without a shared registry. Long names cost bytes on every packet.

Careful

The one place your own choices show up in the numbers above is naming. A channel called "PlayerInventoryManagement" puts 25 bytes on every packet it carries. Short names are worth having on a channel you send on constantly.

What to read next