Direct Sockets

Demián Renzulli
Demián Renzulli
Andrew Rayskiy
Andrew Rayskiy
Vlad Krot
Vlad Krot

Standard web applications are typically restricted to specific communication protocols like HTTP and APIs like WebSocket and WebRTC. While these are powerful, they are designed to be tightly constrained to prevent abuse. They cannot establish raw TCP or UDP connections, which limits the ability of web apps to communicate with legacy systems or hardware devices that use their own non-web protocols. For example, you might want to build a web-based SSH client, connect to a local printer, or manage a fleet of IoT devices. Historically, this required browser plugins or native helper applications.

The Direct Sockets API addresses this limitation by enabling Isolated Web Apps (IWAs) to establish direct TCP and UDP connections without a relay server. With IWAs, thanks to additional security measures—such as strict Content Security Policy (CSP) and cross-origin isolation— this API can be safely exposed.

Use cases

When should you use Direct Sockets over standard WebSockets?

  • IoT and smart devices: Communicating with hardware that uses raw TCP/UDP rather than HTTP.
  • Legacy systems: Connecting to older mail servers (SMTP/IMAP), IRC chat servers, or printers.
  • Remote desktop and terminals: Implementing SSH, Telnet, or RDP clients.
  • P2P systems: Implementing Distributed Hash Tables (DHT) or resilient collaboration tools (like IPFS).
  • Media broadcasting: Leveraging UDP to stream content to multiple endpoints at once (multicasting), enabling use cases like coordinated video playback across a network of retail kiosks.
  • Server and listener capabilities: Configuring the IWA to act as a receiving endpoint for incoming TCP connections or UDP datagrams using TCPServerSocket or bound UDPSocket.

Prerequisites for Direct Sockets

Before using Direct Sockets, you'll need to set up a functional IWA. You can then integrate Direct Sockets into your pages.

Add permission policy

To use Direct Sockets, you must configure the permissions_policy object in your IWA manifest. You need to add the direct-sockets key to explicitly enable the API. Additionally, you must include the cross-origin-isolated key. This key is not specific to Direct Sockets, but is required for all IWAs and determines whether the document can access APIs that require cross-origin isolation.

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

The direct-sockets key determines whether calls to new TCPSocket(...), new TCPServerSocket(...) or new UDPSocket(...) are allowed. If this policy is not set, these constructors will immediately reject with a NotAllowedError.

Implement TCPSocket

Applications can request a TCP connection by creating a TCPSocket instance.

Open a connection

To open a connection, use the new operator and await the opened promise.

The TCPSocket constructor initiates the connection using the specified remoteAddress and remotePort.

const remoteAddress = 'example.com';
const remotePort = 7;

// Configure options like keepAlive or buffering
const options = {
  keepAlive: true,
  keepAliveDelay: 720000
};

let tcpSocket = new TCPSocket(remoteAddress, remotePort, options);

// Wait for the connection to be established
let { readable, writable } = await tcpSocket.opened;

The optional configuration object allows for fine-grained network control; in this specific case, keepAliveDelay is set to 720000 milliseconds to maintain the connection during periods of inactivity. Developers can also configure other properties here, such as noDelay, which disables Nagle's algorithm to stop the system from batching small packets—potentially reducing latency—or sendBufferSize and receiveBufferSize to manage throughput.

In the last part of the preceding snippet, the code awaits the opened promise, which only resolves once the handshake is complete, returning a TCPSocketOpenInfo object containing the readable and writable streams required for data transmission.

Read and write

Once the socket is open, interact with it using standard Streams API interfaces.

  • Writing: The writable stream accepts a BufferSource (like an ArrayBuffer).
  • Reading: The readable stream yields Uint8Array data.
// Writing data
const writer = writable.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode("Hello Server"));

// Call when done
writer.releaseLock();

// Reading data
const reader = readable.getReader();
const { value, done } = await reader.read();
if (!done) {
    const decoder = new TextDecoder();
    console.log("Received:", decoder.decode(value));
}

