Get E-Book
Network Fundamentals with Node.js

Node.js UDP & dgram: Broadcast, Multicast & Connect

Ishtmeet Singh @ishtms/May 11, 2026/41 min read
#nodejs#networking#udp#dgram#sockets

Node.js exposes UDP through the node:dgram module. UDP is different from TCP because it works with individual messages. A UDP socket sends one datagram at a time and receives one datagram at a time. Node gives each received datagram to your code as one message event.

That single idea drives the whole API. There is no stream of bytes to assemble. There is no connection setup like TCP. There is no built-in retry when a packet disappears. A successful send only means your local machine accepted the datagram for sending. It does not mean the remote machine received it, processed it, or even had a program listening.

When you build on UDP, you decide how much the protocol should care about missing messages, repeated messages, late messages, and messages arriving out of order.

UDP and dgram in Node.js

UDP starts with message boundaries. Each call to send() creates one datagram payload. Each received datagram becomes one message event. TCP behaves very differently because TCP gives you a byte stream, where one write on the sender does not guarantee one read event on the receiver.

Here is a tiny UDP receiver -

js
import dgram from 'node:dgram';

const socket = dgram.createSocket('udp4');

socket.on('message', (msg, rinfo) => {
  console.log(msg.toString(), rinfo.address, rinfo.port);
});

socket.bind(41234, '127.0.0.1');

Run that process first, then send one payload at it -

js
import dgram from 'node:dgram';

const socket = dgram.createSocket('udp4');

socket.send('ping', 41234, '127.0.0.1', err => {
  if (err) throw err;
  socket.close();
});

The receiver gets one Buffer containing ping. It also gets rinfo, which tells you where the datagram came from. That metadata includes the sender's address and port.

This is the UDP reading model in Node - one delivered datagram becomes one message event.

A UDP datagram carries one application payload plus a small UDP header. That header has a source port, destination port, length, and checksum. IP handles the address and routing part below UDP. Node's node:dgram module sits close enough to UDP that the transport rules affect how you design the application protocol.

TCP gives Node a connected byte stream. UDP gives Node individual messages. UDP does not track connections, ordering, acknowledgements, retries, or duplicate suppression. If your application needs those behaviors, you add them in the payload and in your own state, or you use a protocol that already provides them.

Datagram Semantics

UDP preserves message boundaries. If one process sends 12 bytes in one socket.send(), the receiver gets those 12 bytes as one message when the datagram arrives. If the sender sends two 6-byte datagrams, the receiver gets two separate messages when they arrive.

The word "when" is doing real work there. A datagram can arrive late. Two datagrams can arrive in another order. One of them can disappear. In rare cases, the receiver can observe a repeated payload.

js
socket.send('one', 41234, '127.0.0.1');
socket.send('two', 41234, '127.0.0.1');

Those calls create two UDP datagrams. On loopback, you will usually see both in order because everything stays inside the local machine. That result is useful for a quick test, but it is not the UDP guarantee.

Across a real network, the receiver may see one then two, two then one, only one of them, neither of them, or a repeated payload if the network duplicates a packet. Your protocol decides which of those outcomes are acceptable.

Packet loss means a datagram sent by one endpoint never reaches the receiving application. It may be dropped by the sender's kernel, a local firewall, a router, a remote firewall, the receiver's kernel, or a full receive buffer. UDP does not ask anyone to send the missing payload again.

Packet reordering means datagrams arrive in another order from the send order. Routing, host scheduling, interface queues, and receive processing can all change timing. UDP does not include an application message sequence number. If your receiver needs to notice reordering, put a sequence number in the payload.

Packet duplication means the receiver observes more than one copy of what looks like the same datagram. It is less common than loss, but a UDP application should still know what duplicates would do. If duplicates would cause trouble, give each message an identifier and keep a short memory of recently processed IDs.

Here is a tiny duplicate-suppression sketch -

js
const seen = new Set();

socket.on('message', msg => {
  const id = msg.subarray(0, 8).toString('hex');
  if (seen.has(id)) return;
  seen.add(id);

  // Continue processing the message.
});

That snippet only handles the first tiny step. The payload has to contain an ID that makes duplicate detection possible. UDP only gives you bytes and the sender metadata.

The UDP header is small - source port, destination port, length, and checksum. The length covers the UDP header plus payload. The checksum lets the receiver reject corrupted transport data when checksum validation applies. IPv4 allows a zero UDP checksum for normal UDP. IPv6 requires UDP checksum coverage for regular UDP.

Node does not emit a message event for datagrams the kernel rejects during checksum validation. JavaScript only sees datagrams the kernel accepted for that socket.

That can confuse debugging. A packet capture might show traffic on the interface while your Node handler stays quiet. The packet may have failed checksum validation, address matching, firewall rules, socket filtering, or receive-buffer admission before Node ever had a chance to emit message.

The source port is also worth reading carefully. Many UDP servers listen on a fixed destination port, while clients use temporary source ports assigned by the OS. The server replies to the source address and source port from rinfo.

js
socket.on('message', (msg, rinfo) => {
  console.log(`${rinfo.address}:${rinfo.port}`, msg.length);
});

That remote address and port identify where this datagram came from. They are not a session. If the same peer sends again from another source port, Node reports another tuple. If a NAT device rewrites the source port, Node reports the rewritten value because that is what reached the local machine.

Application protocols usually put a message type near the beginning of the payload. That lets the receiver decide what to do without guessing from the source address.

