UDP does not deliver your packet. Not "usually delivers", not "delivers unless something is wrong" — there is simply no promise. A datagram arrives, or it doesn't, or it arrives twice, or it arrives out of order, and nothing tells you which. Every other page in these docs describes a thing that works. This one describes a thing that is allowed to fail silently, on purpose, and explains why you'd want that.
UDP is also not encrypted. Anyone on the path can read and modify your datagrams. If you need confidentiality, encrypt the payload yourself or run behind something that does — SCUA will not do it for you, and will not pretend to.
Both of those are the deal. Take it deliberately, or use
net.dial (TCP) and http instead.
#Why anyone wants this
Because sometimes a late packet is worse than a lost one.
TCP guarantees delivery and order, and it pays for that with head-of-line blocking: one lost segment stalls every byte behind it while it's retransmitted. For a file, that's exactly right. For a game, it's a disaster — a position update from 200 ms ago is worthless, the world has moved on, and TCP is making you wait for it before it will hand you the fresh one.
UDP lets you drop the stale packet and use the new one. That's the whole trade. It's why games, DNS, QUIC, and every gossip protocol are built on it.
#The grant
Sending datagrams is its own capability, and
--allow-net never implies it:
scua --allow-udp=1.1.1.1:53 resolve.scua
The grant names addresses, not just "yes". That's deliberate, and it's the most important thing on this page:
A UDP grant that's only an on/off switch has, by construction, granted "may send packets to any host on the internet." That isn't a capability, it's a weapon. So the destination is part of the grant.
| Grant | Means |
|---|---|
--allow-udp=1.1.1.1:53 |
that address, that port |
--allow-udp=1.1.1.1 |
that address, any port |
--allow-udp=10.0.0.0/8:7946 |
that subnet, that port |
--allow-udp (bare) |
any peer — trusted hosts only |
--allow-udp +
--allow-serve=ADDR:PORT |
additionally: may open a port and hear from strangers |
Host names are resolved first, then the resulting address is checked. So a name that resolves outside your allowlist is refused — a name-matching allowlist couldn't do that.
#Talking to a server
(udp.connect)
A connected socket is locked to one peer: you may only send there, and datagrams from anyone else are discarded. This is the DNS-client, QUIC-client, game-client shape.
import udp
import bytes
fn ask_once(sock, query)
udp.send(sock, query)?
match udp.recv(sock, { timeout_ms = 1000 })
Ok(dg) -> return Ok(dg.data)
Error(e) -> return Error(e)
end
end
-- Retrying is not the error path. It IS the path. Nothing will tell you your packet was lost.
fn ask(sock, query, attempts)
let i = 0
while i < attempts do
match ask_once(sock, query)
Ok(reply) -> return Ok(reply)
Error(e) -> i = i + 1
end
end
return Error("no answer after retries")
end
let sock = udp.connect("1.1.1.1", 53)?
match ask(sock, my_query, 3)
Ok(reply) -> print(`got {len(reply)} bytes`)
Error(e) -> print(`resolver never answered: {e}`)
end
udp.close(sock)
Note there is no ? on udp.recv inside
ask_once — a timeout is an ordinary outcome here, not an
exception. See examples/dns_query.scua for a complete, real
DNS query.
#Running a server
(udp.bind)
udp.bind hears from anyone on the
internet. That's listen authority, so it needs
both --allow-udp and
--allow-serve — two facts, two grants, and granting one
never smuggles in the other:
scua --allow-udp --allow-serve=127.0.0.1:9010 echo.scua
let s = udp.bind("127.0.0.1", 9010)?
match udp.recv(s, { timeout_ms = 10000 })
Ok(dg) -> udp.reply(s, dg.peer, dg.data)
Error(e) -> print(`recv: {e}`)
end
#dg.peer is a
handle, not an address
You can read it — udp.peer_addr(dg.peer) gives
you "10.0.0.7:54321" for logs — but you cannot
forge one. That distinction is doing real work:
udp.reply(sock, peer, data)answers someone who reached you. It needs no allowlist entry, because the address came from the network rather than from your script. (Same as writing back on an accepted TCP connection, which needs no--allow-net.)udp.send_to(sock, host, port, data)sends somewhere you named. That's authority, so it is allowlist-checked.
#The 3× rule (why your big reply got refused)
If you reply to a peer with substantially more data than it sent you, you'll get:
anti-amplification limit: a peer may be sent at most 3x the bytes it has sent us
This is not a bug. Here's the attack it stops:
- An attacker sends you a tiny datagram, forging the victim's address as the sender. UDP has no handshake, so you cannot tell.
- You answer with something big.
- You just sent that traffic to the victim — and so did every other server the attacker sprayed.
That's a reflection/amplification DDoS, and it's why memcached-over-UDP (51,000× amplification) was effectively deleted from the internet. SCUA caps every peer at 3× the bytes it has actually sent you (the rule QUIC uses, RFC 9000 §8), so a spoofed source earns the victim almost nothing. A peer that has sent you nothing can be sent nothing.
If you need to return more data than that, you need the peer to prove it's really there first — send a small challenge, make it echo the value back, and only then send the big payload. That's exactly what QUIC's Retry token does.
#Size: keep it under 1200 bytes
The default payload cap is 1200 bytes, and going
over is a loud Error, never a silent truncation. That
number isn't arbitrary: it's QUIC's minimum, and it sits under the
smallest MTU you'll meet in the wild, so a datagram that size crosses
essentially any path without being fragmented.
Fragmentation matters more than it sounds. A fragmented datagram is all-or-nothing — lose one fragment and the whole thing is gone — so fragmenting multiplies your loss rate exactly when you can least afford it. If you have more than 1200 bytes to say, send several datagrams and reassemble them yourself, or use TCP.
Raise it with --udp-max-payload=N if you know your
path.
#In a hot loop, use
udp.recv_batch
udp.recv gets you one datagram. That's
the right shape for a request/response exchange — a DNS query, a health
probe — where you send one thing and wait for one answer.
For a receive loop (a game server, a gossip node,
anything draining a stream of packets), use
udp.recv_batch:
match udp.recv_batch(s, 64, { timeout_ms = 1000 })
Ok(batch) -> for dg in batch do
handle(dg.peer, dg.data)
end
Error(e) -> print(`recv: {e}`)
end
It waits for one datagram, then takes everything else already queued,
up to max (capped at 64). It does not wait
for the batch to fill — that would trade away the latency you chose UDP
for in the first place.
Why it matters, with numbers. Under
--io=async every udp.recv costs a thread
handoff (~260 µs), which caps a one-at-a-time receive loop at roughly
3,500 datagrams/s — that's below what a
64-player 60 Hz game server needs (3,840/s). recv_batch
pays that handoff once for the whole burst and clears
13,600/s. On the blocking platform it's 42,000/s versus
143,000/s. Same correctness either way — same peer handles, same 3×
reply rule — just far less overhead per packet.
Rule of thumb: one exchange → recv. A stream →
recv_batch.
#Blocking or async?
By default a udp.recv blocks the
worker, exactly as net.accept does. That's fine
for a single-purpose tool, and it's the fastest path if the process has
nothing else to do.
Pass --io=async and a receive inside an actor handler
parks instead: the wait moves off the interpreter
thread, so other partitions keep running while you're waiting for a
packet. The script doesn't change — no async, no
await, no coloring. Only the platform does.
#Things that will bite you
Error(interrupted)means the partition was snapshotted mid-wait. For UDP this is indistinguishable from a dropped packet, so handle it the same way — which usually means "do nothing special." This is the one place unreliability is a gift: the snapshot rule costs you nothing.- Build payloads with
..— onbytes,..is binary concatenation, sopayload = payload .. chunkdoes what you'd expect. It will not mix bytes with text, though:b"id=" .. "42"is a loud error, not a conversion. Convert explicitly withbytes.from_string/bytes.to_string. - There is no "connection", so nothing will ever tell you a peer went away. If you need to know, you have to ask (a heartbeat) and time out. That's your job, not the protocol's.
#Where this doesn't work
In a browser you cannot open a raw UDP socket. Not
from SCUA, not from JavaScript, not from anything — the sandbox forbids
it, for exactly the amplification reasons above. import udp
is simply not available in a wasm build.
The browser's real answer is WebTransport datagrams
(unreliable, unordered, over QUIC), which a host can wire up behind
udp.connect. What a browser can never do is
udp.bind, because a web page cannot be a server — which is
already true of net.listen and isn't a new limitation.
#See also
examples/udp_echo.scua— a bound echo server, with the reply rule explained inlineexamples/dns_query.scua— a real DNS query, by hand- Fetch over HTTP — when you want delivery guarantees, use TCP
- Serve HTTP requests — the
servegrant, whichudp.bindalso needs