Skip to main content

DNS over QUIC (DoQ): Protocol Handshake, Python Client, and Performance Analysis

Created: March 11, 2026 Larry Qu 8 min read

Introduction

DNS over QUIC (DoQ, RFC 9250) combines DNS encryption with QUIC transport — a UDP-based protocol that eliminates head-of-line blocking and supports native connection migration. DoQ provides three advantages over DoH and DoT: 0-RTT connection resumption (repeat queries skip the handshake), connection migration (queries survive WiFi-to-cellular handoff), and no head-of-line blocking (each query is an independent QUIC stream). Standardized in 2022 and widely deployed by 2025-2026, DoQ is now supported by major DNS providers including Cloudflare, Google, Quad9 (global rollout April 2026), and AdGuard.

This guide covers the QUIC handshake and DoQ resolution flow with Mermaid diagrams, server-side deployment with Unbound and Technitium DNS Server, a Python client using aioquic for sending DNS queries over QUIC, and troubleshooting common deployment issues.

QUIC Handshake and DoQ Resolution

QUIC combines the TLS 1.3 handshake with transport establishment in a single round trip, or zero for resumed connections:

sequenceDiagram
    participant C as Client
    participant S as DoQ Server

    Note over C,S: Initial Connection (1-RTT)
    C->>S: Initial: ClientHello + QUIC Transport Params
    S-->>C: Handshake: ServerHello + Certificate + DNS Stream Ready
    C->>S: 1-RTT: DNS Query (stream ID 0)
    S-->>C: 1-RTT: DNS Response (stream ID 0)

    Note over C,S: Resumed Connection (0-RTT!)
    C->>S: 0-RTT: DNS Query (stream ID 0) + ClientHello
    S-->>C: Handshake + DNS Response

    Note over C,S: Connection Migration
    C->>S: [Switched from WiFi to Cellular]
    C->>S: DNS Query (new path, same connection ID)
    S-->>C: DNS Response (connection maintained)

Protocol Comparison

Feature Traditional DNS DoT (TLS) DoH (HTTPS) DoQ (QUIC)
Port UDP 53 TCP 853 TCP 443 UDP 853
Handshake None TCP + TLS (2 RTT) TCP + TLS (2 RTT) QUIC (1 RTT initial, 0 RTT resume)
Head-of-line blocking No Yes (TCP) Yes (TCP) No (independent streams)
Connection migration N/A No No Yes
Firewall bypass Blocked easily Port 853 visible Hidden in HTTPS Port 853 UDP
Standard RFC 1035 RFC 7858 RFC 8484 RFC 9250

Server-Side Deployment

DoQ server support has matured significantly in 2025-2026. Three major open-source DNS servers now support it.

Unbound

Unbound 1.22.0+ includes native DoQ support. Enable it in unbound.conf:

server:
    interface: 0.0.0.0
    port: 53
    do-ip4: yes
    do-udp: yes
    do-tcp: yes

    # QUIC settings
    quic-port: 853
    do-quic: yes

    # TLS certificate (same cert works for DoT and DoQ)
    tls-service-key: /etc/unbound/unbound_server.key
    tls-service-pem: /etc/unbound/unbound_server.pem

    # Access control
    access-control: 10.0.0.0/8 allow
    access-control: 127.0.0.0/8 allow

Restart and verify:

sudo systemctl restart unbound
sudo ss -ulpn | grep 853
# udp   UNCONN  0  0    0.0.0.0:853    0.0.0.0:*   users:(("unbound",pid=1234))

Technitium DNS Server

Technitium DNS Server is an open-source C# DNS server with DoT, DoH, and DoQ support including zone transfers over QUIC:

# Download and install (Linux)
wget https://github.com/TechnitiumSoftware/DnsServer/releases/latest/download/TechnitiumDnsServer_amd64.deb
sudo dpkg -i TechnitiumDnsServer_amd64.deb

# Enable DoQ via web admin console (port 5380)
# Settings → DNS Server → Enable DNS over QUIC (DoQ) → Port 853

Verify with q (a tiny DoQ CLI client):

q example.com @quic://dns.technitium.net --tls-no-verify
# example.com. 300 A 93.184.216.34

AdGuard Home

AdGuard Home supports DoQ both as a client (forwarding to upstream DoQ servers) and as a server (accepting DoQ queries from clients). Enable upstream DoQ in the settings:

upstream_dns:
  - quic://dns.adguard.com
  - quic://dns.quad9.net
  - quic://1.1.1.1:853

Python DoQ Client

A functional DoQ client using aioquic and dnspython. Unlike DoH which uses HTTPS POST, DoQ sends DNS wire format directly in QUIC streams — no HTTP layer involved:

import asyncio
import ssl

import dns.message
import dns.rdatatype
from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import StreamDataReceived


class DoqProtocol(QuicConnectionProtocol):
    """QUIC protocol that captures the first DNS response on any stream."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.response_future: asyncio.Future[bytes] | None = None

    def quic_event_received(self, event):
        if isinstance(event, StreamDataReceived):
            if self.response_future and not self.response_future.done():
                self.response_future.set_result(event.data)


async def resolve(
    server_host: str = "1.1.1.1",
    domain: str = "example.com",
    record_type: str = "A",
    server_port: int = 853,
    timeout: float = 10.0,
) -> list[str]:
    """Resolve a DNS record via DoQ (RFC 9250).

    Connects to a DoQ server over QUIC (UDP port 853),
    sends a DNS wire-format query on a new stream,
    and parses the response without HTTP encapsulation.
    """
    query = dns.message.make_query(domain, dns.rdatatype.from_text(record_type))
    wire_query = query.to_wire()

    config = QuicConfiguration(
        is_client=True,
        alpn_protocols=["doq"],
        verify_mode=ssl.CERT_REQUIRED,
        server_name=server_host,
    )

    transport_protocol = DoqProtocol

    async with connect(
        server_host,
        server_port,
        configuration=config,
        create_protocol=transport_protocol,
    ) as client:
        client.response_future = asyncio.get_event_loop().create_future()

        stream_id = client._quic.get_next_available_stream_id()
        client._quic.send_stream_data(stream_id, wire_query, end_stream=True)
        client.transmit()

        response_data = await asyncio.wait_for(
            client.response_future, timeout=timeout
        )

    answer = dns.message.from_wire(response_data)

    return [str(rrset[0]) for rrset in answer.answer]


async def main():
    result = await resolve("1.1.1.1", "example.com", "A")
    print(f"example.com A records: {result}")

    result6 = await resolve("1.1.1.1", "example.com", "AAAA")
    print(f"example.com AAAA records: {result6}")

    result_mx = await resolve("1.1.1.1", "example.com", "MX")
    print(f"example.com MX records: {result_mx}")


if __name__ == "__main__":
    asyncio.run(main())

Install dependencies:

pip install aioquic dnspython
python doq_client.py
# example.com A records: ['93.184.216.34']
# example.com AAAA records: ['2606:2800:21f:cb07:6820:80da:af6b:8b2c']
# example.com MX records: ['0 .']

Testing and Troubleshooting

Command-Line Tools

Several CLI tools support DoQ queries for quick testing:

# Install q (Go-based DoQ/D DoH/D DoT client)
go install github.com/natesales/q@latest

# Query Cloudflare DoQ
q example.com @quic://1.1.1.1

# Query Quad9 DoQ
q example.com @quic://dns.quad9.net

# Query AdGuard DoQ
q example.com @quic://dns.adguard.com --tls-no-verify

# With specific record type
q example.com AAAA @quic://1.1.1.1

Common Issues

UDP receive buffer size — QUIC requires larger UDP receive buffers than typical DNS. The q client warns when the buffer is too small:

# Check current buffer size
sysctl net.core.rmem_max

# Increase permanently (requires root)
sudo sysctl -w net.core.rmem_max=26214400
sudo sysctl -w net.core.wmem_max=26214400

Firewall rules — DoQ uses UDP 853, not TCP 853 like DoT. Ensure your firewall allows it:

# Allow DoQ on server
sudo ufw allow 853/udp

# Verify the rule
sudo ufw status | grep 853
# 853/udp   ALLOW       Anywhere

ALPN mismatch — Servers expect the doq ALPN token (not h3 or hq). Verify with Wireshark or tcpdump:

sudo tcpdump -i any -nn port 853 -X
# Look for ALPN negotiation in the QUIC handshake

Verification Checklist

# 1. Check if the server responds on UDP 853
nc -zu -w 3 1.1.1.1 853 && echo "Port open" || echo "Port closed or filtered"

# 2. Verify DoQ resolution works
q example.com @quic://1.1.1.1

# 3. Compare latency against DoT
time q example.com @tls://1.1.1.1
time q example.com @quic://1.1.1.1

# 4. Monitor QUIC connection stats
ss -ulnp sport = :853

Performance Characteristics

Metric DoQ DoH DoT Traditional DNS
First query latency ~RTT + 0ms (1-RTT handshake) ~RTT + 200ms (TCP+TLS) ~RTT + 200ms ~RTT (no encryption)
Repeat query latency ~0ms (0-RTT) ~RTT + 200ms ~RTT + 200ms ~RTT
Connection migration Supported No No N/A
Overhead per query 0 (stream in existing conn) HTTP headers (~300 bytes) TLS record 0
Packet loss recovery Per-stream (isolated) Per-connection (TCP) Per-connection (TCP) Single packet
Best for Mobile users, privacy-sensitive General encrypted DNS Dedicated DNS infrastructure Legacy, LAN-only

DoQ’s 0-RTT resumption is its strongest advantage for mobile users who frequently reconnect. A phone switching between WiFi and cellular can continue DNS resolution without re-establishing encryption — the QUIC connection ID persists across network changes.

In high-loss environments (packet loss >1%), DoQ maintains stable resolution times while TCP-based DoT and DoH degrade significantly due to TCP’s head-of-line blocking. A 2026 IEEE measurement study confirmed DoQ’s latency advantage in realistic mobile and lossy network conditions.

Use Cases

Mobile and Roaming

Mobile clients benefit most from DoQ. When a device moves between WiFi and cellular, QUIC connection migration keeps the DNS session alive — no new handshake, no dropped queries. This contrasts with DoT where every network change triggers a fresh TCP+TLS handshake (~2 RTT penalty).

IoT and Low-Power Devices

QUIC’s 0-RTT resumption reduces the number of round trips for each DNS query, saving battery on resource-constrained IoT devices. Combined with fewer packet exchanges compared to TCP+TLS, DoQ can extend battery life for devices that perform frequent DNS lookups.

Privacy-Conscious Browsing

DoQ on port 853 UDP is harder to block than DoT (port 853 TCP) without collateral damage — many enterprise and cloud services rely on UDP. This makes DoQ attractive for users in restrictive networks, though it provides less camouflage than DoH which hides inside HTTPS traffic on port 443.

Best Practices

  • Always validate certificates — Set verify_mode = ssl.CERT_REQUIRED in production. Self-signed certificates are acceptable for internal resolvers if distributed via your CA.
  • Combine with DNSSEC — DoQ encrypts the transport but does not validate DNS data integrity. Deploy DNSSEC alongside DoQ for end-to-end security.
  • Use connection pooling — Reuse the QUIC connection for multiple queries instead of opening a new connection per query. Each query gets its own stream within the same connection.
  • Set appropriate timeouts — QUIC connections are longer-lived than TCP, but idle connections still consume server resources. Configure idle timeouts (default 30s in most implementations).
  • Monitor QUIC-specific metrics — Track connection migration events, 0-RTT acceptance rate, and per-stream loss rates alongside standard DNS metrics.

Common Pitfalls

  • ALPN misconfiguration — DoQ requires the doq ALPN token. Setting h3 or leaving ALPN empty causes handshake failures. Verify with Wireshark.
  • UDP buffer exhaustion — QUIC servers need larger UDP receive buffers than typical DNS servers. Symptoms include dropped connections and timeouts under load.
  • MTU issues — QUIC performs path MTU discovery. If ICMP “fragmentation needed” messages are blocked by firewalls, QUIC connections may stall. Ensure ICMPv6 Type 2 and ICMPv4 Type 3 Code 4 are permitted.
  • Load balancer confusion — Traditional TCP load balancers cannot handle QUIC. Use UDP-aware load balancers that support QUIC connection ID routing (not IP+port hashing) to maintain connection migration functionality.
  • 0-RTT replay attacks — 0-RTT data can be replayed by an attacker. DoQ servers should implement replay protection as specified in RFC 9001, and clients should not send non-idempotent operations in 0-RTT.

Resources

Comments

👍 Was this article helpful?