js
socket.on('message', msg => {
  if (msg.length < 1) return;

  if (msg[0] === 1) handleHeartbeat(msg);
  if (msg[0] === 2) handleMeasurement(msg);
});

Length checks come first. UDP makes it easy to receive a short payload, an empty payload, or bytes from a sender using another protocol on the same port. The kernel checks UDP fields. It does not validate your application format.

Datagram size also needs discipline. A UDP datagram has one payload, and the practical payload size depends on IP packet size and the path MTU from Chapter 9.1. A payload can fit the theoretical UDP limit and still be a poor choice for real networks because IP fragmentation may be needed below it. If one fragment is lost, the whole UDP payload is lost from the receiver's point of view.

For ordinary IPv4 UDP, the maximum UDP payload is 65,507 bytes - 65,535 bytes of IPv4 packet size minus a 20-byte IPv4 header and an 8-byte UDP header. IPv6 uses different header rules. Real applications usually choose payloads far below those limits because Ethernet paths, tunnels, VPNs, and cloud overlays reduce the safe size.

Node will let you try a large send. The OS may reject it -

js
const payload = Buffer.alloc(70_000);

socket.send(payload, 41234, '127.0.0.1', err => {
  console.error(err?.code);
  socket.close();
});

On many systems, that prints EMSGSIZE. The exact result depends on address family, platform, interface, and route state.

The send callback belongs to the local send request. It can report local failure. It cannot report peer receipt because UDP has no transport-level acknowledgement from the receiver.

Payload sizing is one of the easiest places for local tests to mislead you. Loopback may carry large datagrams because the path stays inside the local stack. A real path may include Ethernet, Wi-Fi, VPN encapsulation, cloud overlay headers, or a tunnel. Each layer consumes space below your application payload. If the final IP packet is too large for the path, fragmentation or rejection becomes possible.

Many production UDP protocols stay under roughly 1,200 bytes per payload when they must cross unknown Internet paths. That number is protocol guidance, not a Node rule. On a private LAN with known MTU, you may choose something else. The Node rule is simple - socket.send() takes bytes, and the network path decides whether those bytes are practical as one datagram.

The node:dgram Surface

node:dgram is Node's built-in UDP module. It creates datagram sockets, sends datagrams, receives datagrams, joins multicast groups, and exposes socket controls. The API is lower-level than node:http and narrower than node:net because UDP gives you fewer built-in behaviors than TCP.

Create an IPv4 or IPv6 UDP socket like this -

js
import dgram from 'node:dgram';

const udp4 = dgram.createSocket('udp4');
const udp6 = dgram.createSocket('udp6');

The type chooses the address family. udp4 creates an IPv4 UDP socket. udp6 creates an IPv6 UDP socket. That choice affects address parsing, wildcard binds, multicast behavior, and connected UDP defaults.

A dgram.Socket is the JavaScript object Node gives you for a UDP socket. It extends EventEmitter, so events such as message, listening, error, and close follow the EventEmitter behavior from Chapter 7.4.

The JavaScript object wraps native socket state. The kernel still owns the local port, receive queue, send queue, address filters, and multicast membership state. Your object is the handle you use to operate that socket from JavaScript.

dgram.createSocket() accepts a type string or an options object -

js
const socket = dgram.createSocket({
  type: 'udp4',
  reuseAddr: true
});

reuseAddr asks Node to set address-reuse behavior for the underlying socket. You see it often with multicast receivers and with some local restart workflows. Platform details around address reuse can be tricky, so choose it deliberately instead of adding it everywhere.

The options object can also request receive and send buffer sizes in current Node.js releases. For udp6, it can also request IPv6-only behavior. Those settings still pass through OS policy. The kernel may clamp, round, or reject values.

js
const socket = dgram.createSocket({
  type: 'udp6',
  ipv6Only: true
});

ipv6Only controls whether the IPv6 socket should avoid IPv4-mapped address behavior. The full dual-stack discussion belongs with socket options in Chapter 9.6. During early UDP debugging, make the address family explicit and use numeric addresses first.

A datagram socket preserves message boundaries. It can receive from many remote endpoints through one local port. It can send to many remote endpoints from the same local socket. Binding chooses the local address and port. Sending chooses the remote address and port, unless the socket has a connected UDP peer configured.

The common lifecycle looks like this -

text
create socket
  -> bind local address and port
  -> receive message events
  -> send datagrams
  -> close

There is no accept step. A UDP server has one socket that receives datagrams from whichever peers the kernel admits through local filtering. Node reports each peer through rinfo.

js
socket.on('message', (msg, rinfo) => {
  console.log(rinfo);
});

rinfo includes the remote address, remote port, address family, and message size. That remote port often comes from the sender's ephemeral port. It is usually the address you reply to in an echo-style protocol.

dgram.Socket supports ref() and unref() like other Node handles. A UDP socket with an active native handle can keep the process alive while it can receive work. Calling unref() lets the process exit if the UDP socket is the only remaining active handle.

js
const socket = dgram.createSocket('udp4');

socket.unref();
socket.bind(41234);

That pattern appears in telemetry emitters and discovery helpers that should run only while the process has other work. It is easy to misuse in servers because the process can exit while the socket is still bound. Use it only when some other part of the program owns process lifetime.

The close event means Node closed the socket handle. After that, the JavaScript object still exists, but its native socket is gone. Later sends or binds on the same object fail through Node's socket state checks. Create a new socket for a new lifetime.

Binding and Receiving

socket.bind() attaches the UDP socket to a local port and optionally a local address. A bound socket can receive datagrams addressed to that local socket address.