// Call when done
reader.releaseLock();

Optimized reading with BYOB

For high-performance applications where managing memory allocation is critical, the API supports "Bring Your Own Buffer" (BYOB) reading. Instead of letting the browser allocate a new buffer for every chunk of data received, you can pass a pre-allocated buffer to the reader. This reduces garbage collection overhead by writing data directly into your existing memory.

// 1. Get a BYOB reader explicitly
const reader = readable.getReader({ mode: 'byob' });

// 2. Allocate a reusable buffer (e.g., 4KB)
let buffer = new Uint8Array(4096);

// 3. Read directly into the existing buffer
const { value, done } = await reader.read(buffer);

if (!done) {
  // 'value' is a view of the data written directly into your buffer
  console.log("Bytes received:", value.byteLength);
}

reader.releaseLock();

Implement UDPSocket

The UDPSocket class allows for UDP communication. It operates in two distinct modes depending on how you configure the options.

Connected mode

In this mode, the socket communicates with a single specific destination. This is useful for standard client-server tasks.

// Connect to a specific remote host
let udpSocket = new UDPSocket({
    remoteAddress: 'example.com',
    remotePort: 7 });

let { readable, writable } = await udpSocket.opened;

Bound mode

In this mode, the socket is bound to a local IP endpoint. It can receive datagrams from arbitrary sources and send them to arbitrary destinations. This is often used for local discovery protocols or server-like behavior.

// Bind to all interfaces (IPv6)
let udpSocket = new UDPSocket({
    localAddress: '::'
    // omitting localPort lets the OS pick one
});

// localPort will tell you the OS-selected port.
let { readable, writable, localPort } = await udpSocket.opened;

Handle UDP messages

Unlike the TCP stream of bytes, UDP streams deal in UDPMessage objects, which contain the data and the remote address info. The following code demonstrates how to handle Input/Output operations when using a UDPSocket in "bound mode."

// Writing (Bound Mode requires specifying destination)
const writer = writable.getWriter();
await writer.write({
    data: new TextEncoder().encode("Ping"),
    remoteAddress: '192.168.1.50',
    remotePort: 8080
});

// Reading
const reader = readable.getReader();
const { value } = await reader.read();
// value contains: { data, remoteAddress, remotePort }
console.log(`Received from ${value.remoteAddress}:`, value.data);

Unlike "connected mode," where the socket is locked to a specific peer, bound mode allows the socket to communicate with arbitrary destinations. Consequently, when writing data to the writable stream, you must pass a UDPMessage object that explicitly specifies the remoteAddress and remotePort for each packet, instructing the socket exactly where to route that specific datagram. Similarly, when reading from the readable stream, the returned value includes not just the data payload but also the sender's remoteAddress and remotePort, enabling your application to identify the origin of every incoming packet.

Note: When using UDPSocket in "connected mode," the socket is effectively locked to a specific peer, simplifying the I/O process. In this mode, the remoteAddress and remotePort properties are effectively no-ops when writing, as the destination is already fixed. Similarly, when reading messages, these properties will return null, since the source is guaranteed to be the connected peer.

Multicast support

For use cases like synchronizing video playback across multiple kiosks or implementing local device discovery (for example, mDNS), Direct Sockets supports Multicast UDP. This allows messages to be sent to a "group" address and received by all subscribers on the network, rather than a single specific peer.

Multicast permissions

To use multicast capabilities, you must add the specific direct-sockets-multicast permission to your IWA manifest. This is distinct from the standard direct-sockets permission and it's necessary because multicast is used only in private networks.

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "direct-sockets-multicast": ["self"],
    "direct-sockets-private": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

Send multicast datagrams

Sending to a multicast group is very similar to standard UDP "connected mode," with the addition of specific options to control packet behavior.

const MULTICAST_GROUP = '239.0.0.1';
const PORT = 12345;

