UDP

bind() in runtime:net is a UDP socket. It has send/receive rather than the web streams TCP uses, because a datagram arrives whole and carries its own sender — the two things a byte stream erases.

Everything here needs NetListen to bind, and Net to send. Why both.

Bind

JavaScript
import { bind } from "runtime:net";

const sock = bind({ hostname: "0.0.0.0", port: 5353 });
const { port } = await sock.addr;          // port 0 picks an ephemeral one

Receive

receive() gives one datagram. The async iterator is the same thing in a loop, and it ends when the socket closes.

JavaScript
for await (const { data, address, port } of sock) {
  await sock.send(data, { hostname: address, port });   // echo to the sender
}

A zero-length datagram is a message, not an end of stream — receive() returns { data: Uint8Array(0), … }, and only null means closed.

Send

JavaScript
await sock.send(bytes, "203.0.113.7:5353");             // "host:port"
await sock.send("hello", { hostname: "203.0.113.7", port: 5353 });

A string is UTF-8 encoded. One call is one datagram: it is never split, and it resolves with the number of bytes sent.

A client that only sends

StatsD, syslog and most telemetry never wait for anything. connect() fixes the peer, so sends need no address — and datagrams from anyone else are discarded, which is what makes it more than a shortcut.

JavaScript
import { bind } from "runtime:net";

const statsd = bind({ hostname: "0.0.0.0", port: 0 });
await statsd.connect("127.0.0.1:8125");

await statsd.send("page.view:1|c");
await statsd.send("render.ms:12|ms");

No packet is sent by connect() — UDP has no handshake — so it succeeds against a host that is not listening. What it buys is the OS reporting later failures (an ICMP port-unreachable surfaces as an error on a subsequent send or receive), which an unconnected socket never learns about.

Request and response

JavaScript
import { bind } from "runtime:net";

const sock = bind({ hostname: "0.0.0.0", port: 0 });
await sock.connect("1.1.1.1:53");
await sock.send(query);                                  // a DNS query

const answer = await Promise.race([
  sock.receive(),
  new Promise((r) => setTimeout(() => r(null), 2000)),    // your own timeout
]);

The timeout is yours to write. Nothing here retries and nothing times out on its own: a lost datagram is silent, and only the program knows how long an answer is worth waiting for.

Batches

sendMany and receiveMany move many datagrams per host crossing. They save the crossing, not the syscalls — the OS still sees one send per datagram — so they pay off on a busy socket, not on a single exchange.

JavaScript
// One crossing, three datagrams, one destination.
await sock.sendMany(["a", "b", "c"], "127.0.0.1:8125");

// …or a destination each.
await sock.sendMany([
  { data: metric, address: "10.0.0.1:8125" },
  { data: log, address: "10.0.0.2:514" },
]);

// A datagram, plus whatever had already queued behind it.
for (;;) {
  const batch = await sock.receiveMany();      // default: up to 32
  if (batch === null) break;                   // closed
  for (const d of batch) handle(d);
}

receiveMany never waits for a batch to fill: it waits for the first datagram exactly as receive() does, then takes what is already in the kernel. A batch is never empty, and a partly-sent sendMany reports how many datagrams left before it failed.

Multicast

Membership is dynamic; the socket options are set at the bind. interface names which local interface carries the membership — an IPv4 address for a v4 group, an interface index for a v6 one — and the OS chooses if you omit it, which is only unambiguous on a host with one interface.

JavaScript
import { bind } from "runtime:net";

const sock = bind({
  hostname: "0.0.0.0", port: 5353,
  reuseAddress: true,          // another process may hold this port too
  multicastTtl: 1,             // stay on the local segment
  multicastLoopback: false,    // don't hear your own announcements
});

await sock.joinMulticast("224.0.0.251");                 // mDNS
await sock.send(query, "224.0.0.251:5353");

for await (const { data, address } of sock) {
  console.log("responder", address);
}

await sock.leaveMulticast("224.0.0.251");

Naming a source makes it source-specific multicast (RFC 4607, IPv4): the network delivers that sender's traffic and drops everyone else's, so the filter costs this program nothing and cannot be talked past.

JavaScript
await sock.joinMulticast("232.1.1.1", { source: "198.51.100.7" });
// …and left with the source named again — to the OS these are two different
// memberships, not one with a filter attached.
await sock.leaveMulticast("232.1.1.1", { source: "198.51.100.7" });

reuseAddress is what lets a second process on the same machine receive the same group — without it the second bind fails with ERR_ADDRESS_IN_USE, which is the usual reason an mDNS or SSDP responder works alone and stops working next to avahi or a system service.

Broadcast

JavaScript
const sock = bind({ hostname: "0.0.0.0", port: 0, broadcast: true });
await sock.send(payload, "255.255.255.255:9");           // wake-on-LAN, discovery

Off by default: the OS refuses a broadcast send without it, and addressing every host on a segment is worth saying out loud. IPv4 only — IPv6 has no broadcast, so asking for it on a v6 socket is an error rather than a flag that sets nothing.

Socket options

Set at the bind, because three of them (reusePort, reuseAddress, ipv6Only) mean nothing afterwards. An omitted option leaves the OS default rather than a value chosen for you.