js
const socket = dgram.createSocket('udp4');

socket.on('listening', () => {
  console.log(socket.address());
});

socket.bind(41234, '0.0.0.0');

The listening event fires after bind completes. For UDP, listening means the socket is bound and ready to receive datagrams. There is no TCP accept queue and no per-peer connection state.

When the address is omitted, the OS binds to the wildcard address for the socket family. For udp4, that means the IPv4 wildcard path. For udp6, that means the IPv6 wildcard path, with platform and socket-option behavior deciding dual-stack behavior.

When the port is omitted or set to 0, the OS chooses an ephemeral port -

js
socket.bind(0, '127.0.0.1', () => {
  console.log(socket.address().port);
});

That pattern is useful for tests and temporary clients that need a stable local socket without hard-coding a port. The chosen port belongs to the local process until the socket closes.

Bind can fail. Common errors include EADDRINUSE when another socket owns the same local address and port combination, EADDRNOTAVAIL when the address is not present on the host, and EACCES for permission-sensitive ports or platform policy. Node emits error for asynchronous socket errors. Some invalid API calls throw before native work starts.

Binding to a specific address narrows what the socket can receive. Binding to 127.0.0.1 receives loopback traffic. Binding to a LAN address receives datagrams addressed to that interface address. Binding to the wildcard address lets the kernel deliver datagrams addressed to suitable local addresses for that family.

js
socket.bind({ port: 41234, address: '127.0.0.1' });

The object form is easier to read once you add options such as exclusive. In ordinary single-process UDP code, the positional form and object form reach the same kind of kernel bind. Clustered or shared-handle setups have more ownership details and belong outside this foundation chapter.

A bound UDP socket can receive from many peers without any per-peer setup -

js
const peers = new Map();

socket.on('message', (msg, rinfo) => {
  const key = `${rinfo.address}:${rinfo.port}`;
  peers.set(key, Date.now());
});

That map is application state. The kernel does not create one connected socket per peer for you. If a peer goes quiet, UDP sends no close notification. Your code decides how long a remembered peer should stay in memory.

The rinfo.size field is the received payload size in bytes. It should match msg.length for the delivered buffer. Prefer msg.length when parsing because the bytes live in msg. Use rinfo for remote metadata.

Add the error listener before binding in examples and small tools -

js
socket.on('error', err => {
  console.error(err.code);
  socket.close();
});

A UDP process with an unhandled error event can exit the same way any EventEmitter can.

The main receive event is message -

js
socket.on('message', (msg, rinfo) => {
  socket.send(msg, rinfo.port, rinfo.address);
});

That is a UDP echo server. It replies to the address and port from the incoming datagram. The reply is a new datagram. There is no per-client socket. There is no connection object for that peer unless your application creates one in JavaScript.

The msg argument is a Buffer. It contains the payload bytes from exactly one datagram. If your protocol is text, decode it. If your protocol is binary, parse fields from the buffer. Chapter 2 covered Buffer mechanics. The UDP-specific rule is that this buffer represents one protocol message.

js
socket.on('message', msg => {
  if (msg.length < 5) return;

  const type = msg.readUInt8(0);
  const value = msg.readUInt32BE(1);

  console.log(type, value);
});

The parser can rely on msg belonging to one datagram. It still has to validate length before reading fields. A short, malformed, or hostile payload can reach the handler.

socket.close() closes the underlying socket and stops new receive events. A close callback attaches to the close event -

js
socket.close(() => {
  console.log('closed');
});

Closing a UDP socket discards local receive state. Datagrams may still exist in the network or in OS queues, but this JavaScript socket will not deliver more message events.

Payload parsing deserves care because UDP gives your code one complete message with no higher-level schema. A text payload can be invalid UTF-8. A binary payload can be shorter than expected. A sender can use the right port with the wrong protocol. UDP does not negotiate content for you.

Here is a parser that accepts exactly six bytes -

js
socket.on('message', msg => {
  if (msg.length !== 6) return;

  const type = msg.readUInt16BE(0);
  const value = msg.readUInt32BE(2);

  handle(type, value);
});

The early length check protects the reads. readUInt32BE(2) needs four bytes starting at offset two. Without the check, malformed input becomes a JavaScript exception inside the receive handler. Exceptions in message listeners follow ordinary EventEmitter behavior and can take down the process if they escape.

For binary-framed text protocols, check the header before decoding text -

js
socket.on('message', msg => {
  if (msg.length < 1) return;
  if (msg[0] !== 1) return;

  const name = msg.subarray(1).toString('utf8');
  handleName(name);
});

The subarray() call creates a Buffer view over the same memory. Chapter 2 covered Buffer views. For UDP handlers, the practical rule is simple - if the bytes will be stored for later, store parsed values or copy the bytes intentionally. If the handler processes them immediately, passing the Buffer through synchronous code is usually fine.

Zero-length datagrams are valid UDP payloads. Node can deliver an empty Buffer in message.

js
socket.on('message', msg => {
  if (msg.length === 0) handleEmptyProbe();
});

Some discovery protocols use empty or tiny probes because the address and port metadata carry most of the signal. Your protocol decides whether an empty payload is valid.

Source address validation is application logic. rinfo.address and rinfo.port say where the datagram appeared to come from in the received packet. UDP does not prove peer identity. Local networks, NAT, spoofing rules, and firewall policy all affect how much you should trust that tuple. For a private health probe, it may be enough. For security-sensitive behavior, add authentication at the protocol layer.

