This article is originally published on August 24, 2025 at Zenn(Japanese)
What is WebTransport?
WebTransport is a Web API that enables bidirectional communication using UDP as its foundation over HTTP/3 (QUIC), which has seen growing adoption.
While browser implementations are becoming relatively stable (e.g., Safari from version 26 onward), until recently the server-side options were limited to languages like C++, Rust, Go, or Python.
With OpenSSL versions now around 3.5, the crypto features required by QUIC are becoming widely available, improving server-side support. Node.js is also slated to support this in v25, expected around October 2025—though other implementations have relied on other SSL libraries like BoringSSL.
https://github.com/nodejs/node/pull/59249
Meanwhile, Deno introduced an experimental WebTransport API in version 2.2, which is the context for this article.
Incidentally, I’ve been following WebTransport since its initial draft stage and have experimented with it a few years back.
https://zenn.dev/yamayuski/scraps/1f2bc1588f422d
https://zenn.dev/yamayuski/scraps/f923ef3e776c13
What is Deno?
Deno is a server-side TypeScript/JavaScript runtime—one alternative to Node.js—written in Rust.
One of the key differences from Node.js is explicit permission control: by default, Deno cannot access files or network unless those permissions are granted via command-line flags, such as --allow-read=sample.json
. In interactive mode, Deno even prompts you to allow required permissions on the fly.
Trying Deno + WebTransport Together
So I combined the two: I built a WebTransport server using TypeScript in Deno.
Here’s the sample code:
https://github.com/yamayuski/deno-webtransport-sample
For the experiment, I ran Deno v2.4.5 on WSL2 (Ubuntu 24.04). It took me two full days to write the sample, during which I encountered and noted several sticking points—listed below for reference.
1. WSL2 Does Not Forward UDP by Default
WSL2 automatically forwards ports bound to 127.0.0.1
to the Windows side, which is convenient—but only for TCP. If you need to access the WSL server from another machine, you must set up port forwarding manually in Windows and open the firewall.
It turns out that WSL2 doesn’t forward UDP-bound ports by default, unlike TCP, which is what most servers listen on—so that behavior is easy to overlook.
You can workaround this by setting networkingMode=mirrored
in .wslconfig
(default is NAT). Conveniently, the newer “WSL Settings” app lets you adjust this via a GUI. Installing PowerToys also lets you tweak environment variables and hosts via GUI.
I discovered this issue when trying to connect a Deno server and client via WebTransport inside WSL. It worked fine once forwarding was correctly configured.
https://github.com/denoland/deno/blob/main/tests/specs/run/webtransport/main.ts
2. Certificate Generation Might Need Explicit 127.0.0.1
Although certificates are usually issued for domains, they can also be issued for IP addresses.
If you set the hostname to localhost
, it might still bind to 127.0.0.1
. So to be safe, generate a certificate that explicitly includes both localhost
and 127.0.0.1
, like so:
$ mkcert -cert-file localhost.crt -key-file localhost.key localhost 127.0.0.1
https://github.com/denoland/deno/tree/main/tests/testdata/tls
Behavior may vary depending on whether you’re using WSL or Docker, so take note. In public environments, using Let’s Encrypt certificates avoids these complications.
3. Deno Alone Lacks Built-In TypeScript Serving
While it’s easy to spin up an HTTP server with Deno.serve
, there doesn’t seem to be a built-in API for serving entire directories or transpiling TypeScript into JavaScript on the fly.
Older versions may have offered Deno.emit
, but it’s no longer available.
The deno-vite-plugin
enables running a Vite server via deno run
, but serving over HTTPS requires Node.js’s createSecureServer
from http2
, which Deno hasn’t implemented yet. So, using Vite didn’t work.
https://github.com/denoland/deno/blob/v2.4.5/ext/node/polyfills/http2.ts#L1756
You could use local-ssl-proxy
as a workaround, but that felt too clunky—so I ended up embedding all JavaScript directly into index.html
. Since this is just a sample, that’s fine.
4. Chrome Requires a Flag for Self-Signed Certificates
In Chrome, enable the “WebTransport Developer Mode” flag (chrome://flags/#webtransport-developer-mode
) to allow WebTransport over HTTP/3 with certificates not issued by a known certificate authority.
Otherwise, self-signed certificates created using mkcert will be rejected. There used to be a way to pass hashes via launch flags, but that didn’t work for me. Enabling the Developer Mode flag was the solution.
5. Serving with QUIC is Straightforward
Here’s how I set up a basic HTTP/3 (QUIC) server:
const hostname = "localhost";
const port = 4433;
const cert = Deno.readTextFileSync("localhost.crt");
const key = Deno.readTextFileSync("localhost.key");
const server = new Deno.QuicEndpoint({
hostname,
port,
});
const listener = server.listen({
alpnProtocols: ["h3"],
cert,
key,
});
This creates an HTTP/3 server on 127.0.0.1:4433/udp
. While Deno.serve
can handle HTTP/2 or HTTP/1.1, for QUIC you must use the unstable API and include the --unstable-net
flag; otherwise Deno won’t even construct the WebTransport
class.
6. Firefox Didn’t Work—Promise Rejected
I only tested Chrome thoroughly. When I attempted to connect via Firefox, the promise was rejected, and I couldn’t figure out why—so I left it at that.
7. Communication Is Primarily Stream-Based
Previously, with WebSocket, bidirectional communication was event-driven:
const ws = new WebSocket("wss://localhost:8080");
ws.addEventListener("message", (data) => {
console.log("received", data.data);
});
ws.send("hello");
Simple and JavaScript-like.
In contrast, WebTransport uses streams, which is a bit more complex:
const wt = new WebTransport("https://localhost:4433");
await wt.ready;
const { readable, writable } = await wt.createBidirectionalStream();
const writer = writable.getWriter();
await writer.ready;
await writer.write((new TextEncoder()).encode("Hello server!"));
writer.releaseLock();
const reader = readable.getReader();
const response = await reader.read();
console.log((new TextDecoder()).decode(response.value));
reader.releaseLock();
wt.close();
On the client side, it’s straightforward: create a stream, send data, read the response, and close it—streams are disposable.
On the server side, you use a new style:
await wt.ready;
for await (const { readable, writable } of wt.incomingBidirectionalStreams) {
for await (const value of readable.pipeThrough(new TextDecoderStream())) {
console.log("Received", value);
const writer = writable.getWriter();
await writer.write(textEncoder.encode(`Pong: ${value}`));
writer.releaseLock();
break;
}
break;
}
return wt.closed;
Perhaps better use of pipe()
would be more elegant, but this achieves a basic ping-pong example.
https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/for-await...of
Here, for await
is relatively new syntax. It works with AsyncIterator
to process items sequentially.
Interestingly, PHP is also gravitating toward : iterable
instead of : array
, using yield $val1;
constructs for iterators (useful in PHPUnit DataProviders)—though that’s a bit of a tangent.
Note that WebTransport streams can time out quickly if unused, requiring client-side reconnection via a new stream.
Moreover, although this communication uses UDP, it provides TCP-like reliability (minus guaranteed order—applications must handle that). With wt.datagrams, you can also perform truly unreliable UDP-like communication—no retransmission or ordering—useful in scenarios like live streaming, gaming, or video calls.
This dual capability enables the developing “Media over QUIC (moq)” spec, gaining attention as a potential alternative to WebRTC or custom UDP implementations (RUDP).
In summary, I tried using Deno to implement WebTransport communication. Stream-based communication libraries are still scarce—so if you’re working on this, you could be a pioneer!
—やまゆ (Web Engineer with Laravel, babylon.js, AWS)
CAUSION: This article was translated using ChatGPT!
Top comments (0)