Unreliable Sending

A carrier can send on a cheaper lane that does not promise delivery or order. It is for things you send constantly and can afford to lose, like positions and visual effects.

Turning it on

Pass one option when you build the carrier.

local positions = Pigeon.new("Positions", { Unreliable = true })

That is the whole switch. Every fire and forget send on this carrier now takes the unreliable lane. Fire and forget means you send and move on, with no reply and no confirmation.

carrier.Unreliable Shared

carrier.Unreliable: boolean

The option is stored on the carrier as a plain field. It is true only if you passed Unreliable = true, and false otherwise, including when you pass no options at all.

ValueWhat sends do
falseEverything takes the reliable lane. This is the default.
trueFire and forget sends take the unreliable lane. Requests and replies still take the reliable one.
local reliable = Pigeon.new("Chat")
print(reliable.Unreliable)    --> false

local positions = Pigeon.new("Positions", { Unreliable = true })
print(positions.Unreliable)   --> true

Careful

Passing any options table makes a fresh carrier that Pigeon does not cache. Calling Pigeon.new("Positions") somewhere else gives you a different, reliable carrier on the same channel name. The two share the channel, so a packet goes to every carrier that bound its event with On or When, and a handler registered on both fires twice. Hold on to the one you built and pass it around. See Carriers.

What unreliable actually means

Underneath, Pigeon keeps two remotes for every bucket in its pool: a normal RemoteEvent and an UnreliableRemoteEvent twin. The unreliable one is cheaper to send on, and Roblox gives you nothing in return.

ReliableUnreliable
ArrivesAlwaysUsually. Some are lost.
OrderSame order you sent themAny order
CostHigherLower
Held for a client that is not readyYes, up to 64 for 30 secondsNo, dropped

So an unreliable message can vanish, and two sent a frame apart can arrive the wrong way round. Your handler has to be fine with both.

Only fire and forget uses the lane

Requests never go unreliable, no matter what the carrier says. A request is a question that yields until it gets an answer, and dropping either half would leave a thread waiting for nothing.

MethodLane on an unreliable carrier
EmitUnreliable
Broadcast, BroadcastTo, BroadcastExceptUnreliable
SendToRoomUnreliable
CallReliable, always
CallToReliable, always
The reply a When responder returnsReliable, always
Ping, Handshake, RequestTableReliable, because each is a Call underneath

A reply is never held back either. The far side is already yielded on it, so it is provably listening, and holding it would strand that thread until its timeout. See Requests and Replies.

Nothing is queued for a client that has not called Init

On a normal carrier, the server holds messages for a client until that client calls Init() on the channel. Up to 64 per channel, for 30 seconds. That is what stops a broadcast landing before the client has registered its handlers. See Startup and Buffering.

Unreliable messages are not held. They are thrown away.

Server sends to a client that has not called Init

reliable queued delivered on Init
unreliable dropped

That is the right call. You already told Pigeon this message may be lost in transit. A message that can be lost has no business being kept in a queue and replayed seconds later, when it is stale and the player has moved on. Holding a position from thirty seconds ago and delivering it as if it were current would be worse than losing it.

Note

The client still has to call Init(). Until it does, an unreliable carrier receives nothing at all from the server, and unlike a reliable one it gets no backlog once it does. Call Init() as soon as your handlers are set up.

Sending from the client can fall back

Sending from the client never needs Init. But the unreliable remotes have to replicate down from the server before the client can use them, and that takes a moment after joining.

If the client calls Emit on an unreliable carrier before the twin has arrived, Pigeon does not drop the message. It sends it on the reliable remote instead. If neither remote has arrived, the message waits in the client's outbox and goes out as soon as one of them does. So your first few sends after joining may be reliable ones. Nothing breaks, and it settles by itself.

A position streaming example

Every heartbeat the server sends everyone's position to everyone. Losing a frame does not matter, because another one is a sixtieth of a second behind it.

Server
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local Pigeon = require(ReplicatedStorage.Pigeon)

local positions = Pigeon.new("Positions", { Unreliable = true })

RunService.Heartbeat:Connect(function()
	local snapshot = {}

	for _, player in Players:GetPlayers() do
		local character = player.Character
		local root = character and character.PrimaryPart
		if root then
			-- String keys, because a table of positions is not a plain array.
			snapshot[tostring(player.UserId)] = root.Position
		end
	end

	positions:Broadcast("Update", snapshot)
end)
Client
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local positions = Pigeon.new("Positions", { Unreliable = true })

positions:On("Update", function(snapshot)
	for userId, position in snapshot do
		moveGhost(userId, position)
	end
end)

-- Without this the server sends nothing, and nothing is kept for later.
positions:Init()

Each message carries the whole picture rather than a change to it. That is the pattern that survives a lossy lane: if one goes missing, the next one still tells the client everything it needs. A message that said "move player 5 forward by 2 studs" would leave the client permanently wrong the first time one was dropped.

The client passes { Unreliable = true } too, but only because this carrier might send as well. Receiving works either way. The flag only ever affects the sends that carrier makes.

What to use it for

Good fitBad fit
Character and vehicle positionsCurrency and inventory changes
Aim direction and look vectorsPurchases and trades
Cosmetic effects and hit sparksQuest progress and unlocks
Live meters that refresh constantlyAnything the player would notice missing

The test is simple. If losing one message leaves the game in a wrong state that never fixes itself, use a reliable carrier.

Staged tables on an unreliable carrier

Warning

Do not capture a staged table on an unreliable carrier. Staged tables replicate their writes as fire and forget broadcasts, so on an unreliable carrier those patches take the lossy lane. A dropped patch is never resent, and the client's copy is wrong from then on. Keep staged tables on a reliable carrier. See Staged Tables.

Switching at runtime

Unreliable is a plain field on the carrier and it is read at the moment you send. You can flip it.

Server
local carrier = Pigeon.new("Effects", { Unreliable = true })

local payload = { round = 2 }

carrier.Unreliable = false
carrier:Broadcast("Important", payload)   -- reliable
carrier.Unreliable = true

This is worth knowing but it is not a habit to get into. If a channel has both kinds of traffic on it, two carriers with two names is clearer than one you keep toggling, and it cannot be got wrong by a race.

Write handlers that do not mind repeats

This lane can drop a message and it can deliver messages out of order. Write your handlers so that neither one leaves the game in a wrong state.

The rule is to send what a value is, not how to change it. A message that sets a number is safe to miss, because the next one corrects it. A message that adds to a number is not, because a missed one is gone and a repeated one counts twice.

Client
-- Fragile. A dropped or repeated message leaves the wrong number.
positions:On("Hit", function(amount)
	health -= amount
end)

-- Fine. Every message says what the value is now.
positions:On("Health", function(value)
	health = value
end)

If something really has to be counted, number it yourself and ignore anything you have already seen. That also throws away messages that arrive out of order.

Client
local lastSeen = 0

positions:On("Tick", function(index, payload)
	if index <= lastSeen then
		return
	end
	lastSeen = index
	apply(payload)
end)

Note

Anything that must arrive exactly once does not belong on this lane at all. Put it on a normal carrier instead.

What to read next