One dgram.Socket can handle many logical peers, but the handler still runs on the JavaScript thread. A slow parser delays every other peer sharing that socket. That is acceptable for low-rate control traffic. It becomes painful for high-rate telemetry or realtime data. The receive queue below Node does not know which peer your application cares about most. It drops datagrams when the queue is full.

Keep the receive handler simple. Validate length. Parse the smallest useful header. Move expensive work away from the immediate event path. Count accepted, rejected, and malformed messages separately. Those counters will never fully describe network loss, but they make your local parser behavior visible.

Version fields are cheap and useful. A one-byte version at the front of the payload gives the receiver a quick way to reject old senders, new senders, and random traffic on the same port.

js
socket.on('message', msg => {
  if (msg.length < 2) return;
  if (msg[0] !== 1) return;

  dispatch(msg[1], msg.subarray(2));
});

That is still a tiny protocol, but it gives you two useful properties. The receiver can reject unknown versions before parsing the body, and the message type is explicit. UDP gives you bytes. Your protocol should make those bytes easy to identify.

For binary messages, reserve fields intentionally. A spare flag byte or reserved integer gives later versions room to evolve without changing the whole datagram layout. The receiver can require reserved fields to be zero today and reject messages that set them early. That helps during rolling deploys where old and new processes may share a port for a while.

Small headers beat guesswork. They make logs, packet captures, and compatibility checks easier to read.

The Path Through Node and libuv

The JavaScript object is only the part you touch directly.

dgram.createSocket('udp4') creates a JavaScript dgram.Socket, sets up EventEmitter state, records the address family, and prepares native binding state. JavaScript calls into Node's UDP binding. Node uses libuv's UDP handle type to talk to the operating system socket API.

libuv is the event-loop layer here. For UDP, libuv exposes uv_udp_t for socket state and uv_udp_send_t for individual send requests. V8 does not receive packets directly. V8 runs your JavaScript callback after Node has already gone through native code, libuv, and the kernel.

A bind call follows this path -

text
dgram.Socket.bind()
  -> Node UDP binding
  -> libuv UDP handle
  -> OS UDP socket
  -> bind local address and port

After bind, Node starts receiving on that handle. Underneath, libuv registers interest in readability for the UDP socket with the platform event backend. On Linux, that usually goes through epoll. On macOS and BSD systems, it usually goes through kqueue. On Windows, it uses IOCP-oriented machinery.

The backend differs by platform, but Node receives the same kind of handoff. When the OS reports UDP receive work, libuv calls back into Node, and Node emits message.

The kernel stores received UDP datagrams in the socket receive queue. Each queued datagram has payload bytes plus peer address metadata. When Node reads one, the OS copies that payload into memory supplied by the native path. Node then gives JavaScript a Buffer and the rinfo metadata.

There is an important operational result. Stream backpressure does not apply to dgram.Socket receive events. A dgram.Socket is an EventEmitter, not a Readable stream. If JavaScript spends too long doing CPU work, the event loop stops draining receive callbacks. The kernel receive queue can fill. Once it is full, later UDP datagrams are dropped. The sender does not receive a normal UDP signal for that drop.

Receive buffers exist below Node. You can query or request sizes with getRecvBufferSize() and setRecvBufferSize(), subject to OS limits. Chapter 9.6 covers buffer tuning. The local rule is enough for now - a larger receive buffer can absorb a burst, but it cannot make UDP reliable. It only changes how much the local kernel can queue before dropping.

Sends follow their own local path -

text
socket.send(Buffer)
  -> validate target or connected peer
  -> optional DNS lookup for hostnames
  -> libuv UDP send request
  -> OS send path
  -> callback or error

When the target address is a hostname, Node resolves it before it has a numeric address for the UDP send. That lookup can delay the send and can fail with DNS errors from Chapter 9.2. Numeric addresses skip name resolution.

The send callback belongs to the local send request. For a buffer payload, the callback is the point where it is safe to reuse or mutate memory that was needed for the send. This rarely affects small examples. It affects high-rate UDP code that reuses buffers.

js
const buf = Buffer.from('stats');

socket.send(buf, 41234, '127.0.0.1', err => {
  if (err) console.error(err.code);
});

The callback can report local errors such as DNS failure, invalid address family, oversized datagram, closed socket state, or platform send failure. It cannot report application-level handling by the peer. The receiver may be absent. A firewall may drop the packet. The remote process may be overloaded. The local callback can still run with no error.

Send buffering also works differently from TCP streams. TCP has a byte stream, peer flow control, and drain behavior. UDP sends are individual datagram requests. The OS may queue them briefly, but there is no per-peer receive-window negotiation and no stream drain signal.

Node v24 exposes send queue inspection methods for dgram sockets. Those methods describe local queued send requests. They do not describe remote delivery.

Receive readiness enters JavaScript as message. Send completion enters JavaScript as the send callback. Errors can appear as callbacks, thrown exceptions, or error events depending on where they happen. Argument validation can fail before native code. Bind and send failures can surface asynchronously. ICMP errors, when the platform reports them to the socket, can surface later and often show up more clearly with connected UDP.

That delayed error behavior is normal for UDP. There is no setup phase where the peer confirms that it is ready. The first sign of a bad remote port might be a later ICMP message. There may also be no sign at all.

Sending Datagrams

socket.send() sends one UDP datagram payload. For an unconnected UDP socket, pass the message, destination port, and destination address.

js
const socket = dgram.createSocket('udp4');

