diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index ccc01397dd2d426de172e3d68595a8e0a7743f1f..12ae895a70bbfd66ab853c5984015d48f587cd99 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -29,7 +29,7 @@ jobs:
         with:
           # This field is required.  Dont set the patch version to always use
           # the latest patch version.
-          version: v1.45.1
+          version: v1.48.0
   notify:
     needs:
       - golangci
diff --git a/README.md b/README.md
index 50c963ee06898fe6f8139015339b23b8db6b27c3..ff36c26c68473024c6e59640d284116f581d540d 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,8 @@ Usage:
   dnsproxy [OPTIONS]
 
 Application Options:
-      --config-path=      yaml configuration file. Minimal working configuration in config.yaml.dist. Options passed
-                          through command line will override the ones from this file.
+      --config-path=      yaml configuration file. Minimal working configuration in config.yaml.dist.
+                          Options passed through command line will override the ones from this file.
   -v, --verbose           Verbose output (optional)
   -o, --output=           Path to the log file. If not set, write to stdout.
   -l, --listen=           Listening addresses
@@ -52,10 +52,10 @@ Application Options:
       --tls-min-version=  Minimum TLS version, for example 1.0
       --tls-max-version=  Maximum TLS version, for example 1.3
       --insecure          Disable secure TLS certificate validation
-  -g, --dnscrypt-config=  Path to a file with DNSCrypt configuration. You can generate one using
-                          https://github.com/ameshkov/dnscrypt
-  -u, --upstream=         An upstream to be used (can be specified multiple times). You can also specify path to a file
-                          with the list of servers
+  -g, --dnscrypt-config=  Path to a file with DNSCrypt configuration. You can generate one using https://github.com/ameshkov/dnscrypt
+      --http3             Enable HTTP/3 support
+  -u, --upstream=         An upstream to be used (can be specified multiple times).
+                          You can also specify path to a file with the list of servers
   -b, --bootstrap=        Bootstrap DNS for DoH and DoT, can be specified multiple times (default: 8.8.8.8:53)
   -f, --fallback=         Fallback resolvers to use when regular ones are unavailable, can be specified multiple times.
                           You can also specify path to a file with the list of servers
@@ -63,8 +63,8 @@ Application Options:
       --fastest-addr      Respond to A or AAAA requests only with the fastest IP address
       --cache             If specified, DNS cache is enabled
       --cache-size=       Cache size (in bytes). Default: 64k
-      --cache-min-ttl=    Minimum TTL value for DNS entries, in seconds. Capped at 3600. Artificially extending TTLs
-                          should only be done with careful consideration.
+      --cache-min-ttl=    Minimum TTL value for DNS entries, in seconds. Capped at 3600.
+                          Artificially extending TTLs should only be done with careful consideration.
       --cache-max-ttl=    Maximum TTL value for DNS entries, in seconds.
       --cache-optimistic  If specified, optimistic DNS cache is enabled
   -r, --ratelimit=        Ratelimit (requests per second)
@@ -75,8 +75,8 @@ Application Options:
       --dns64-prefix=     If specified, this is the DNS64 prefix dnsproxy will be using when it works as a DNS64 server.
                           If not specified, dnsproxy uses the 'Well-Known Prefix' 64:ff9b::
       --ipv6-disabled     If specified, all AAAA requests will be replied with NoError RCode and empty answer
-      --bogus-nxdomain=   Transform the responses containing at least a single IP that matches specified addresses and
-                          CIDRs into NXDOMAIN.  Can be specified multiple times.
+      --bogus-nxdomain=   Transform the responses containing at least a single IP that matches specified addresses
+                          and CIDRs into NXDOMAIN.  Can be specified multiple times.
       --udp-buf-size=     Set the size of the UDP buffer in bytes. A value <= 0 will use the system default.
       --max-go-routines=  Set the maximum number of go routines. A value <= 0 will not not set a maximum.
       --version           Prints the program version
@@ -144,6 +144,16 @@ DNS-over-QUIC upstream:
 ./dnsproxy -u quic://dns.adguard.com
 ```
 
+DNS-over-HTTPS upstream with enabled HTTP/3 support (chooses it if it's faster):
+```shell
+./dnsproxy -u https://dns.google/dns-query --http3
+```
+
+DNS-over-HTTPS upstream with forced HTTP/3 (no fallback to other protocol):
+```shell
+./dnsproxy -u h3://dns.google/dns-query
+```
+
 DNSCrypt upstream ([DNS Stamp](https://dnscrypt.info/stamps) of AdGuard DNS):
 ```shell
 ./dnsproxy -u sdns://AQIAAAAAAAAAFDE3Ni4xMDMuMTMwLjEzMDo1NDQzINErR_JS3PLCu_iZEIbq95zkSV2LFsigxDIuUso_OQhzIjIuZG5zY3J5cHQuZGVmYXVsdC5uczEuYWRndWFyZC5jb20