const socket = new UDPSocket({
  remoteAddress: MULTICAST_GROUP,
  remotePort: PORT,
  // Time To Live: How many router hops the packet can survive (default: 1)
  multicastTimeToLive: 5,
  // Loopback: Whether to receive your own packets (default: true)
  multicastLoopback: true
});

const { writable } = await socket.opened;
// Write to the stream as usual...

Receive multicast datagrams

To receive multicast traffic, you must open a UDPSocket in "bound mode" (typically binding to 0.0.0.0 or ::) and then join a specific group using the MulticastController. You may also use the multicastAllowAddressSharing option (similar to SO_REUSEADDR on Unix), which is essential for device discovery protocols where multiple applications on the same device need to listen to the same port.

const socket = new UDPSocket({
  localAddress: '0.0.0.0', // Listen on all interfaces
  localPort: 12345,
  multicastAllowAddressSharing: true // Allow multiple applications to bind to the same address / port pair.
});

// The open info contains the MulticastController
const { readable, multicastController } = await socket.opened;

// Join the group to start receiving packets
await multicastController.joinGroup('239.0.0.1');

const reader = readable.getReader();

// Read the stream...
const { value } = await reader.read();
console.log(`Received multicast from ${value.remoteAddress}`);

// When finished, you can leave the group (this is an optional, but recommended practice)
await multicastController.leaveGroup('239.0.0.1');

Create a server

The API also supports TCPServerSocket for accepting incoming TCP connections, effectively allowing your IWA to act as a local server. The following code illustrates how to establish a TCP server using the TCPServerSocket interface.

// Listen on all interfaces (IPv6)
let tcpServerSocket = new TCPServerSocket('::');

// Accept connections via the readable stream
let { readable } = await tcpServerSocket.opened;
let reader = readable.getReader();

// Wait for a client to connect
let { value: clientSocket } = await reader.read();

// 'clientSocket' is a standard TCPSocket you can now read/write to

By instantiating the class with the '::' address, the server binds to all available IPv6 network interfaces to listen for incoming attempts. Unlike traditional callback-based server APIs, this API utilizes the web's Streams API pattern: incoming connections are delivered as a ReadableStream. When you call reader.read(), the application waits for and accepts the next connection from the queue, resolving to a value that is a fully functional TCPSocket instance ready for two-way communication with that specific client.

Debug Direct Sockets with Chrome DevTools

From Chrome 138, you can debug Direct Sockets traffic directly within the Network panel in Chrome DevTools, eliminating the need for external packet sniffers. This tooling lets you monitor TCPSocket connections as well as UDPSocket traffic (in both bound and connected modes) alongside your standard HTTP requests.

To inspect your app's network activity:

  1. Open the Network panel in Chrome DevTools.
  2. Locate and select the socket connection in the request table.
  3. Open the Messages tab to view a log of all transmitted and received data.

The data in the Messages tab in DevTools.

This view provides a Hex Viewer, allowing you to inspect the raw binary payload of your TCP and UDP messages, ensuring your protocol implementation is byte-perfect.

Demo

IWA Kitchen Sink features an app with multiple tabs, each demonstrating a different IWA API such as Direct Sockets, Controlled Frame and more.

Alternatively, the telnet client demo contains an Isolated Web App that allows the user to connect to a TCP/IP server through an interactive terminal. In other words, a Telnet client.

Conclusion

The Direct Sockets API closes a critical functionality gap by enabling web applications to handle raw network protocols that were previously impossible to support without native wrappers. It goes beyond simple client connectivity; with TCPServerSocket, applications can listen for incoming connections, while UDPSocket offers flexible modes for both peer-to-peer communication and local network discovery.

By exposing these raw TCP and UDP capabilities through the modern Streams API, you can now build full-featured implementations of legacy protocols—like SSH, RDP, or custom IoT standards—directly in JavaScript. Because this API grants low-level network access, it carries significant security implications. Therefore, it is restricted to Isolated Web Apps (IWAs), ensuring that such power is only granted to trusted, explicitly installed applications that enforce strict security policies. This balance lets you build powerful, device-centric applications while maintaining the safety users expect from the web platform.

Resources