socket.send('hello', 41234, '127.0.0.1', err => {
  if (err) console.error(err.code);
  socket.close();
});

The message can be a string, Buffer, TypedArray, DataView, or an array of supported binary chunks. Strings are encoded as UTF-8 bytes. Binary payloads make size accounting easier because UDP limits are byte limits.

socket.send() can also send part of a buffer with offset and length arguments -

js
const buf = Buffer.from('xxpayloadxx');

socket.send(buf, 2, 7, 41234, '127.0.0.1', err => {
  if (err) console.error(err.code);
});

That sends payload. Offsets and lengths are byte offsets. Multi-byte text characters do not change that rule once the data is already in a Buffer.

An unbound socket can send. Node will bind it implicitly to a wildcard local address and an ephemeral port before sending. That is convenient for one-shot clients.

js
const socket = dgram.createSocket('udp4');

socket.send('probe', 41234, '127.0.0.1', () => {
  console.log(socket.address());
  socket.close();
});

The printed address shows the local port the OS assigned. The receiver sees that source port in rinfo.port. If the receiver replies, it sends back to that port.

For request-response UDP, keep the socket open long enough to receive the reply. The reply targets the source address and port from the original datagram. If you close immediately after send(), the OS can release that local port before the reply arrives.

js
const socket = dgram.createSocket('udp4');

socket.on('message', msg => {
  console.log('reply -', msg.toString());
  socket.close();
});

socket.send('hello', 41234, '127.0.0.1');

There is still no connection. The client is simply keeping its local UDP socket alive so a datagram sent back to its local port can reach the process.

Hostnames work too -

js
socket.send('hello', 41234, 'localhost', err => {
  if (err) console.error(err.code);
});

That send includes name resolution. localhost may resolve to IPv4, IPv6, or both depending on OS and Node lookup behavior. A udp4 socket needs an IPv4 destination. A udp6 socket needs an IPv6 destination unless platform behavior and socket options allow an IPv4-mapped path. Numeric addresses remove that ambiguity while debugging.

The send target has three pieces - remote address, remote port, and address family. The local endpoint has its own address and port. With UDP, a single local endpoint can send to many remote endpoints.

js
for (const port of [41234, 41235, 41236]) {
  socket.send('tick', port, '127.0.0.1');
}

Every call creates a separate datagram. The same local socket can receive replies from all three peers. If your protocol needs to match replies to requests, put request IDs in the payload or keep application state by remote endpoint.

Datagram sends are atomic at the UDP API level. One send request describes one payload. If the OS accepts it and the receiver eventually gets it, the receiver gets that payload as one datagram. Node will not deliver the first half as one message event and the second half as another message event. IP fragmentation can happen below UDP, but reassembly finishes before delivery to the UDP socket. Failed reassembly means no delivered datagram.

Arrays of buffers are useful when your protocol has a small header and a payload you already have in another buffer -

js
const header = Buffer.alloc(3);
header[0] = 2;
header.writeUInt16BE(payload.length, 1);

socket.send([header, payload], 41234, '127.0.0.1');

The header uses one type byte and a two-byte big-endian payload length. Node treats the array as one UDP datagram payload built from the chunks. The chunks do not create separate datagrams. The receiver sees one message event containing the combined bytes.

This can reduce avoidable copying in application code, but it makes length accounting easier to get wrong. Count bytes after encoding. A string's character count and its datagram byte count can differ.

js
const text = 'snowman: \u2603';
const bytes = Buffer.byteLength(text);

socket.send(text, 41234, '127.0.0.1');
console.log(bytes);

The send size is the UTF-8 byte length, not the number of JavaScript string code units. Binary protocols should usually build a Buffer explicitly before sending so the byte layout is visible.

Send callbacks are optional, but skipping them hides local errors. For fire-and-forget telemetry, that may be a deliberate choice. For tools, tests, and service protocols, log the error code while building the path.

js
socket.send(payload, port, host, err => {
  if (err) console.error('udp send failed', err.code);
});

That callback helps you separate local rejection from possible network loss. It still cannot tell you whether the receiver handled the message.

Sending too fast can create local queue pressure. UDP has no TCP-style backpressure signal from a peer, but Node and the OS still have finite queues. Node v24 exposes getSendQueueSize() and getSendQueueCount() on dgram sockets. Those values describe queued send work in Node and libuv, not remote delivery.

js
console.log(socket.getSendQueueCount());
console.log(socket.getSendQueueSize());

If those numbers grow during a burst, JavaScript is producing send requests faster than the local runtime can hand them down. Treat that as a local pacing signal. It says nothing about receiver health.

One more practical detail - socket.send() can bind implicitly. That is convenient, but it can hide the chosen local address and port. In code that expects replies, explicit bind() is easier to debug because the listening callback gives you the local endpoint before the first send.

js
socket.bind(0, '0.0.0.0', () => {
  socket.send('hello', 41234, '192.0.2.20');
});

The local port is chosen once for that socket. Later sends from the same socket use the same local port until the socket closes.

Connected UDP

A connected UDP socket stores a default remote address and port in kernel socket state. It also filters inbound datagrams so the socket receives messages only from that remote peer.

js
const socket = dgram.createSocket('udp4');

socket.connect(41234, '127.0.0.1', () => {
  socket.send('ping');
});

UDP connect() is not the TCP handshake from Chapter 9.3. There is no SYN. There is no accepted socket on the other side. The local kernel records a remote socket address for this UDP socket. Node emits connect when that local association completes, or it calls the callback.