OptionWhat it does
reusePortShare the address across processes; the kernel distributes datagrams. Unix only.
reuseAddressShare it with another socket — what two processes on one multicast group need.
broadcastPermit sending to the broadcast address. IPv4 only.
ttlHop limit for unicast datagrams (0–255).
multicastTtlHop limit for multicast datagrams. The OS default is 1 — the local segment.
multicastLoopbackWhether your own multicast sends come back to this host.
ipv6OnlyFor a v6 bind: IPv6 only, or also IPv4 through v4-mapped addresses. The platform default differs, so say which if it matters.

The address family picks the v4 or v6 spelling of each, so a ::1 socket gets the IPv6 option and not a v4 one that would silently set nothing.

Five of them can also change after the bind:

JavaScript
await sock.setTtl(64);
await sock.setMulticastTtl(4);
await sock.setBroadcast(true);
await sock.setMulticastLoopback(false);
await sock.setMulticastInterface("192.168.1.10");   // outgoing multicast

reusePort, reuseAddress and ipv6Only have no setters on purpose: they must be set between socket() and bind(), so a setter would be one that quietly did nothing.

What UDP does not promise

Worth reading once before shipping a UDP service, because none of it is the runtime's to fix:

  • Delivery. A datagram may be dropped by any hop, including this host's own socket buffer when a program stops calling receive(). Nothing reports it.

  • Order. Two datagrams may arrive in either order, or twice.

  • Backpressure. There is none. A sender that outruns a receiver simply loses datagrams; the send keeps succeeding.

  • A size that always fits. Stay at or under 1200 bytes of payload to cross the internet without IP fragmentation (what QUIC and DNS assume); the hard ceiling is 65,507. A datagram that does not fit arrives with truncated: true and the rest gone — impossible over IPv4, which cannot carry one that large, so it is an IPv6 jumbogram that reaches it.

  • Who sent it. A source address is trivially forged. Treat address as a hint, not an identity, and never send a large answer to a small unverified request — that is the shape of a reflection attack.

Keeping the process alive

A pending receive() is a reason for the process to stay running — which is what a server wants, and what a background listener does not. unref() says so:

JavaScript
const control = bind({ hostname: "127.0.0.1", port: 0 });
control.receive().then(handleControlPacket);
control.unref();          // …but don't hold the process open for it

ref() undoes it. A parked receive keeps working either way — this changes what the event loop counts, not what the socket does. Note the difference from Node: here a bound socket with nothing in flight is already not a reason to stay alive, matching listen(); only a receive in flight is.

Two capabilities

Binding takes a port — NetListen — and sending reaches a peer — Net. A UDP socket is a server and a client at once, so it is checked against both rather than whichever one it was created under:

Shell
esrun --allow-listen=5353 responder.js          # receive only
esrun --allow-listen=127.0.0.1:0 \
      --allow-net=127.0.0.1:8125 statsd.js                 # send to one collector

A program that only sends still needs listen: its ephemeral source port is a port, and the replies that come back to it are inbound traffic. --allow-net is checked on every destination, not once at the bind, because one socket sends to as many peers as it likes.

Coming from Node, Deno or Bun

esrunNode.jsBunDeno
Bindbind({ port })dgram.createSocket("udp4") + .bind(port)await Bun.udpSocket({ port })Deno.listenDatagram({ port, transport: "udp" })
Receiveawait sock.receive().on("message", (msg, rinfo) => …)socket: { data(sock, buf, port, addr) {} }await conn.receive()
Sendawait sock.send(data, addr).send(buf, port, host, cb)sock.send(buf, port, host)await conn.send(data, addr)
Connected socketawait sock.connect(addr).connect(port, host){ connect: { port, hostname } }
MulticastjoinMulticast / leaveMulticastaddMembership / dropMembershipaddMembership / dropMembershipjoinMulticastV4 / .leave()
Batch sendsendMany()sendMany()
Batch receivereceiveMany()
Source-specific multicast{ source }addSourceSpecificMembershipaddSourceSpecificMembership
ref() / unref()
Truncation reported
Availabilitystable (pre-1.0)stablestableunstable (--unstable-net)
Permission modellisten and netnonenone--allow-net

The same echo server, four ways:

JavaScript
// esrun — a promise per datagram
import { bind } from "runtime:net";
const sock = bind({ hostname: "0.0.0.0", port: 5353 });
for await (const { data, address, port } of sock) {
  await sock.send(data, { hostname: address, port });
}
JavaScript
// Node.js — an event per datagram
import dgram from "node:dgram";
const sock = dgram.createSocket("udp4");
sock.on("message", (msg, rinfo) => sock.send(msg, rinfo.port, rinfo.address));
sock.bind(5353);
JavaScript
// Bun — a callback on the socket options
const sock = await Bun.udpSocket({
  port: 5353,
  socket: { data(socket, buf, port, addr) { socket.send(buf, port, addr); } },
});
JavaScript
// Deno — a promise per datagram, behind --unstable-net
const conn = Deno.listenDatagram({ port: 5353, transport: "udp" });
for await (const [data, from] of conn) {
  await conn.send(data, from);
}

See also

Last updated on
Edit this page