diff --git a/go.mod b/go.mod
index a011f4cb371289034fd36ce1a170af3fcf6b935e..52232f05891a41bc4a97f9cd0c79327775cb3aa8 100644
--- a/go.mod
+++ b/go.mod
@@ -24,6 +24,7 @@ require (
 	github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
 	github.com/golang/mock v1.6.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
+	github.com/marten-seemann/qpack v0.2.1 // indirect
 	github.com/marten-seemann/qtls-go1-18 v0.1.2 // indirect
 	github.com/marten-seemann/qtls-go1-19 v0.1.0 // indirect
 	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
diff --git a/go.sum b/go.sum
index be7c9b390204abb4b0b228ae361338fd6931ef73..484a8304ef8c7f6719adab2275539883e1dd2953 100644
--- a/go.sum
+++ b/go.sum
@@ -42,6 +42,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/lucas-clemente/quic-go v0.29.0 h1:Vw0mGTfmWqGzh4jx/kMymsIkFK6rErFVmg+t9RLrnZE=
 github.com/lucas-clemente/quic-go v0.29.0/go.mod h1:CTcNfLYJS2UuRNB+zcNlgvkjBhxX6Hm3WUxxAQx2mgE=
+github.com/marten-seemann/qpack v0.2.1 h1:jvTsT/HpCn2UZJdP+UUB53FfUUgeOyG5K1ns0OJOGVs=
+github.com/marten-seemann/qpack v0.2.1/go.mod h1:F7Gl5L1jIgN1D11ucXefiuJS9UMVP2opoCp2jDKb7wc=
 github.com/marten-seemann/qtls-go1-18 v0.1.2 h1:JH6jmzbduz0ITVQ7ShevK10Av5+jBEKAHMntXmIV7kM=
 github.com/marten-seemann/qtls-go1-18 v0.1.2/go.mod h1:mJttiymBAByA49mhlNZZGrH5u1uXYZJ+RW28Py7f4m4=
 github.com/marten-seemann/qtls-go1-19 v0.1.0 h1:rLFKD/9mp/uq1SYGYuVZhm83wkmU95pK5df3GufyYYU=
@@ -55,6 +57,7 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
@@ -87,6 +90,7 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -104,6 +108,7 @@ golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -117,6 +122,7 @@ golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVq
 golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
diff --git a/main.go b/main.go
index ecc3bdd4c19ed0aa36dda33fec48d249fc89dc61..df92091f1fa75ae52a6838e177d1806f19d2f830 100644
--- a/main.go
+++ b/main.go
@@ -80,6 +80,11 @@ type Options struct {
 	// Path to the DNSCrypt configuration file
 	DNSCryptConfigPath string `yaml:"dnscrypt-config" short:"g" long:"dnscrypt-config" description:"Path to a file with DNSCrypt configuration. You can generate one using https://github.com/ameshkov/dnscrypt"`
 
+	// HTTP3 controls whether HTTP/3 is enabled for this instance of dnsproxy.
+	// At this point it only enables it for upstreams, but in the future it will
+	// also enable it for the server.
+	HTTP3 bool `yaml:"http3" long:"http3" description:"Enable HTTP/3 support" optional:"yes" optional-value:"false"`
+
 	// Upstream DNS servers settings
 	// --
 
@@ -292,7 +297,18 @@ func createProxyConfig(options *Options) proxy.Config {
 func initUpstreams(config *proxy.Config, options *Options) {
 	// Init upstreams
 	upstreams := loadServersList(options.Upstreams)
+
+	httpVersions := upstream.DefaultHTTPVersions
+	if options.HTTP3 {
+		httpVersions = []upstream.HTTPVersion{
+			upstream.HTTPVersion3,
+			upstream.HTTPVersion2,
+			upstream.HTTPVersion11,
+		}
+	}
+
 	upsOpts := &upstream.Options{
+		HTTPVersions:       httpVersions,
 		InsecureSkipVerify: options.Insecure,
 		Bootstrap:          options.BootstrapDNS,
 		Timeout:            defaultTimeout,
diff --git a/proxy/errors.go b/proxy/errors.go
index 88089c92c466caf8423de995b98e4708630ea097..241fec6b68151151e2cabf98ae3a8ebe9f13a9e0 100644
--- a/proxy/errors.go
+++ b/proxy/errors.go
@@ -12,11 +12,11 @@ import (
 // isEPIPE checks if the underlying error is EPIPE.  syscall.EPIPE exists on all
 // OSes except for Plan 9.  Validate with:
 //
-//   $ for os in $(go tool dist list | cut -d / -f 1 | sort -u)
-//   do
-//           echo -n "$os"
-//           env GOOS="$os" go doc syscall.EPIPE | grep -F -e EPIPE
-//   done
+//	$ for os in $(go tool dist list | cut -d / -f 1 | sort -u)
+//	do
+//	        echo -n "$os"
+//	        env GOOS="$os" go doc syscall.EPIPE | grep -F -e EPIPE
+//	done
 //
 // For the Plan 9 version see ./errors_plan9.go.
 func isEPIPE(err error) (ok bool) {
diff --git a/proxy/server_https.go b/proxy/server_https.go
index 9fc15536dc80852e46dbaaa18791830b69c69516..7324fc14eaf3aa2f900e7eaa0156f9f332c6e89e 100644
--- a/proxy/server_https.go
+++ b/proxy/server_https.go
@@ -56,11 +56,10 @@ func (p *Proxy) listenHTTPS(srv *http.Server, l net.Listener) {
 // ServeHTTP is the http.RequestHandler implementation that handles DoH queries
 // Here is what it returns:
 //
-//  - http.StatusBadRequest if there is no DNS request data;
-//  - http.StatusUnsupportedMediaType if request content type is not
-//    "application/dns-message";
-//  - http.StatusMethodNotAllowed if request method is not GET or POST.
-//
+//   - http.StatusBadRequest if there is no DNS request data;
+//   - http.StatusUnsupportedMediaType if request content type is not
+//     "application/dns-message";
+//   - http.StatusMethodNotAllowed if request method is not GET or POST.
 func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	log.Tracef("Incoming HTTPS request on %s", r.URL)
 
@@ -159,11 +158,10 @@ func (p *Proxy) respondHTTPS(d *DNSContext) error {
 // suitable r's header.  It returns nil if r doesn't contain any information
 // about real client's IP address.  Current headers priority is:
 //
-//   1. CF-Connecting-IP
-//   2. True-Client-IP
-//   3. X-Real-IP
-//   4. X-Forwarded-For
-//
+//  1. CF-Connecting-IP
+//  2. True-Client-IP
+//  3. X-Real-IP
+//  4. X-Forwarded-For
 func realIPFromHdrs(r *http.Request) (realIP net.IP) {
 	for _, h := range []string{
 		// Headers set by CloudFlare proxy servers.
diff --git a/proxy/upstreams.go b/proxy/upstreams.go
index a57b240f2bb3c4f1357babe16b75cf61444c82a4..f85255a5e29589f82d6c1362193a152658a766fc 100644
--- a/proxy/upstreams.go
+++ b/proxy/upstreams.go
@@ -72,13 +72,7 @@ func ParseUpstreamsConfig(upstreamConfig []string, options *upstream.Options) (*
 			dnsUpstream, ok := upstreamsIndex[u]
 			if !ok {
 				// create an upstream
-				dnsUpstream, err = upstream.AddressToUpstream(
-					u,
-					&upstream.Options{
-						Bootstrap:          options.Bootstrap,
-						Timeout:            options.Timeout,
-						InsecureSkipVerify: options.InsecureSkipVerify,
-					})
+				dnsUpstream, err = upstream.AddressToUpstream(u, options.Clone())
 
 				if err != nil {
 					err = fmt.Errorf("cannot prepare the upstream %s (%s): %s", l, options.Bootstrap, err)
diff --git a/proxyutil/udp_unix.go b/proxyutil/udp_unix.go
index b5e625c377962537c4a9a84021a4a55682fb6a8f..e9d13dab8e4bd9bb9c971d1cd126ef25e67acd16 100644
--- a/proxyutil/udp_unix.go
+++ b/proxyutil/udp_unix.go
@@ -15,9 +15,8 @@ import (
 // connection to receive an appropriate OOB data.  For both versions the flags
 // are:
 //
-//   FlagDst
-//   FlagInterface
-//
+//   - FlagDst
+//   - FlagInterface
 const (
 	ipv4Flags ipv4.ControlFlags = ipv4.FlagDst | ipv4.FlagInterface
 	ipv6Flags ipv6.ControlFlags = ipv6.FlagDst | ipv6.FlagInterface
diff --git a/upstream/bootstrap.go b/upstream/bootstrap.go
index a78d69391f215d5ec55652dca60cdc236ae063f6..a5e70edb5518aa42b339d977eed7b356a11eb8e9 100755
--- a/upstream/bootstrap.go
+++ b/upstream/bootstrap.go
@@ -12,53 +12,66 @@ import (
 
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
-	"golang.org/x/net/http2"
 )
 
-// NextProtoDQ is the ALPN token for DoQ. During connection establishment,
-// DNS/QUIC support is indicated by selecting the ALPN token "dq" in the
+// NextProtoDQ is the ALPN token for DoQ. During the connection establishment,
+// DNS/QUIC support is indicated by selecting the ALPN token "doq" in the
 // crypto handshake.
-// Current draft version:
-// https://datatracker.ietf.org/doc/html/draft-ietf-dprive-dnsoquic-02
-const NextProtoDQ = "doq-i02"
+// The current draft version is https://datatracker.ietf.org/doc/rfc9250/.
+const NextProtoDQ = "doq"
 
 // compatProtoDQ is a list of ALPN tokens used by a QUIC connection.
 // NextProtoDQ is the latest draft version supported by dnsproxy, but it also
 // includes previous drafts.
-var compatProtoDQ = []string{NextProtoDQ, "doq-i00", "dq", "doq"}
+var compatProtoDQ = []string{NextProtoDQ, "doq-i00", "dq", "doq-i02"}
 
-// RootCAs is the CertPool that must be used by all upstreams
-// Redefining RootCAs makes sense on iOS to overcome the 15MB memory limit of the NEPacketTunnelProvider
-// nolint
+// RootCAs is the CertPool that must be used by all upstreams. Redefining
+// RootCAs makes sense on iOS to overcome the 15MB memory limit of the
+// NEPacketTunnelProvider.
 var RootCAs *x509.CertPool
 
-// CipherSuites - custom list of TLSv1.2 ciphers
-// nolint
+// CipherSuites is a custom list of TLSv1.2 ciphers.
 var CipherSuites []uint16
 
-// TODO: refactor bootstrapper, it's overcomplicated and hard to understand what it does
+// TODO(ameshkov): refactor bootstrapper, it's overcomplicated and hard to
+// understand what it does.
 type bootstrapper struct {
-	URL            *url.URL
-	resolvers      []*Resolver // list of Resolvers to use to resolve hostname, if necessary
-	dialContext    dialHandler // specifies the dial function for creating unencrypted TCP connections.
+	// URL is the upstream server address.
+	URL *url.URL
+
+	// resolvers is a list of *net.Resolver to use to resolve the upstream
+	// hostname, if necessary.
+	resolvers []*Resolver
+
+	// dialContext is the dial function for creating unencrypted TCP
+	// connections.
+	dialContext dialHandler
+
+	// resolvedConfig is a *tls.Config that is used for encrypted DNS protocols.
 	resolvedConfig *tls.Config
-	sync.RWMutex
 
-	// stores options for AddressToUpstream func:
-	// callbacks for checking certificates, timeout,
-	// the need to verify the server certificate,
-	// the addresses of upstream servers, etc
+	// sessionsCache is necessary to achieve TLS session resumption.  We create
+	// once when the bootstrapper is created and re-use every time when we need
+	// to create a new tls.Config.
+	sessionsCache tls.ClientSessionCache
+
+	// guard protects dialContext and resolvedConfig.
+	guard sync.RWMutex
+
+	// options is the Options that were passed to the AddressToUpstream
+	// function.  It configures different upstream properties: callbacks for
+	// checking certificates, timeout, etc.
 	options *Options
 }
 
-// newBootstrapperResolved creates a new bootstrapper that already contains resolved config.
-// This can be done only in the case when we already know the resolver IP address.
-// options -- Upstream customization options
+// newBootstrapperResolved creates a new bootstrapper that already contains
+// resolved config. This can be done only in the case when we already know the
+// resolver IP address passed via options.
 func newBootstrapperResolved(upsURL *url.URL, options *Options) (*bootstrapper, error) {
 	// get a host without port
 	host, port, err := net.SplitHostPort(upsURL.Host)
 	if err != nil {
-		return nil, fmt.Errorf("bootstrapper requires port in address %s", upsURL.String())
+		return nil, fmt.Errorf("bootstrapper requires port in address %s", upsURL)
 	}
 
 	var resolverAddresses []string
@@ -70,6 +83,10 @@ func newBootstrapperResolved(upsURL *url.URL, options *Options) (*bootstrapper,
 	b := &bootstrapper{
 		URL:     upsURL,
 		options: options,
+		// Use the default capacity for the LRU cache.  It may be useful to
+		// store several caches since the user may be routed to different
+		// servers in case there's load balancing on the server-side.
+		sessionsCache: tls.NewLRUClientSessionCache(0),
 	}
 	b.dialContext = b.createDialContext(resolverAddresses)
 	b.resolvedConfig = b.createTLSConfig(host)
@@ -77,9 +94,9 @@ func newBootstrapperResolved(upsURL *url.URL, options *Options) (*bootstrapper,
 	return b, nil
 }
 
-// newBootstrapper initializes a new bootstrapper instance
-// address -- original resolver address string (i.e. tls://one.one.one.one:853)
-// options -- Upstream customization options
+// newBootstrapper initializes a new bootstrapper instance. u is the original
+// resolver address string (i.e. tls://one.one.one.one:853), options is the
+// upstream configuration options.
 func newBootstrapper(u *url.URL, options *Options) (b *bootstrapper, err error) {
 	resolvers := []*Resolver{}
 	if len(options.Bootstrap) != 0 {
@@ -103,18 +120,31 @@ func newBootstrapper(u *url.URL, options *Options) (b *bootstrapper, err error)
 		URL:       u,
 		resolvers: resolvers,
 		options:   options,
+		// Use the default capacity for the LRU cache.  It may be useful to
+		// store several caches since the user may be routed to different
+		// servers in case there's load balancing on the server-side.
+		sessionsCache: tls.NewLRUClientSessionCache(0),
 	}, nil
 }
 
-// dialHandler specifies the dial function for creating unencrypted TCP connections.
+// dialHandler describes the dial function for creating unencrypted network
+// connections to the upstream server.  Internally, this function will use the
+// supplied bootstrap DNS servers to resolve the upstream's IP address and only
+// then it will actually establish a connection.
 type dialHandler func(ctx context.Context, network, addr string) (net.Conn, error)
 
-// will get usable IP address from Address field, and caches the result
+// get is the main function of bootstrapper that does two crucial things.
+// First, it creates an instance of a dialHandler function that should be used
+// by the Upstream to establish a connection to the upstream DNS server.  This
+// dialHandler in a lazy manner resolves the DNS server IP address using the
+// bootstrap DNS servers supplied to this bootstrapper instance.  It will also
+// create an instance of *tls.Config that should be used for establishing an
+// encrypted connection for DoH/DoT/DoQ.
 func (n *bootstrapper) get() (*tls.Config, dialHandler, error) {
-	n.RLock()
+	n.guard.RLock()
 	if n.dialContext != nil && n.resolvedConfig != nil { // fast path
 		tlsConfig, dialContext := n.resolvedConfig, n.dialContext
-		n.RUnlock()
+		n.guard.RUnlock()
 		return tlsConfig.Clone(), dialContext, nil
 	}
 
@@ -123,22 +153,24 @@ func (n *bootstrapper) get() (*tls.Config, dialHandler, error) {
 	//
 
 	// get a host without port
-	addr := n.URL
-	host, port, err := net.SplitHostPort(addr.Host)
+	u := n.URL
+	host, port, err := net.SplitHostPort(u.Host)
 	if err != nil {
-		n.RUnlock()
-		return nil, nil, fmt.Errorf("bootstrapper requires port in address %s", addr.String())
+		n.guard.RUnlock()
+		return nil, nil, fmt.Errorf("bootstrapper requires port in address %s", u)
 	}
 
-	// if n.address's host is an IP, just use it right away
+	// if n.address's host is an IP, just use it right away.
 	ip := net.ParseIP(host)
 	if ip != nil {
-		n.RUnlock()
+		n.guard.RUnlock()
 
-		// Upgrade lock to protect n.resolved
 		resolverAddress := net.JoinHostPort(host, port)
-		n.Lock()
-		defer n.Unlock()
+
+		// Upgrade lock to protect n.resolvedConfig.
+		// TODO(ameshkov): rework, that's not how it should be done.
+		n.guard.Lock()
+		defer n.guard.Unlock()
 
 		n.dialContext = n.createDialContext([]string{resolverAddress})
 		n.resolvedConfig = n.createTLSConfig(host)
@@ -148,7 +180,7 @@ func (n *bootstrapper) get() (*tls.Config, dialHandler, error) {
 	// Don't lock anymore (we can launch multiple lookup requests at a time)
 	// Otherwise, it might mess with the timeout specified for the Upstream
 	// See here: https://github.com/AdguardTeam/dnsproxy/issues/15
-	n.RUnlock()
+	n.guard.RUnlock()
 
 	//
 	// if it's a hostname
@@ -182,24 +214,26 @@ func (n *bootstrapper) get() (*tls.Config, dialHandler, error) {
 		return nil, nil, fmt.Errorf("couldn't find any suitable IP address for host %s", host)
 	}
 
-	n.Lock()
-	defer n.Unlock()
+	n.guard.Lock()
+	defer n.guard.Unlock()
 
 	n.dialContext = n.createDialContext(resolved)
 	n.resolvedConfig = n.createTLSConfig(host)
 	return n.resolvedConfig, n.dialContext, nil
 }
 
-// createTLSConfig creates a client TLS config
+// createTLSConfig creates a client TLS config that will be used to establish
+// an encrypted connection for DoH/DoT/DoQ.
 func (n *bootstrapper) createTLSConfig(host string) *tls.Config {
 	tlsConfig := &tls.Config{
 		ServerName:            host,
 		RootCAs:               RootCAs,
 		CipherSuites:          CipherSuites,
+		ClientSessionCache:    n.sessionsCache,
 		MinVersion:            tls.VersionTLS12,
 		InsecureSkipVerify:    n.options.InsecureSkipVerify,
 		VerifyPeerCertificate: n.options.VerifyServerCertificate,
-		ClientSessionCache:    tls.NewLRUClientSessionCache(1),
+		VerifyConnection:      n.options.VerifyConnection,
 	}
 
 	// Depending on the URL scheme, we choose what ALPN will be advertised by
@@ -210,16 +244,26 @@ func (n *bootstrapper) createTLSConfig(host string) *tls.Config {
 		//
 		// See https://github.com/ameshkov/dnslookup/issues/19.
 	case "https":
-		tlsConfig.NextProtos = []string{http2.NextProtoTLS, "http/1.1"}
+		httpVersions := n.options.HTTPVersions
+		if httpVersions == nil {
+			httpVersions = DefaultHTTPVersions
+		}
+
+		var nextProtos []string
+		for _, v := range httpVersions {
+			nextProtos = append(nextProtos, string(v))
+		}
+
+		tlsConfig.NextProtos = nextProtos
 	case "quic":
 		tlsConfig.NextProtos = compatProtoDQ
-		tlsConfig.ClientSessionCache = tls.NewLRUClientSessionCache(10)
 	}
 
 	return tlsConfig
 }
 
-// createDialContext returns dialContext function that tries to establish connection with all given addresses one by one
+// createDialContext returns a dialHandler function that tries to establish the
+// connection to each of the provided addresses one by one.
 func (n *bootstrapper) createDialContext(addresses []string) (dialContext dialHandler) {
 	dialer := &net.Dialer{
 		Timeout: n.options.Timeout,
@@ -240,14 +284,23 @@ func (n *bootstrapper) createDialContext(addresses []string) (dialContext dialHa
 			conn, err := dialer.DialContext(ctx, network, resolverAddress)
 			elapsed := time.Since(start)
 			if err == nil {
-				log.Tracef("dialer has successfully initialized connection to %s in %s", resolverAddress, elapsed)
+				log.Tracef(
+					"dialer has successfully initialized connection to %s in %s",
+					resolverAddress,
+					elapsed,
+				)
 
 				return conn, nil
 			}
 
 			errs = append(errs, err)
 
-			log.Tracef("dialer failed to initialize connection to %s, in %s, cause: %s", resolverAddress, elapsed, err)
+			log.Tracef(
+				"dialer failed to initialize connection to %s, in %s, cause: %s",
+				resolverAddress,
+				elapsed,
+				err,
+			)
 		}
 
 		return nil, errors.List("all dialers failed", errs...)
diff --git a/upstream/upstream.go b/upstream/upstream.go
index 235fcbf9e02f9b1ce31959bbf730908a875f5366..586526d6774abe96d9ededb18f6f3206d1bf2234 100644
--- a/upstream/upstream.go
+++ b/upstream/upstream.go
@@ -1,7 +1,9 @@
-// Package upstream implements DNS clients for all known DNS encryption protocols
+// Package upstream implements DNS clients for all known DNS encryption
+// protocols.
 package upstream
 
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"fmt"
 	"net"
@@ -17,13 +19,17 @@ import (
 	"github.com/miekg/dns"
 )
 
-// Upstream is an interface for a DNS resolver
+// Upstream is an interface for a DNS resolver.
 type Upstream interface {
+	// Exchange sends the DNS query m to this upstream and returns the response
+	// that has been received or an error if something went wrong.
 	Exchange(m *dns.Msg) (*dns.Msg, error)
+	// Address returns the address of the upstream DNS resolver.
 	Address() string
 }
 
-// Options for AddressToUpstream func
+// Options for AddressToUpstream func.  With these options we can configure the
+// upstream properties.
 type Options struct {
 	// Bootstrap is a list of DNS servers to be used to resolve
 	// DNS-over-HTTPS/DNS-over-TLS hostnames.  Plain DNS, DNSCrypt, or
@@ -42,17 +48,55 @@ type Options struct {
 	// InsecureSkipVerify disables verifying the server's certificate.
 	InsecureSkipVerify bool
 
-	// VerifyServerCertificate used to be set to crypto/tls
-	// Config.VerifyPeerCertificate for DNS-over-HTTPS, DNS-over-QUIC,
-	// DNS-over-TLS.
+	// HTTPVersions is a list of HTTP versions that should be supported by the
+	// DNS-over-HTTPS client.  If not set, HTTP/1.1 and HTTP/2 will be used.
+	HTTPVersions []HTTPVersion
+
+	// VerifyServerCertificate is used to set the VerifyPeerCertificate property
+	// of the *tls.Config for DNS-over-HTTPS, DNS-over-QUIC, and DNS-over-TLS.
 	VerifyServerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
 
+	// VerifyConnection is used to set the VerifyConnection property
+	// of the *tls.Config for DNS-over-HTTPS, DNS-over-QUIC, and DNS-over-TLS.
+	VerifyConnection func(state tls.ConnectionState) error
+
 	// VerifyDNSCryptCertificate is the callback the DNSCrypt server certificate
 	// will be passed to.  It's called in dnsCrypt.exchangeDNSCrypt.
 	// Upstream.Exchange method returns any error caused by it.
 	VerifyDNSCryptCertificate func(cert *dnscrypt.Cert) error
 }
 
+// Clone copies o to a new struct.  Note, that this is not a deep clone.
+func (o *Options) Clone() (clone *Options) {
+	return &Options{
+		Bootstrap:                 o.Bootstrap,
+		Timeout:                   o.Timeout,
+		ServerIPAddrs:             o.ServerIPAddrs,
+		InsecureSkipVerify:        o.InsecureSkipVerify,
+		HTTPVersions:              o.HTTPVersions,
+		VerifyServerCertificate:   o.VerifyServerCertificate,
+		VerifyConnection:          o.VerifyConnection,
+		VerifyDNSCryptCertificate: o.VerifyDNSCryptCertificate,
+	}
+}
+
+// HTTPVersion is an enumeration of the HTTP versions that we support.  Values
+// that we use in this enumeration are also used as ALPN values.
+type HTTPVersion string
+
+const (
+	// HTTPVersion11 is HTTP/1.1.
+	HTTPVersion11 HTTPVersion = "http/1.1"
+	// HTTPVersion2 is HTTP/2.
+	HTTPVersion2 HTTPVersion = "h2"
+	// HTTPVersion3 is HTTP/3.
+	HTTPVersion3 HTTPVersion = "h3"
+)
+
+// DefaultHTTPVersions is the list of HTTPVersion that we use by default in
+// the DNS-over-HTTPS client.
+var DefaultHTTPVersions = []HTTPVersion{HTTPVersion11, HTTPVersion2}
+
 const (
 	// defaultPortPlain is the default port for plain DNS.
 	defaultPortPlain = 53
@@ -72,11 +116,12 @@ const (
 
 // AddressToUpstream converts addr to an Upstream instance:
 //
-//  8.8.8.8:53 or udp://dns.adguard.com for plain DNS;
-//  tcp://8.8.8.8:53 for plain DNS-over-TCP;
-//  tls://1.1.1.1 for DNS-over-TLS;
-//  https://dns.adguard.com/dns-query for DNS-over-HTTPS;
-//  sdns://... for DNS stamp, see https://dnscrypt.info/stamps-specifications.
+//   - 8.8.8.8:53 or udp://dns.adguard.com for plain DNS;
+//   - tcp://8.8.8.8:53 for plain DNS-over-TCP;
+//   - tls://1.1.1.1 for DNS-over-TLS;
+//   - https://dns.adguard.com/dns-query for DNS-over-HTTPS;
+//   - h3://dns.google for DNS-over-HTTPS that only works with HTTP/3;
+//   - sdns://... for DNS stamp, see https://dnscrypt.info/stamps-specifications.
 //
 // opts are applied to the u.  nil is a valid value for opts.
 func AddressToUpstream(addr string, opts *Options) (u Upstream, err error) {
@@ -129,6 +174,10 @@ func urlToUpstream(uu *url.URL, opts *Options) (u Upstream, err error) {
 		return newDoQ(uu, opts)
 	case "tls":
 		return newDoT(uu, opts)
+	case "h3":
+		opts.HTTPVersions = []HTTPVersion{HTTPVersion3}
+		uu.Scheme = "https"
+		return newDoH(uu, opts)
 	case "https":
 		return newDoH(uu, opts)
 	default:
@@ -190,11 +239,10 @@ func logBegin(upstreamAddress string, req *dns.Msg) {
 	qtype := ""
 	target := ""
 	if len(req.Question) != 0 {
-		qtype = dns.TypeToString[req.Question[0].Qtype]
+		qtype = dns.Type(req.Question[0].Qtype).String()
 		target = req.Question[0].Name
 	}
-	log.Debug("%s: sending request %s %s",
-		upstreamAddress, qtype, target)
+	log.Debug("%s: sending request %s %s", upstreamAddress, qtype, target)
 }
 
 // Write to log about the result of DNS request
@@ -203,6 +251,5 @@ func logFinish(upstreamAddress string, err error) {
 	if err != nil {
 		status = err.Error()
 	}
-	log.Debug("%s: response: %s",
-		upstreamAddress, status)
+	log.Debug("%s: response: %s", upstreamAddress, status)
 }
diff --git a/upstream/upstream_dnscrypt.go b/upstream/upstream_dnscrypt.go
index e3c05a837213c2919a6902708002ac6f9952539e..d0a0c04d59946a09544156f8ac53625429bef025 100644
--- a/upstream/upstream_dnscrypt.go
+++ b/upstream/upstream_dnscrypt.go
@@ -13,9 +13,8 @@ import (
 	"github.com/miekg/dns"
 )
 
-//
-// DNSCrypt
-//
+// dnsCrypt is a struct that implements the Upstream interface for the DNSCrypt
+// protocol.
 type dnsCrypt struct {
 	boot       *bootstrapper
 	client     *dnscrypt.Client       // DNSCrypt client properties
@@ -27,8 +26,10 @@ type dnsCrypt struct {
 // type check
 var _ Upstream = (*dnsCrypt)(nil)
 
+// Address implements the Upstream interface for *dnsCrypt.
 func (p *dnsCrypt) Address() string { return p.boot.URL.String() }
 
+// Exchange implements the Upstream interface for *dnsCrypt.
 func (p *dnsCrypt) Exchange(m *dns.Msg) (*dns.Msg, error) {
 	reply, err := p.exchangeDNSCrypt(m)
 
diff --git a/upstream/upstream_doh.go b/upstream/upstream_doh.go
index b71e64e9d65889edf7d8ef6b4586fba911b8d289..78a93afa9eb6168ef34a188188bea0b70c0f70f5 100644
--- a/upstream/upstream_doh.go
+++ b/upstream/upstream_doh.go
@@ -1,9 +1,12 @@
 package upstream
 
 import (
+	"context"
+	"crypto/tls"
 	"encoding/base64"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"net/url"
 	"os"
@@ -11,6 +14,9 @@ import (
 	"time"
 
 	"github.com/AdguardTeam/golibs/errors"
+	"github.com/AdguardTeam/golibs/log"
+	"github.com/lucas-clemente/quic-go"
+	"github.com/lucas-clemente/quic-go/http3"
 	"github.com/miekg/dns"
 	"golang.org/x/net/http2"
 )
@@ -34,7 +40,8 @@ const (
 	dohMaxIdleConns = 1
 )
 
-// dnsOverHTTPS represents DNS-over-HTTPS upstream.
+// dnsOverHTTPS is a struct that implements the Upstream interface for the
+// DNS-over-HTTPS protocol.
 type dnsOverHTTPS struct {
 	boot *bootstrapper
 
@@ -43,6 +50,10 @@ type dnsOverHTTPS struct {
 	// needed. Clients are safe for concurrent use by multiple goroutines.
 	client      *http.Client
 	clientGuard sync.Mutex
+
+	// quicConfig is the QUIC configuration that is used if HTTP/3 is enabled
+	// for this upstream.
+	quicConfig *quic.Config
 }
 
 // type check
@@ -58,11 +69,25 @@ func newDoH(uu *url.URL, opts *Options) (u Upstream, err error) {
 		return nil, fmt.Errorf("creating https bootstrapper: %w", err)
 	}
 
-	return &dnsOverHTTPS{boot: b}, nil
+	return &dnsOverHTTPS{
+		boot: b,
+
+		quicConfig: &quic.Config{
+			KeepAlivePeriod: QUICKeepAlivePeriod,
+			// You can read more on address validation here:
+			// https://datatracker.ietf.org/doc/html/rfc9000#section-8.1
+			// Setting maxOrigins to 1 and tokensPerOrigin to 10 assuming that
+			// this is more than enough for the way we use it (one connection
+			// per upstream).
+			TokenStore: quic.NewLRUTokenStore(1, 10),
+		},
+	}, nil
 }
 
+// Address implements the Upstream interface for *dnsOverHTTPS.
 func (p *dnsOverHTTPS) Address() string { return p.boot.URL.String() }
 
+// Exchange implements the Upstream interface for *dnsOverHTTPS.
 func (p *dnsOverHTTPS) Exchange(m *dns.Msg) (*dns.Msg, error) {
 	client, err := p.getClient()
 	if err != nil {
@@ -146,8 +171,8 @@ func (p *dnsOverHTTPS) getClient() (c *http.Client, err error) {
 		return p.client, nil
 	}
 
-	// Timeout can be exceeded while waiting for the lock
-	// This happens quite often on mobile devices
+	// Timeout can be exceeded while waiting for the lock. This happens quite
+	// often on mobile devices.
 	elapsed := time.Since(startTime)
 	if p.boot.options.Timeout > 0 && elapsed > p.boot.options.Timeout {
 		return nil, fmt.Errorf("timeout exceeded: %s", elapsed)
@@ -158,6 +183,10 @@ func (p *dnsOverHTTPS) getClient() (c *http.Client, err error) {
 	return p.client, err
 }
 
+// createClient creates a new *http.Client instance.  The HTTP protocol version
+// will depend on whether HTTP3 is allowed and provided by this upstream.  Note,
+// that we'll attempt to establish a QUIC connection when creating the client in
+// order to check whether HTTP3 is supported.
 func (p *dnsOverHTTPS) createClient() (*http.Client, error) {
 	transport, err := p.createTransport()
 	if err != nil {
@@ -175,14 +204,32 @@ func (p *dnsOverHTTPS) createClient() (*http.Client, error) {
 }
 
 // createTransport initializes an HTTP transport that will be used specifically
-// for this DoH resolver. This HTTP transport ensures that the HTTP requests
-// will be sent exactly to the IP address got from the bootstrap resolver.
-func (p *dnsOverHTTPS) createTransport() (*http.Transport, error) {
+// for this DoH resolver.  This HTTP transport ensures that the HTTP requests
+// will be sent exactly to the IP address got from the bootstrap resolver. Note,
+// that this function will first attempt to establish a QUIC connection (if
+// HTTP3 is enabled in the upstream options).  If this attempt is successful,
+// it returns an HTTP3 transport, otherwise it returns the H1/H2 transport.
+func (p *dnsOverHTTPS) createTransport() (t http.RoundTripper, err error) {
 	tlsConfig, dialContext, err := p.boot.get()
 	if err != nil {
 		return nil, fmt.Errorf("bootstrapping %s: %w", p.boot.URL, err)
 	}
 
+	// First, we attempt to create an HTTP3 transport.  If the probe QUIC
+	// connection is established successfully, we'll be using HTTP3 for this
+	// upstream.
+	transportH3, err := p.createTransportH3(tlsConfig, dialContext)
+	if err == nil {
+		log.Debug("using HTTP/3 for this upstream: QUIC was faster")
+		return transportH3, nil
+	}
+
+	log.Debug("using HTTP/2 for this upstream: %v", err)
+
+	if !p.supportsHTTP() {
+		return nil, errors.Error("HTTP1/1 and HTTP2 are not supported by this upstream")
+	}
+
 	transport := &http.Transport{
 		TLSClientConfig:    tlsConfig,
 		DisableCompression: true,
@@ -210,3 +257,185 @@ func (p *dnsOverHTTPS) createTransport() (*http.Transport, error) {
 
 	return transport, nil
 }
+
+// createTransportH3 tries to create an HTTP/3 transport for this upstream.
+// We should be able to fall back to H1/H2 in case if HTTP/3 is unavailable or
+// if it is too slow.  In order to do that, this method will run two probes
+// in parallel (one for TLS, the other one for QUIC) and if QUIC is faster it
+// will create the *http3.RoundTripper instance.
+func (p *dnsOverHTTPS) createTransportH3(
+	tlsConfig *tls.Config,
+	dialContext dialHandler,
+) (roundTripper *http3.RoundTripper, err error) {
+	if !p.supportsH3() {
+		return nil, errors.Error("HTTP3 support is not enabled")
+	}
+
+	addr, err := p.probeH3(tlsConfig, dialContext)
+	if err != nil {
+		return nil, err
+	}
+
+	return &http3.RoundTripper{
+		Dial: func(
+			ctx context.Context,
+			// Ignore the address and always connect to the one that we got
+			// from the bootstrapper.
+			_ string,
+			tlsCfg *tls.Config,
+			cfg *quic.Config,
+		) (c quic.EarlyConnection, err error) {
+			return quic.DialAddrEarlyContext(ctx, addr, tlsCfg, cfg)
+		},
+		DisableCompression: true,
+		TLSClientConfig:    tlsConfig,
+		QuicConfig:         p.quicConfig,
+	}, nil
+}
+
+// probeH3 runs a test to check whether QUIC is faster than TLS for this
+// upstream.  If the test is successful it will return the address that we
+// should use to establish the QUIC connections.
+func (p *dnsOverHTTPS) probeH3(
+	tlsConfig *tls.Config,
+	dialContext dialHandler,
+) (addr string, err error) {
+	// We're using bootstrapped address instead of what's passed to the function
+	// it does not create an actual connection, but it helps us determine
+	// what IP is actually reachable (when there are v4/v6 addresses).
+	rawConn, err := dialContext(context.Background(), "udp", "")
+	if err != nil {
+		return "", fmt.Errorf("failed to dial: %w", err)
+	}
+	// It's never actually used.
+	_ = rawConn.Close()
+
+	udpConn, ok := rawConn.(*net.UDPConn)
+	if !ok {
+		return "", fmt.Errorf("not a UDP connection to %s", p.Address())
+	}
+
+	addr = udpConn.RemoteAddr().String()
+
+	// Avoid spending time on probing if this upstream only supports HTTP/3.
+	if p.supportsH3() && !p.supportsHTTP() {
+		return addr, nil
+	}
+
+	// Use a new *tls.Config with empty session cache for probe connections.
+	// Surprisingly, this is really important since otherwise it invalidates
+	// the existing cache.
+	// TODO(ameshkov): figure out why the sessions cache invalidates here.
+	probeTLSCfg := tlsConfig.Clone()
+	probeTLSCfg.ClientSessionCache = nil
+
+	// Do not expose probe connections to the callbacks that are passed to
+	// the bootstrap options to avoid side-effects.
+	// TODO(ameshkov): consider exposing, somehow mark that this is a probe.
+	probeTLSCfg.VerifyPeerCertificate = nil
+	probeTLSCfg.VerifyConnection = nil
+
+	// Run probeQUIC and probeTLS in parallel and see which one is faster.
+	chQuic := make(chan error, 1)
+	chTLS := make(chan error, 1)
+	go p.probeQUIC(addr, probeTLSCfg, chQuic)
+	go p.probeTLS(dialContext, probeTLSCfg, chTLS)
+
+	select {
+	case quicErr := <-chQuic:
+		if quicErr != nil {
+			// QUIC failed, return error since HTTP3 was not preferred.
+			return "", quicErr
+		}
+
+		// Return immediately, QUIC was faster.
+		return addr, quicErr
+	case tlsErr := <-chTLS:
+		if tlsErr != nil {
+			// Return immediately, TLS failed.
+			log.Debug("probing TLS: %v", tlsErr)
+			return addr, nil
+		}
+
+		return "", errors.Error("TLS was faster than QUIC, prefer it")
+	}
+}
+
+// probeQUIC attempts to establish a QUIC connection to the specified address.
+// We run probeQUIC and probeTLS in parallel and see which one is faster.
+func (p *dnsOverHTTPS) probeQUIC(addr string, tlsConfig *tls.Config, ch chan error) {
+	startTime := time.Now()
+
+	timeout := p.boot.options.Timeout
+	if timeout == 0 {
+		timeout = dialTimeout
+	}
+	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(timeout))
+	defer cancel()
+
+	conn, err := quic.DialAddrEarlyContext(ctx, addr, tlsConfig, p.quicConfig)
+	if err != nil {
+		ch <- fmt.Errorf("opening QUIC connection to %s: %w", p.Address(), err)
+		return
+	}
+
+	// Ignore the error since there's no way we can use it for anything useful.
+	_ = conn.CloseWithError(QUICCodeNoError, "")
+
+	ch <- nil
+
+	elapsed := time.Now().Sub(startTime)
+	log.Debug("elapsed on establishing a QUIC connection: %s", elapsed)
+}
+
+// probeTLS attempts to establish a TLS connection to the specified address. We
+// run probeQUIC and probeTLS in parallel and see which one is faster.
+func (p *dnsOverHTTPS) probeTLS(dialContext dialHandler, tlsConfig *tls.Config, ch chan error) {
+	startTime := time.Now()
+
+	conn, err := tlsDial(dialContext, "tcp", tlsConfig)
+	if err != nil {
+		ch <- fmt.Errorf("opening TLS connection: %w", err)
+		return
+	}
+
+	// Ignore the error since there's no way we can use it for anything useful.
+	_ = conn.Close()
+
+	ch <- nil
+
+	elapsed := time.Now().Sub(startTime)
+	log.Debug("elapsed on establishing a TLS connection: %s", elapsed)
+}
+
+// supportsH3 returns true if HTTP/3 is supported by this upstream.
+func (p *dnsOverHTTPS) supportsH3() (ok bool) {
+	for _, v := range p.supportedHTTPVersions() {
+		if v == HTTPVersion3 {
+			return true
+		}
+	}
+
+	return false
+}
+
+// supportsHTTP returns true if HTTP/1.1 or HTTP2 is supported by this upstream.
+func (p *dnsOverHTTPS) supportsHTTP() (ok bool) {
+	for _, v := range p.supportedHTTPVersions() {
+		if v == HTTPVersion11 || v == HTTPVersion2 {
+			return true
+		}
+	}
+
+	return false
+}
+
+// supportedHTTPVersions returns the list of supported HTTP versions.
+func (p *dnsOverHTTPS) supportedHTTPVersions() (v []HTTPVersion) {
+	v = p.boot.options.HTTPVersions
+	if v == nil {
+		v = DefaultHTTPVersions
+	}
+
+	return v
+}
diff --git a/upstream/upstream_doh_test.go b/upstream/upstream_doh_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..16a9a09f44fd01cb325aa2055d47c56b0f170d6a
--- /dev/null
+++ b/upstream/upstream_doh_test.go
@@ -0,0 +1,264 @@
+package upstream
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/base64"
+	"fmt"
+	"net"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/lucas-clemente/quic-go"
+	"github.com/lucas-clemente/quic-go/http3"
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUpstreamDoH(t *testing.T) {
+	testCases := []struct {
+		name             string
+		http3Enabled     bool
+		httpVersions     []HTTPVersion
+		delayHandshakeH3 time.Duration
+		delayHandshakeH2 time.Duration
+		expectedProtocol HTTPVersion
+	}{{
+		name:             "http1.1_h2",
+		http3Enabled:     false,
+		httpVersions:     []HTTPVersion{HTTPVersion11, HTTPVersion2},
+		expectedProtocol: HTTPVersion2,
+	}, {
+		name:             "fallback_to_http2",
+		http3Enabled:     false,
+		httpVersions:     []HTTPVersion{HTTPVersion3, HTTPVersion2},
+		expectedProtocol: HTTPVersion2,
+	}, {
+		name:             "http3",
+		http3Enabled:     true,
+		httpVersions:     []HTTPVersion{HTTPVersion3},
+		expectedProtocol: HTTPVersion3,
+	}, {
+		name:             "race_http3_faster",
+		http3Enabled:     true,
+		httpVersions:     []HTTPVersion{HTTPVersion3, HTTPVersion2},
+		delayHandshakeH2: time.Second,
+		expectedProtocol: HTTPVersion3,
+	}, {
+		name:             "race_http2_faster",
+		http3Enabled:     true,
+		httpVersions:     []HTTPVersion{HTTPVersion3, HTTPVersion2},
+		delayHandshakeH3: time.Second,
+		expectedProtocol: HTTPVersion2,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			srv := startDoHServer(t, testDoHServerOptions{
+				http3Enabled:     tc.http3Enabled,
+				delayHandshakeH2: tc.delayHandshakeH2,
+				delayHandshakeH3: tc.delayHandshakeH3,
+			})
+			t.Cleanup(srv.Shutdown)
+
+			// Create a DNS-over-HTTPS upstream.
+			address := fmt.Sprintf("https://%s/dns-query", srv.addr)
+
+			var lastState tls.ConnectionState
+			u, err := AddressToUpstream(
+				address,
+				&Options{
+					InsecureSkipVerify: true,
+					HTTPVersions:       tc.httpVersions,
+					VerifyConnection: func(state tls.ConnectionState) (err error) {
+						if state.NegotiatedProtocol != string(tc.expectedProtocol) {
+							return fmt.Errorf(
+								"expected %s, got %s",
+								tc.expectedProtocol,
+								state.NegotiatedProtocol,
+							)
+						}
+						lastState = state
+						return nil
+					},
+				},
+			)
+			require.NoError(t, err)
+
+			// Test that it responds properly.
+			for i := 0; i < 10; i++ {
+				checkUpstream(t, u, address)
+			}
+
+			doh := u.(*dnsOverHTTPS)
+
+			// Trigger re-connection.
+			doh.client = nil
+
+			// Force it to establish the connection again.
+			checkUpstream(t, u, address)
+
+			// Check that TLS session was resumed properly.
+			require.True(t, lastState.DidResume)
+		})
+	}
+}
+
+// testDoHServerOptions allows customizing testDoHServer behavior.
+type testDoHServerOptions struct {
+	http3Enabled     bool
+	delayHandshakeH2 time.Duration
+	delayHandshakeH3 time.Duration
+}
+
+// testDoHServer is an instance of a test DNS-over-HTTPS server that we use
+// for tests.
+type testDoHServer struct {
+	// addr is the address that this server listens to.
+	addr string
+
+	// tlsConfig is the TLS configuration that is used for this server.
+	tlsConfig *tls.Config
+
+	// server is an HTTP/1.1 and HTTP/2 server.
+	server *http.Server
+
+	// serverH3 is an HTTP/3 server.
+	serverH3 *http3.Server
+}
+
+// Shutdown stops the DOH server.
+func (s *testDoHServer) Shutdown() {
+	if s.server != nil {
+		_ = s.server.Shutdown(context.Background())
+	}
+
+	if s.serverH3 != nil {
+		_ = s.serverH3.Close()
+	}
+}
+
+// startDoHServer starts a new DNS-over-HTTPS server on a random port and
+// returns the instance of this server.  Depending on whether http3Enabled is
+// set to true or false it will or will not initialize a HTTP/3 server.
+func startDoHServer(
+	t *testing.T,
+	opts testDoHServerOptions,
+) (s *testDoHServer) {
+	tlsConfig := createServerTLSConfig(t, "127.0.0.1")
+	handler := createDoHHandler()
+
+	// Step one is to create a regular HTTP server, we'll always have it
+	// running.
+	server := &http.Server{
+		Handler: handler,
+	}
+
+	// Listen TCP first.
+	tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+
+	tcpListen, err := net.ListenTCP("tcp", tcpAddr)
+	require.NoError(t, err)
+
+	tlsConfigH2 := tlsConfig.Clone()
+	tlsConfigH2.NextProtos = []string{string(HTTPVersion2), string(HTTPVersion11)}
+	tlsConfigH2.GetConfigForClient = func(_ *tls.ClientHelloInfo) (*tls.Config, error) {
+		if opts.delayHandshakeH2 > 0 {
+			time.Sleep(opts.delayHandshakeH2)
+		}
+		return nil, nil
+	}
+	tlsListen := tls.NewListener(tcpListen, tlsConfigH2)
+
+	// Run the H1/H2 server.
+	go server.Serve(tlsListen)
+
+	// Get the real address that the listener now listens to.
+	tcpAddr = tcpListen.Addr().(*net.TCPAddr)
+
+	var serverH3 *http3.Server
+
+	if opts.http3Enabled {
+		tlsConfigH3 := tlsConfig.Clone()
+		tlsConfigH3.NextProtos = []string{string(HTTPVersion3)}
+		tlsConfigH3.GetConfigForClient = func(_ *tls.ClientHelloInfo) (*tls.Config, error) {
+			if opts.delayHandshakeH3 > 0 {
+				time.Sleep(opts.delayHandshakeH3)
+			}
+			return nil, nil
+		}
+
+		serverH3 = &http3.Server{
+			TLSConfig:  tlsConfig.Clone(),
+			QuicConfig: &quic.Config{},
+			Handler:    handler,
+		}
+
+		// Listen UDP for the H3 server. Reuse the same port as was used for the
+		// TCP listener.
+		udpAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("127.0.0.1:%d", tcpAddr.Port))
+		require.NoError(t, err)
+
+		udpListen, err := net.ListenUDP("udp", udpAddr)
+		require.NoError(t, err)
+
+		// Run the H3 server.
+		go serverH3.Serve(udpListen)
+	}
+
+	return &testDoHServer{
+		tlsConfig: tlsConfig,
+		server:    server,
+		serverH3:  serverH3,
+		// Save the address that the server listens to.
+		addr: tcpAddr.String(),
+	}
+}
+
+// createDoHHandler returns a very simple http.Handler that reads the incoming
+// request and returns with a test message.
+func createDoHHandler() (h http.Handler) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/dns-query", func(w http.ResponseWriter, r *http.Request) {
+		dnsParam := r.URL.Query().Get("dns")
+		buf, err := base64.RawURLEncoding.DecodeString(dnsParam)
+		if err != nil {
+			http.Error(
+				w,
+				fmt.Sprintf("internal error: %s", err),
+				http.StatusInternalServerError,
+			)
+			return
+		}
+
+		m := &dns.Msg{}
+		err = m.Unpack(buf)
+		if err != nil {
+			http.Error(
+				w,
+				fmt.Sprintf("internal error: %s", err),
+				http.StatusInternalServerError,
+			)
+			return
+		}
+
+		resp := respondToTestMessage(m)
+
+		buf, err = resp.Pack()
+		if err != nil {
+			http.Error(
+				w,
+				fmt.Sprintf("internal error: %s", err),
+				http.StatusInternalServerError,
+			)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/dns-message")
+		_, err = w.Write(buf)
+	})
+
+	return mux
+}
diff --git a/upstream/upstream_dot.go b/upstream/upstream_dot.go
index 4b31d33096e3e910dfe93846ce411f05f98f2bf5..380478d34eaed09551c3fb9b0950d6291d0396d7 100644
--- a/upstream/upstream_dot.go
+++ b/upstream/upstream_dot.go
@@ -11,9 +11,8 @@ import (
 	"github.com/miekg/dns"
 )
 
-//
-// DNS-over-TLS
-//
+// dnsOverTLS is a struct that implements the Upstream interface for the
+// DNS-over-TLS protocol.
 type dnsOverTLS struct {
 	boot *bootstrapper
 	pool *TLSPool
@@ -37,8 +36,10 @@ func newDoT(uu *url.URL, opts *Options) (u Upstream, err error) {
 	return &dnsOverTLS{boot: b}, nil
 }
 
+// Address implements the Upstream interface for *dnsOverTLS.
 func (p *dnsOverTLS) Address() string { return p.boot.URL.String() }
 
+// Exchange implements the Upstream interface for *dnsOverTLS.
 func (p *dnsOverTLS) Exchange(m *dns.Msg) (reply *dns.Msg, err error) {
 	var pool *TLSPool
 	p.RLock()
diff --git a/upstream/upstream_plain.go b/upstream/upstream_plain.go
index 1b6d17813736c94b467a5320a8742f8c50b438e8..f5f4efd67619d626e21e668e8e41a262097af3a2 100644
--- a/upstream/upstream_plain.go
+++ b/upstream/upstream_plain.go
@@ -8,9 +8,8 @@ import (
 	"github.com/miekg/dns"
 )
 
-//
-// plain DNS
-//
+// plainDNS is a struct that implements the Upstream interface for the regular
+// DNS protocol.
 type plainDNS struct {
 	address   string
 	timeout   time.Duration
@@ -31,7 +30,7 @@ func newPlain(uu *url.URL, timeout time.Duration, preferTCP bool) (u *plainDNS)
 	}
 }
 
-// Address returns the original address that we've put in initially, not resolved one
+// Address implements the Upstream interface for *plainDNS.
 func (p *plainDNS) Address() string {
 	if p.preferTCP {
 		return "tcp://" + p.address
@@ -40,6 +39,7 @@ func (p *plainDNS) Address() string {
 	return p.address
 }
 
+// Exchange implements the Upstream interface for *plainDNS.
 func (p *plainDNS) Exchange(m *dns.Msg) (*dns.Msg, error) {
 	if p.preferTCP {
 		tcpClient := dns.Client{Net: "tcp", Timeout: p.timeout}
diff --git a/upstream/upstream_pool.go b/upstream/upstream_pool.go
index 276fa04fa0240900725c8a737774f742ba35ae5d..9c3ddd3bc8ecb5c8ca4f81e15cd5b47365f6a91b 100644
--- a/upstream/upstream_pool.go
+++ b/upstream/upstream_pool.go
@@ -11,35 +11,40 @@ import (
 	"github.com/AdguardTeam/golibs/log"
 )
 
+// dialTimeout is the global timeout for establishing a TLS connection.
+// TODO(ameshkov): use bootstrap timeout instead.
 const dialTimeout = 10 * time.Second
 
 // TLSPool is a connections pool for the DNS-over-TLS Upstream.
 //
 // Example:
-//  pool := TLSPool{Address: "tls://1.1.1.1:853"}
-//  netConn, err := pool.Get()
-//  if err != nil {panic(err)}
-//  c := dns.Conn{Conn: netConn}
-//  q := dns.Msg{}
-//  q.SetQuestion("google.com.", dns.TypeA)
-//  log.Println(q)
-//  err = c.WriteMsg(&q)
-//  if err != nil {panic(err)}
-//  r, err := c.ReadMsg()
-//  if err != nil {panic(err)}
-//  log.Println(r)
-//  pool.Put(c.Conn)
+//
+//	pool := TLSPool{Address: "tls://1.1.1.1:853"}
+//	netConn, err := pool.Get()
+//	if err != nil {panic(err)}
+//	c := dns.Conn{Conn: netConn}
+//	q := dns.Msg{}
+//	q.SetQuestion("google.com.", dns.TypeA)
+//	log.Println(q)
+//	err = c.WriteMsg(&q)
+//	if err != nil {panic(err)}
+//	r, err := c.ReadMsg()
+//	if err != nil {panic(err)}
+//	log.Println(r)
+//	pool.Put(c.Conn)
 type TLSPool struct {
 	boot *bootstrapper
 
-	// connections
-	conns      []net.Conn
-	connsMutex sync.Mutex // protects conns
+	// conns is the list of connections available in the pool.
+	conns []net.Conn
+	// connsMutex protects conns.
+	connsMutex sync.Mutex
 }
 
-// Get gets or creates a new TLS connection
+// Get gets a connection from the pool (if there's one available) or creates
+// a new TLS connection.
 func (n *TLSPool) Get() (net.Conn, error) {
-	// get the connection from the slice inside the lock
+	// Get the connection from the slice inside the lock.
 	var c net.Conn
 	n.connsMutex.Lock()
 	num := len(n.conns)
@@ -50,7 +55,7 @@ func (n *TLSPool) Get() (net.Conn, error) {
 	}
 	n.connsMutex.Unlock()
 
-	// if we got connection from the slice, update deadline and return it.
+	// If we got connection from the slice, update deadline and return it.
 	if c != nil {
 		err := c.SetDeadline(time.Now().Add(dialTimeout))
 
@@ -64,7 +69,7 @@ func (n *TLSPool) Get() (net.Conn, error) {
 	return n.Create()
 }
 
-// Create creates a new connection for the pool (but not puts it there)
+// Create creates a new connection for the pool (but not puts it there).
 func (n *TLSPool) Create() (net.Conn, error) {
 	tlsConfig, dialContext, err := n.boot.get()
 	if err != nil {
@@ -80,7 +85,7 @@ func (n *TLSPool) Create() (net.Conn, error) {
 	return conn, nil
 }
 
-// Put returns connection to the pool
+// Put returns the connection to the pool.
 func (n *TLSPool) Put(c net.Conn) {
 	if c == nil {
 		return
@@ -90,16 +95,18 @@ func (n *TLSPool) Put(c net.Conn) {
 	n.connsMutex.Unlock()
 }
 
-// tlsDial is basically the same as tls.DialWithDialer, but we will call our own dialContext function to get connection
+// tlsDial is basically the same as tls.DialWithDialer, but we will call our own
+// dialContext function to get connection.
 func tlsDial(dialContext dialHandler, network string, config *tls.Config) (*tls.Conn, error) {
-	// we're using bootstrapped address instead of what's passed to the function
+	// We're using bootstrapped address instead of what's passed
+	// to the function.
 	rawConn, err := dialContext(context.Background(), network, "")
 	if err != nil {
 		return nil, err
 	}
 
-	// we want the timeout to cover the whole process: TCP connection and TLS handshake
-	// dialTimeout will be used as connection deadLine
+	// We want the timeout to cover the whole process: TCP connection and
+	// TLS handshake dialTimeout will be used as connection deadLine.
 	conn := tls.Client(rawConn, config)
 	err = conn.SetDeadline(time.Now().Add(dialTimeout))
 	if err != nil {
diff --git a/upstream/upstream_pool_test.go b/upstream/upstream_pool_test.go
index fe59edfa02d6dd7e9030b716fe0cd4f6a4fb7b8d..85b73ad3bb0e7651960b2c6dae5b38fd042d84a8 100644
--- a/upstream/upstream_pool_test.go
+++ b/upstream/upstream_pool_test.go
@@ -1,48 +1,51 @@
 package upstream
 
 import (
+	"crypto/tls"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/require"
 )
 
 func TestTLSPoolReconnect(t *testing.T) {
+	var lastState tls.ConnectionState
 	u, err := AddressToUpstream(
 		"tls://one.one.one.one",
 		&Options{
 			Bootstrap: []string{"8.8.8.8:53"},
 			Timeout:   timeout,
+			VerifyConnection: func(state tls.ConnectionState) error {
+				lastState = state
+				return nil
+			},
 		},
 	)
-	if err != nil {
-		t.Fatalf("cannot create upstream: %s", err)
-	}
+	require.NoError(t, err)
 
-	// Send the first test message
+	// Send the first test message.
 	req := createTestMessage()
 	reply, err := u.Exchange(req)
-	if err != nil {
-		t.Fatalf("first DNS message failed: %s", err)
-	}
+	require.NoError(t, err)
 	requireResponse(t, req, reply)
 
-	// Now let's close the pooled connection and return it back to the pool
+	// Now let's close the pooled connection and return it back to the pool.
 	p := u.(*dnsOverTLS)
 	conn, _ := p.pool.Get()
 	conn.Close()
 	p.pool.Put(conn)
 
-	// Send the second test message
+	// Send the second test message.
 	req = createTestMessage()
 	reply, err = u.Exchange(req)
-	if err != nil {
-		t.Fatalf("second DNS message failed: %s", err)
-	}
+	require.NoError(t, err)
 	requireResponse(t, req, reply)
 
 	// Now assert that the number of connections in the pool is not changed
-	if len(p.pool.conns) != 1 {
-		t.Fatal("wrong number of pooled connections")
-	}
+	require.Len(t, p.pool.conns, 1)
+
+	// Check that the session was resumed on the last attempt.
+	require.True(t, lastState.DidResume)
 }
 
 func TestTLSPoolDeadLine(t *testing.T) {
diff --git a/upstream/upstream_quic.go b/upstream/upstream_quic.go
index e4ca6df3d6c6b9656ce5eded37ea12433c468ca7..eb98fd171726a771cf9ef678ed43de8d696f09c6 100644
--- a/upstream/upstream_quic.go
+++ b/upstream/upstream_quic.go
@@ -15,30 +15,37 @@ import (
 )
 
 const (
-	// DoQCodeNoError is used when the connection or stream needs to be closed,
+	// QUICCodeNoError is used when the connection or stream needs to be closed,
 	// but there is no error to signal.
-	DoQCodeNoError = quic.ApplicationErrorCode(0)
-	// DoQCodeInternalError signals that the DoQ implementation encountered
+	QUICCodeNoError = quic.ApplicationErrorCode(0)
+	// QUICCodeInternalError signals that the DoQ implementation encountered
 	// an internal error and is incapable of pursuing the transaction or the
 	// connection.
-	DoQCodeInternalError = quic.ApplicationErrorCode(1)
-	// DoQCodeProtocolError signals that the DoQ implementation encountered
+	QUICCodeInternalError = quic.ApplicationErrorCode(1)
+	// QUICCodeProtocolError signals that the DoQ implementation encountered
 	// a protocol error and is forcibly aborting the connection.
-	DoQCodeProtocolError = quic.ApplicationErrorCode(2)
+	QUICCodeProtocolError = quic.ApplicationErrorCode(2)
+	// QUICKeepAlivePeriod is the value that we pass to *quic.Config and that
+	// controls the period with with keep-alive frames are being sent to the
+	// connection. We set it to 20s as it would be in the quic-go@v0.27.1 with
+	// KeepAlive field set to true This value is specified in
+	// https://pkg.go.dev/github.com/lucas-clemente/quic-go/internal/protocol#MaxKeepAliveInterval.
+	//
+	// TODO(ameshkov):  Consider making it configurable.
+	QUICKeepAlivePeriod = time.Second * 20
 )
 
-//
-// dnsOverQUIC is a DNS-over-QUIC implementation according to the spec:
-// https://www.rfc-editor.org/rfc/rfc9250.html
-//
+// dnsOverQUIC is a struct that implements the Upstream interface for the
+// DNS-over-QUIC protocol (spec: https://www.rfc-editor.org/rfc/rfc9250.html).
 type dnsOverQUIC struct {
 	// boot is a bootstrap DNS abstraction that is used to resolve the upstream
 	// server's address and open a network connection to it.
 	boot *bootstrapper
-	// tokenStore is a QUIC token store that is used across QUIC connections.
-	// Since the QUIC config is re-created when a connection is (re-)opened
-	// the tokenStore is instead saved as part of the dnsOverQUIC struct.
-	tokenStore quic.TokenStore
+	// quicConfig is the QUIC configuration that is used for establishing
+	// connections to the upstream.  This configuration includes the TokenStore
+	// that needs to be stored for the lifetime of dnsOverQUIC since we can
+	// re-create the connection.
+	quicConfig *quic.Config
 	// conn is the current active QUIC connection.  It can be closed and
 	// re-opened when needed.
 	conn quic.Connection
@@ -62,11 +69,24 @@ func newDoQ(uu *url.URL, opts *Options) (u Upstream, err error) {
 		return nil, fmt.Errorf("creating quic bootstrapper: %w", err)
 	}
 
-	return &dnsOverQUIC{boot: b, tokenStore: quic.NewLRUTokenStore(1, 10)}, nil
+	return &dnsOverQUIC{
+		boot: b,
+		quicConfig: &quic.Config{
+			KeepAlivePeriod: QUICKeepAlivePeriod,
+			// You can read more on address validation here:
+			// https://datatracker.ietf.org/doc/html/rfc9000#section-8.1
+			// Setting maxOrigins to 1 and tokensPerOrigin to 10 assuming that
+			// this is more than enough for the way we use it (one connection
+			// per upstream).
+			TokenStore: quic.NewLRUTokenStore(1, 10),
+		},
+	}, nil
 }
 
+// Address implements the Upstream interface for *dnsOverQUIC.
 func (p *dnsOverQUIC) Address() string { return p.boot.URL.String() }
 
+// Exchange implements the Upstream interface for *dnsOverQUIC.
 func (p *dnsOverQUIC) Exchange(m *dns.Msg) (res *dns.Msg, err error) {
 	var conn quic.Connection
 	conn, err = p.getConnection(true)
@@ -95,13 +115,13 @@ func (p *dnsOverQUIC) Exchange(m *dns.Msg) (res *dns.Msg, err error) {
 	var stream quic.Stream
 	stream, err = p.openStream(conn)
 	if err != nil {
-		p.closeConnWithError(DoQCodeInternalError)
+		p.closeConnWithError(QUICCodeInternalError)
 		return nil, fmt.Errorf("open new stream to %s: %w", p.Address(), err)
 	}
 
 	_, err = stream.Write(proxyutil.AddPrefix(buf))
 	if err != nil {
-		p.closeConnWithError(DoQCodeInternalError)
+		p.closeConnWithError(QUICCodeInternalError)
 		return nil, fmt.Errorf("failed to write to a QUIC stream: %w", err)
 	}
 
@@ -117,7 +137,7 @@ func (p *dnsOverQUIC) Exchange(m *dns.Msg) (res *dns.Msg, err error) {
 		// fatal error.  It SHOULD forcibly abort the connection using QUIC's
 		// CONNECTION_CLOSE mechanism and SHOULD use the DoQ error code
 		// DOQ_PROTOCOL_ERROR.
-		p.closeConnWithError(DoQCodeProtocolError)
+		p.closeConnWithError(QUICCodeProtocolError)
 	}
 	return res, err
 }
@@ -152,7 +172,7 @@ func (p *dnsOverQUIC) getConnection(useCached bool) (quic.Connection, error) {
 	}
 	if conn != nil {
 		// we're recreating the connection, let's create a new one.
-		_ = conn.CloseWithError(DoQCodeNoError, "")
+		_ = conn.CloseWithError(QUICCodeNoError, "")
 	}
 	p.RUnlock()
 
@@ -162,11 +182,10 @@ func (p *dnsOverQUIC) getConnection(useCached bool) (quic.Connection, error) {
 	var err error
 	conn, err = p.openConnection()
 	if err != nil {
-		// This does not look too nice, but QUIC (or maybe quic-go)
-		// doesn't seem stable enough.
-		// Maybe retransmissions aren't fully implemented in quic-go?
-		// Anyways, the simple solution is to make a second try when
-		// it fails to open the QUIC conn.
+		// This does not look too nice, but QUIC (or maybe quic-go) doesn't
+		// seem stable enough. Maybe retransmissions aren't fully implemented
+		// in quic-go? Anyways, the simple solution is to make a second try when
+		// it fails to open the QUIC connection.
 		conn, err = p.openConnection()
 		if err != nil {
 			return nil, err
@@ -224,17 +243,8 @@ func (p *dnsOverQUIC) openConnection() (conn quic.Connection, err error) {
 	}
 
 	addr := udpConn.RemoteAddr().String()
-	quicConfig := &quic.Config{
-		// Set the keep alive interval to 20s as it would be in the
-		// quic-go@v0.27.1 with KeepAlive field set to true.  This value is
-		// specified in
-		// https://pkg.go.dev/github.com/lucas-clemente/quic-go/internal/protocol#MaxKeepAliveInterval.
-		//
-		// TODO(ameshkov):  Consider making it configurable.
-		KeepAlivePeriod: 20 * time.Second,
-		TokenStore:	p.tokenStore,
-	}
-	conn, err = quic.DialAddrEarlyContext(context.Background(), addr, tlsConfig, quicConfig)
+
+	conn, err = quic.DialAddrEarlyContext(context.Background(), addr, tlsConfig, p.quicConfig)
 	if err != nil {
 		return nil, fmt.Errorf("opening quic connection to %s: %w", p.Address(), err)
 	}
diff --git a/upstream/upstream_quic_test.go b/upstream/upstream_quic_test.go
index f5d2ac5b73c6783e79c3c2912fa042d2d0ae8d62..0a2fe21f048d1f7809c60a80d4655526c63383c5 100644
--- a/upstream/upstream_quic_test.go
+++ b/upstream/upstream_quic_test.go
@@ -1,6 +1,7 @@
 package upstream
 
 import (
+	"crypto/tls"
 	"testing"
 
 	"github.com/lucas-clemente/quic-go"
@@ -10,7 +11,16 @@ import (
 func TestUpstreamDoQ(t *testing.T) {
 	// Create a DNS-over-QUIC upstream
 	address := "quic://dns.adguard.com"
-	u, err := AddressToUpstream(address, &Options{InsecureSkipVerify: true})
+	var lastState tls.ConnectionState
+	u, err := AddressToUpstream(
+		address,
+		&Options{
+			VerifyConnection: func(state tls.ConnectionState) error {
+				lastState = state
+				return nil
+			},
+		},
+	)
 	require.NoError(t, err)
 
 	uq := u.(*dnsOverQUIC)
@@ -23,8 +33,17 @@ func TestUpstreamDoQ(t *testing.T) {
 		if conn == nil {
 			conn = uq.conn
 		} else {
-			// This way we test that the conn is properly reused
+			// This way we test that the conn is properly reused.
 			require.Equal(t, conn, uq.conn)
 		}
 	}
+
+	// Close the connection (make sure that we re-establish the connection).
+	_ = conn.CloseWithError(quic.ApplicationErrorCode(0), "")
+
+	// Try to establish it again.
+	checkUpstream(t, u, address)
+
+	// Make sure that the session has been resumed.
+	require.True(t, lastState.DidResume)
 }
diff --git a/upstream/upstream_test.go b/upstream/upstream_test.go
index 4a0901c4143156260d16c277f4eb56ccc728daf2..dcf9e19a859f7d66b9afd2f9c3619681674148c6 100644
--- a/upstream/upstream_test.go
+++ b/upstream/upstream_test.go
@@ -1,8 +1,16 @@
 package upstream
 
 import (
+	"crypto/ecdsa"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
 	"fmt"
 	"io"
+	"math/big"
 	"net"
 	"net/url"
 	"os"
@@ -192,6 +200,10 @@ func TestUpstreams(t *testing.T) {
 		// Cloudflare DNS
 		address:   "quic://dns-unfiltered.adguard.com:784",
 		bootstrap: []string{},
+	}, {
+		// Google DNS (HTTP3)
+		address:   "h3://dns.google/dns-query",
+		bootstrap: []string{},
 	}}
 	for _, test := range upstreams {
 		t.Run(test.address, func(t *testing.T) {
@@ -237,6 +249,10 @@ func TestAddressToUpstream(t *testing.T) {
 		addr: "https://one.one.one.one",
 		opt:  opt,
 		want: "https://one.one.one.one:443",
+	}, {
+		addr: "h3://one.one.one.one",
+		opt:  opt,
+		want: "https://one.one.one.one:443",
 	}}
 
 	for _, tc := range testCases {
@@ -405,45 +421,6 @@ func TestUpstreamsWithServerIP(t *testing.T) {
 	}
 }
 
-func checkUpstream(t *testing.T, u Upstream, addr string) {
-	t.Helper()
-
-	req := createTestMessage()
-	reply, err := u.Exchange(req)
-	require.NoErrorf(t, err, "couldn't talk to upstream %s", addr)
-
-	requireResponse(t, req, reply)
-}
-
-func createTestMessage() *dns.Msg {
-	return createHostTestMessage("google-public-dns-a.google.com")
-}
-
-func createHostTestMessage(host string) (req *dns.Msg) {
-	return &dns.Msg{
-		MsgHdr: dns.MsgHdr{
-			Id:               dns.Id(),
-			RecursionDesired: true,
-		},
-		Question: []dns.Question{{
-			Name:   dns.Fqdn(host),
-			Qtype:  dns.TypeA,
-			Qclass: dns.ClassINET,
-		}},
-	}
-}
-
-func requireResponse(t *testing.T, req, reply *dns.Msg) {
-	require.NotNil(t, reply)
-	require.Lenf(t, reply.Answer, 1, "wrong number of answers: %d", len(reply.Answer))
-	require.Equal(t, req.Id, reply.Id)
-
-	a, ok := reply.Answer[0].(*dns.A)
-	require.Truef(t, ok, "wrong answer type: %v", reply.Answer[0])
-
-	require.Equalf(t, net.IPv4(8, 8, 8, 8), a.A.To16(), "wrong answer: %v", a.A)
-}
-
 func TestAddPort(t *testing.T) {
 	testCases := []struct {
 		name string
@@ -498,3 +475,131 @@ func TestAddPort(t *testing.T) {
 		})
 	}
 }
+
+func checkUpstream(t *testing.T, u Upstream, addr string) {
+	t.Helper()
+
+	req := createTestMessage()
+	reply, err := u.Exchange(req)
+	require.NoErrorf(t, err, "couldn't talk to upstream %s", addr)
+
+	requireResponse(t, req, reply)
+}
+
+func createTestMessage() (m *dns.Msg) {
+	return createHostTestMessage("google-public-dns-a.google.com")
+}
+
+func respondToTestMessage(m *dns.Msg) (resp *dns.Msg) {
+	resp = &dns.Msg{}
+	resp.SetReply(m)
+	resp.Answer = append(resp.Answer, &dns.A{
+		A: net.IPv4(8, 8, 8, 8),
+		Hdr: dns.RR_Header{
+			Name:   "google-public-dns-a.google.com.",
+			Rrtype: dns.TypeA,
+			Class:  dns.ClassINET,
+			Ttl:    100,
+		},
+	})
+
+	return resp
+}
+
+func createHostTestMessage(host string) (req *dns.Msg) {
+	return &dns.Msg{
+		MsgHdr: dns.MsgHdr{
+			Id:               dns.Id(),
+			RecursionDesired: true,
+		},
+		Question: []dns.Question{{
+			Name:   dns.Fqdn(host),
+			Qtype:  dns.TypeA,
+			Qclass: dns.ClassINET,
+		}},
+	}
+}
+
+func requireResponse(t *testing.T, req, reply *dns.Msg) {
+	require.NotNil(t, reply)
+	require.Lenf(t, reply.Answer, 1, "wrong number of answers: %d", len(reply.Answer))
+	require.Equal(t, req.Id, reply.Id)
+
+	a, ok := reply.Answer[0].(*dns.A)
+	require.Truef(t, ok, "wrong answer type: %v", reply.Answer[0])
+
+	require.Equalf(t, net.IPv4(8, 8, 8, 8), a.A.To16(), "wrong answer: %v", a.A)
+}
+
+// createServerTLSConfig creates a test server TLS configuration. It returns
+// a *tls.Config that can be used for both the server and the client.
+func createServerTLSConfig(t *testing.T, tlsServerName string) (tlsConfig *tls.Config) {
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	require.NoError(t, err)
+
+	notBefore := time.Now()
+	notAfter := notBefore.Add(5 * 365 * time.Hour * 24)
+
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"AdGuard Tests"},
+		},
+		NotBefore: notBefore,
+		NotAfter:  notAfter,
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+	template.DNSNames = append(template.DNSNames, tlsServerName)
+
+	derBytes, err := x509.CreateCertificate(
+		rand.Reader,
+		&template,
+		&template,
+		publicKey(privateKey),
+		privateKey,
+	)
+	require.NoError(t, err)
+
+	certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+	keyPem := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "RSA PRIVATE KEY",
+			Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
+		},
+	)
+
+	cert, err := tls.X509KeyPair(certPem, keyPem)
+	require.NoError(t, err)
+
+	roots := x509.NewCertPool()
+	roots.AppendCertsFromPEM(certPem)
+
+	tlsConfig = &tls.Config{
+		Certificates: []tls.Certificate{cert},
+		ServerName:   tlsServerName,
+		RootCAs:      roots,
+		MinVersion:   tls.VersionTLS12,
+	}
+
+	return tlsConfig
+}
+
+// publicKey extracts the public key from the specified private key.
+func publicKey(priv any) (pub any) {
+	switch k := priv.(type) {
+	case *rsa.PrivateKey:
+		return &k.PublicKey
+	case *ecdsa.PrivateKey:
+		return &k.PublicKey
+	default:
+		return nil
+	}
+}
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/body.go b/vendor/github.com/lucas-clemente/quic-go/http3/body.go
new file mode 100644
index 0000000000000000000000000000000000000000..d6e704ebcb169646fb06c040c6e7b4c1a01ce3dd
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/body.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/client.go b/vendor/github.com/lucas-clemente/quic-go/http3/client.go
new file mode 100644
index 0000000000000000000000000000000000000000..90115e4b1b82926b8ebfc9edcc466c733c3aae33
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/client.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/error_codes.go b/vendor/github.com/lucas-clemente/quic-go/http3/error_codes.go
new file mode 100644
index 0000000000000000000000000000000000000000..d87eef4ae076a40de382cee10cb5833b0e2a9d8f
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/error_codes.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/frames.go b/vendor/github.com/lucas-clemente/quic-go/http3/frames.go
new file mode 100644
index 0000000000000000000000000000000000000000..af9f28eb9f5b1a02e2e01b5db3231d9ee2ae6950
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/frames.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/gzip_reader.go b/vendor/github.com/lucas-clemente/quic-go/http3/gzip_reader.go
new file mode 100644
index 0000000000000000000000000000000000000000..01983ac77b8c79eb430d13f89ea40bba3b29a89c
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/gzip_reader.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/http_stream.go b/vendor/github.com/lucas-clemente/quic-go/http3/http_stream.go
new file mode 100644
index 0000000000000000000000000000000000000000..4c69068cdbb5c815822fdcc531f5225b5e26026c
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/http_stream.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/request.go b/vendor/github.com/lucas-clemente/quic-go/http3/request.go
new file mode 100644
index 0000000000000000000000000000000000000000..0b9a7278cc0a4442057190304a5fec6f3456465a
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/request.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/request_writer.go b/vendor/github.com/lucas-clemente/quic-go/http3/request_writer.go
new file mode 100644
index 0000000000000000000000000000000000000000..3dd1cbaef341fb716b2d3e38b998ed82fe479869
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/request_writer.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/response_writer.go b/vendor/github.com/lucas-clemente/quic-go/http3/response_writer.go
new file mode 100644
index 0000000000000000000000000000000000000000..70a7cd3f484382167a8b7acead1d2cbe237099b3
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/response_writer.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/roundtrip.go b/vendor/github.com/lucas-clemente/quic-go/http3/roundtrip.go
new file mode 100644
index 0000000000000000000000000000000000000000..5cde95a62fd8a6ffeee9bc34fbf248f09d1c7b15
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/roundtrip.go differ
diff --git a/vendor/github.com/lucas-clemente/quic-go/http3/server.go b/vendor/github.com/lucas-clemente/quic-go/http3/server.go
new file mode 100644
index 0000000000000000000000000000000000000000..086bc8e256452aa23187438c9785ef36bd5b33cc
Binary files /dev/null and b/vendor/github.com/lucas-clemente/quic-go/http3/server.go differ
diff --git a/vendor/github.com/marten-seemann/qpack/.codecov.yml b/vendor/github.com/marten-seemann/qpack/.codecov.yml
new file mode 100644
index 0000000000000000000000000000000000000000..00064af33101737796f3746de1a6b8059bab0b3f
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/.codecov.yml differ
diff --git a/vendor/github.com/marten-seemann/qpack/.gitignore b/vendor/github.com/marten-seemann/qpack/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..66c189a0904978caf9f02955729eb557c2856656
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/.gitignore differ
diff --git a/vendor/github.com/marten-seemann/qpack/.gitmodules b/vendor/github.com/marten-seemann/qpack/.gitmodules
new file mode 100644
index 0000000000000000000000000000000000000000..5ac16f084afde78701f1ae3b0c1c963ff2150fca
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/.gitmodules differ
diff --git a/vendor/github.com/marten-seemann/qpack/.golangci.yml b/vendor/github.com/marten-seemann/qpack/.golangci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4a91adc77ace30b4359b17c42ff01ef5bd2d2e20
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/.golangci.yml differ
diff --git a/vendor/github.com/marten-seemann/qpack/LICENSE.md b/vendor/github.com/marten-seemann/qpack/LICENSE.md
new file mode 100644
index 0000000000000000000000000000000000000000..1ac5a2d9ae6a99a7da8cf29193770a0d8c04e430
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/LICENSE.md differ
diff --git a/vendor/github.com/marten-seemann/qpack/README.md b/vendor/github.com/marten-seemann/qpack/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a621825ac9d5b5d7f5162d7a62c739a63f1a1b56
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/README.md differ
diff --git a/vendor/github.com/marten-seemann/qpack/decoder.go b/vendor/github.com/marten-seemann/qpack/decoder.go
new file mode 100644
index 0000000000000000000000000000000000000000..c9001941333ebe646960641122a87fb408179fb0
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/decoder.go differ
diff --git a/vendor/github.com/marten-seemann/qpack/encoder.go b/vendor/github.com/marten-seemann/qpack/encoder.go
new file mode 100644
index 0000000000000000000000000000000000000000..13e0ad2c7d1f9631e8fef48c655b4da24acc4a08
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/encoder.go differ
diff --git a/vendor/github.com/marten-seemann/qpack/header_field.go b/vendor/github.com/marten-seemann/qpack/header_field.go
new file mode 100644
index 0000000000000000000000000000000000000000..4c043a9928c5a45d38de71bdb83acd9f7687ec9d
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/header_field.go differ
diff --git a/vendor/github.com/marten-seemann/qpack/static_table.go b/vendor/github.com/marten-seemann/qpack/static_table.go
new file mode 100644
index 0000000000000000000000000000000000000000..930e83c7c12c392c5d3c882b2a0231bcd021bfa6
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/static_table.go differ
diff --git a/vendor/github.com/marten-seemann/qpack/varint.go b/vendor/github.com/marten-seemann/qpack/varint.go
new file mode 100644
index 0000000000000000000000000000000000000000..28d71122e139c922be0232e48d26da18432366ee
Binary files /dev/null and b/vendor/github.com/marten-seemann/qpack/varint.go differ
diff --git a/vendor/modules.txt b/vendor/modules.txt
index cf5e87e03dec0cb446390ef3e2043fa1f337cbe7..170d03040e5282e5a3f9ca4ddfd1e9471e212b05 100644
Binary files a/vendor/modules.txt and b/vendor/modules.txt differ