After connect(), socket.send() uses the stored remote endpoint, so you omit the target arguments. socket.remoteAddress() returns that associated endpoint.

js
socket.on('connect', () => {
  console.log(socket.remoteAddress());
});

Connected UDP is useful when one socket talks to one peer. It removes repeated target arguments and lets the kernel reject inbound datagrams from other remote addresses before JavaScript sees them. That filter is local socket behavior. It is not peer authentication.

js
socket.on('message', (msg, rinfo) => {
  console.log('from connected peer -', rinfo.port);
});

For a connected UDP socket, random datagrams from other ports will not reach that handler on typical platforms. The kernel checks the remote tuple before delivery to that socket.

That filter can make measurements cleaner. Suppose a local process sends periodic datagrams to one collector and only expects replies from that collector. A connected UDP socket keeps unrelated packets on the same local port away from JavaScript. The kernel still receives and classifies packets, but your message handler only sees the associated peer.

Connected UDP also fixes the default destination for send() -

js
socket.connect(41234, '127.0.0.1', () => {
  socket.send(Buffer.from([1]));
  socket.send(Buffer.from([2]));
});

Both sends target the same remote socket address. The local source port also stays stable for that socket. That is useful for request IDs, counters, and peer-local state in small protocols.

The local bind can still be explicit -

js
socket.bind(0, '127.0.0.1', () => {
  socket.connect(41234, '127.0.0.1');
});

Here the OS chooses the local ephemeral port during bind, then records the connected UDP peer. Explicit bind helps when the local address affects behavior, such as a host with several interfaces or a test that needs to print the source port before sending.

If you need another peer, creating another socket is usually easier to read than reusing one connected socket. Each JavaScript object then has one peer association and one error stream. Reusing a single socket for several connected peers can work, but timing around in-flight sends and later ICMP errors becomes harder to follow.

disconnect() removes the associated remote endpoint -

js
socket.disconnect();
socket.send('next', 41235, '127.0.0.1');

After disconnecting, the socket can send to explicit targets again. Calling disconnect() on a socket that is already disconnected raises a Node error.

Connected UDP can also make some network errors more visible. If the remote host replies with ICMP Port Unreachable, some platforms report an error to the connected UDP socket. Node can surface that as an error event or send callback error depending on timing and platform behavior. Linux commonly reports ECONNREFUSED for connected UDP after an ICMP port-unreachable response. Other systems differ, and firewalls often drop traffic without sending ICMP.

ICMP Port Unreachable means a host received traffic for a UDP port with no receiver. It is network feedback. It is not a UDP response packet.

This affects debugging. A connected UDP send to a closed local port may produce ECONNREFUSED on one platform. The same code across a network may report nothing because a firewall dropped either the UDP datagram or the ICMP error. Treat ICMP as helpful when it appears, not as something your protocol can rely on.

The timing can also surprise you. The send callback can fire first because the local send completed. The ICMP error can arrive later and surface through the socket. Callback success means the local send request completed. It does not mean the peer port exists.

Unicast, Broadcast, and Multicast

Unicast sends one datagram to one destination socket address. Most examples so far used unicast - 127.0.0.1:41234, one destination address and one destination port.

Broadcast sends one IPv4 datagram to a broadcast address so hosts on the addressed local network can receive it, subject to interface, router, firewall, and socket policy. Node requires broadcast mode on the socket before sending to an IPv4 broadcast address.

js
const socket = dgram.createSocket('udp4');

socket.setBroadcast(true);
socket.send('who is there?', 41234, '255.255.255.255');

255.255.255.255 is the limited IPv4 broadcast address. Directed broadcast addresses such as 192.168.1.255 depend on the local network prefix and network policy. Many routed networks block broadcast forwarding. Local development can succeed on one interface and fail on another because route and interface choice changed.

Broadcast receivers are ordinary bound UDP sockets -

js
const socket = dgram.createSocket('udp4');

socket.on('message', (msg, rinfo) => {
  console.log(msg.toString(), rinfo.address);
});

socket.bind(41234, '0.0.0.0');

Binding to the wildcard address gives the OS room to deliver datagrams received on suitable local IPv4 addresses. Binding to loopback keeps the socket in loopback-only behavior. Interface choice still comes from the OS routing table and socket options.

Broadcast is IPv4 behavior. IPv6 uses multicast for cases where IPv4 code might reach for broadcast. That shows up in Node because setBroadcast() is an IPv4 socket behavior. A udp6 socket uses multicast methods instead.

Directed broadcast needs the network prefix. On a 192.168.1.25/24 interface, the directed broadcast address is commonly 192.168.1.255. On another prefix, it changes. The OS route table and interface mask decide where a directed broadcast send goes. Routers often block directed broadcasts because they can amplify traffic.

Broadcast is easy to lose inside virtualized setups. Docker bridge networks, VM host-only adapters, corporate Wi-Fi isolation, and VPN clients can all change broadcast reachability. Your Node send can be correct while the local network policy prevents receivers from seeing anything.

Multicast sends one datagram to an IP multicast group address. A multicast group is an address that receivers join through the kernel. Senders target the group address. Receivers ask the kernel to deliver datagrams for that group on one or more interfaces.

js
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });

socket.on('message', msg => {
  console.log(msg.toString());
});

socket.bind(41234, () => {
  socket.addMembership('239.255.0.1');
});

239.255.0.1 sits in the administratively scoped IPv4 multicast range. The exact group address should belong to the protocol or environment you control. Random multicast groups can collide with other software on the same network.

addMembership() tells the kernel to join the multicast group. When you pass only the group address, the OS chooses eligible interfaces. Passing an interface address makes the membership more explicit.

js
socket.bind(41234, () => {
  socket.addMembership('239.255.0.1', '192.168.1.25');
});

That second argument is a local interface address for IPv4. IPv6 multicast interface selection uses IPv6 rules and may require scoped interface names in some calls. Production multicast code usually needs environment-specific tests because platform behavior and network policy both affect results.

Leaving a group is explicit -

js
socket.dropMembership('239.255.0.1');

Closing the socket also releases membership state for that socket. Explicit leave calls are still useful in long-running processes where subscriptions change at runtime. A process that joins several groups should track which socket joined which group on which interface.

Sending multicast uses the same send shape as normal UDP, with the group address as the destination -

js
const socket = dgram.createSocket('udp4');

socket.setMulticastTTL(1);
socket.send('announce', 41234, '239.255.0.1');

setMulticastTTL() controls how far multicast packets can travel in hop-count terms. A value of 1 usually keeps the packet on the local network segment. Higher values move into multicast routing policy, which many networks restrict or disable.

setMulticastLoopback(false) controls whether the sender can receive its own multicast datagrams on the same host. Default behavior can surprise local tests because a process may observe its own announcements.

reuseAddr is common with multicast because multiple receivers on one host may need to bind the same port to receive the same group traffic. Unix-like systems and Windows have different edge cases around address reuse. Chapter 9.6 covers that socket-option layer. For this chapter, remember the normal multicast shape - create the socket with { reuseAddr: true } when multiple listeners may share the multicast port.

Broadcast and multicast are local-network mechanisms more than Internet mechanisms. Containers, VM networks, cloud VPCs, host firewalls, Wi-Fi isolation, and VPN routes can all change whether packets leave, arrive, or loop back. Node exposes the socket calls. The network decides reachability.

Multicast also has a port requirement. Joining a group controls which group traffic the kernel can deliver. Binding controls which local UDP port the socket receives. Senders usually target the group address plus the shared protocol port. A receiver that joins the right group on the wrong port will stay quiet.

js
socket.bind(9999, () => {
  socket.addMembership('239.255.0.1');
});

That receives datagrams sent to group 239.255.0.1 on port 9999, subject to interface and network policy. A sender using port 41234 is sending to another UDP socket address.

Failure Shapes

UDP failures are often silent.

A TCP connection attempt can fail during connection setup. UDP has no connection setup. An unconnected UDP send to a valid-looking address can complete locally while the datagram disappears before any receiver sees it.

Start by separating local failure from remote absence.

Local failures happen while Node or the OS handles your socket operation. Bad arguments throw. Bind conflicts produce errors. DNS lookup can fail before send. Oversized datagrams can produce EMSGSIZE. Sending after close can fail through Node's socket state. These failures are visible because they happen on the local path.

Remote absence is quieter. A process may be listening on the wrong port. A host firewall may drop inbound UDP. A NAT rule may be missing. A receiver may drop packets because its socket receive buffer is full. An intermediate device may drop fragments. Your send callback can still report success because the local kernel accepted the datagram for sending.

There is a third category - local success followed by remote rejection feedback. ICMP Port Unreachable belongs there. A remote host or local host can report that a UDP port is closed. The report is separate from the original UDP datagram. It may arrive late. It may be filtered. It may be delivered to the socket in platform-specific ways. Connected UDP gives the kernel a clearer peer association, so these errors are more likely to reach Node.

This classification keeps logs easier to read -

text
argument or state error
  -> Node throws or calls back with error
bind or send kernel error
  -> error event or callback error
remote or path drop
  -> no Node event
ICMP feedback
  -> platform-dependent socket error

Silence only becomes useful after the local path has been checked. Before that, silence could mean the process bound the wrong address, the sender used another address family, DNS resolved differently, the datagram exceeded local size limits, or the receiver exited before a handler ran.

Use the smallest local test first -

js
socket.on('message', (msg, rinfo) => {
  console.log(msg.length, rinfo.address, rinfo.port);
});

socket.bind(41234, '127.0.0.1');

Loopback removes interface routing, Wi-Fi policy, and remote firewall behavior. It still exercises Node, libuv, the kernel UDP socket path, port binding, and message event delivery.

After loopback works, bind to a real interface address or wildcard address and test from another process on the same host. Then test from another host on the same network. Each step adds one new layer of possible loss.

Two local processes are better than one process pretending to be both sides. A single process can accidentally share variables, exit too early, or hide timing problems because all callbacks run through one event loop. Separate processes force the reply path through real socket state.

Receiver -

js
const socket = dgram.createSocket('udp4');

socket.on('message', (msg, rinfo) => {
  console.log(msg.toString(), rinfo);
});

socket.bind(41234, '127.0.0.1');

Sender -

js
const socket = dgram.createSocket('udp4');

socket.send('probe', 41234, '127.0.0.1', err => {
  if (err) console.error(err.code);
  socket.close();
});

After that works, keep the receiver unchanged and move the sender to another terminal. Then change only the bind address. Then change only the target host. This slow testing path keeps DNS, routing, binding, and parser bugs from collapsing into one silent UDP failure.

When a test crosses hosts, log both endpoints. On the receiver, log rinfo. On the sender, log socket.address() after bind or after the first send callback. The two logs should line up - sender local address and port on one side, receiver rinfo on the other. NAT, containers, and wildcard binds can make those values look different from what you expected.

Use numeric addresses while isolating UDP. A hostname target mixes DNS behavior into the send path. 127.0.0.1 and ::1 are different families. A udp4 socket sending to localhost may behave differently from a udp6 socket sending to the same name.

Next, narrow the problem to receive path or send path.

For receive bugs, check the bound local address and port. socket.address() reports what the OS assigned. A socket bound to 127.0.0.1 receives loopback traffic, not traffic sent to the host's LAN address. A socket bound to 0.0.0.0 can receive on suitable IPv4 local addresses, subject to firewall policy.

js
socket.on('listening', () => {
  console.log(socket.address());
});

Check whether another process owns the port. On Linux, ss -lunp shows UDP sockets with local addresses and process data when permissions allow it. ip addr shows interface addresses. ip route shows route selection. Packet capture tools can show whether the datagram reaches an interface, but packet capture belongs to the debugging workflow, not the Node API itself.

For local server bugs, log from the listening event instead of assuming bind succeeded -

js
socket.on('listening', () => {
  const { address, port, family } = socket.address();
  console.log({ address, port, family });
});

That output anchors the process to an actual local socket address. If the address is 127.0.0.1, remote hosts have the wrong target. If the family is IPv6, an IPv4 sender has the wrong family. If the port differs from the expected value, implicit bind or test setup changed the endpoint.

For send bugs, log the destination address, destination port, local socket address after bind or implicit bind, and callback error. Hostnames add DNS behavior, so switch to a numeric address while isolating UDP behavior. Large messages add fragmentation or size rejection, so test with a tiny payload while isolating routing and binding.

js
socket.send('x', 41234, '192.0.2.10', err => {
  console.error(err?.code ?? 'sent locally');
});

sent locally means the local send request completed. It says nothing about a remote handler.

For protocol bugs, add a tiny header with version and message type before adding more complexity -

js
const msg = Buffer.from([1, 3, 0, 0]);

socket.send(msg, 41234, '127.0.0.1');

Version and type fields make packet captures and logs easier to line up. They also give the receiver a cheap rejection path for old senders. UDP gives you bytes, so your protocol should make those bytes self-describing enough for your deployment.

Receive buffer overflow is the failure that feels most unfair during load tests. The receiver process is alive. The socket is bound. Small tests pass. Under burst traffic, messages vanish. The kernel receive queue filled while JavaScript was busy or while the process could not be scheduled fast enough. UDP drops the excess. Node emits no event for datagrams the kernel discarded before Node read them.

js
socket.on('message', msg => {
  while (expensiveWork(msg)) break;
});

CPU-heavy message handlers make this easy to reproduce. Move expensive work away from the receive path, batch carefully, or use a protocol with explicit feedback when the sender needs to know the receiver is keeping up. Bigger receive buffers can buy time, but the receiver still has a finite queue.

Handler allocation pressure can cause the same kind of loss. A high-rate UDP listener that allocates objects per packet, parses JSON per packet, and writes logs per packet can fall behind even when the network rate looks modest. The kernel drops datagrams before JavaScript sees them, so application-level counters undercount attempted traffic. Compare sender counts, receiver counts, and host-level UDP drop counters when the platform exposes them.

The Node process can also lose messages during startup and shutdown. A sender can transmit before the receiver has completed bind. At that point, the process has no socket endpoint for the datagram. During shutdown, closing the socket releases the port while senders may still be sending. UDP has no close handshake to coordinate that transition.

ICMP errors deserve their own check. With unconnected UDP, many platforms deliver ICMP Port Unreachable in ways Node may not associate with a specific socket operation. With connected UDP, the kernel has a peer tuple attached to the socket, so it has a better place to report the error.

js
const socket = dgram.createSocket('udp4');

socket.on('error', err => console.error(err.code));

socket.connect(9, '127.0.0.1', () => {
  socket.send('test');
});

Port 9 may be closed on your machine. On Linux, that can produce ECONNREFUSED after the local stack receives ICMP Port Unreachable. On another OS, or across a firewall, the same test may produce no error. The absence of an error is normal UDP behavior.

Broadcast and multicast add their own failure modes. Broadcast send requires setBroadcast(true). Multicast receive requires membership on the right group and interface. Multiple local multicast receivers often need reuseAddr. VM and container networks may suppress broadcast or multicast. Cloud networks commonly restrict both. A local process can be correct and still receive no packets because the network dropped that traffic class.

Another common bug comes from treating UDP like a stream. A receiver that expects one large logical record across several datagrams needs its own record assembly rules. If one datagram is lost, the record has a missing piece. If datagrams reorder, the record needs sequence numbers. If duplicates arrive, the assembler needs duplicate handling. UDP will not supply those rules.

For many backend services, the cleaner design is to keep UDP messages independent. Metrics packets, local discovery announcements, health probes, and some telemetry messages can tolerate missing samples. The data model accepts absence. A request that changes money, inventory, access control, or user-visible state usually needs stronger behavior than raw UDP gives by itself.

The practical debugging loop stays simple -

text
verify bind address and port
  -> verify tiny numeric-address send
  -> verify local receive event
  -> verify interface and route
  -> verify firewall and network policy

UDP gives Node a precise local API and a loose delivery model. The API can tell you when the socket bound, when one datagram arrived, when a local send request completed, and when the OS reported an error. Delivery, ordering, duplicate suppression, and peer readiness belong above that API or outside the host.

Keep this rule close - node:dgram is a clean wrapper around datagram sockets. A successful UDP send proves very little beyond local acceptance.