diff --git a/doc/api/cli.md b/doc/api/cli.md
index eb8ee7020cb894..46007c5b86b927 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -966,6 +966,14 @@ If the ES module being `require()`'d contains top-level `await`, this flag
 allows Node.js to evaluate the module, try to locate the
 top-level awaits, and print their location to help users find them.
 
+### `--experimental-quic`
+
+<!--
+added: REPLACEME
+-->
+
+Enables the experimental `node:quic` built-in module.
+
 ### `--experimental-require-module`
 
 <!-- YAML
@@ -3089,6 +3097,7 @@ one is included in the list below.
 * `--experimental-loader`
 * `--experimental-modules`
 * `--experimental-print-required-tla`
+* `--experimental-quic`
 * `--experimental-require-module`
 * `--experimental-shadow-realm`
 * `--experimental-specifier-resolution`
diff --git a/doc/api/index.md b/doc/api/index.md
index 0f3d4c8c4fec35..3ca2a91cd8fbe5 100644
--- a/doc/api/index.md
+++ b/doc/api/index.md
@@ -50,6 +50,7 @@
 * [Process](process.md)
 * [Punycode](punycode.md)
 * [Query strings](querystring.md)
+* [QUIC](quic.md)
 * [Readline](readline.md)
 * [REPL](repl.md)
 * [Report](report.md)
diff --git a/doc/api/quic.md b/doc/api/quic.md
new file mode 100644
index 00000000000000..cccc8ebae64979
--- /dev/null
+++ b/doc/api/quic.md
@@ -0,0 +1,1713 @@
+# QUIC
+
+<!-- introduced_in=REPLACEME-->
+
+<!-- YAML
+added: REPLACEME
+-->
+
+> Stability: 1.0 - Early development
+
+<!-- source_link=lib/quic.js -->
+
+The 'node:quic' module provides an implementation of the QUIC protocol.
+To access it, start Node.js with the `--experimental-quic` option and:
+
+```mjs
+import quic from 'node:quic';
+```
+
+```cjs
+const quic = require('node:quic');
+```
+
+The module is only available under the `node:` scheme.
+
+## `quic.connect(address[, options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `address` {string|net.SocketAddress}
+* `options` {quic.SessionOptions}
+* Returns: {Promise} a promise for a {quic.QuicSession}
+
+Initiate a new client-side session.
+
+```mjs
+import { connect } from 'node:quic';
+import { Buffer } from 'node:buffer';
+
+const enc = new TextEncoder();
+const alpn = 'foo';
+const client = await connect('123.123.123.123:8888', { alpn });
+await client.createUnidirectionalStream({
+  body: enc.encode('hello world'),
+});
+```
+
+By default, every call to `connect(...)` will create a new local
+`QuicEndpoint` instance bound to a new random local IP port. To
+specify the exact local address to use, or to multiplex multiple
+QUIC sessions over a single local port, pass the `endpoint` option
+with either a `QuicEndpoint` or `EndpointOptions` as the argument.
+
+```mjs
+import { QuicEndpoint, connect } from 'node:quic';
+
+const endpoint = new QuicEndpoint({
+  address: '127.0.0.1:1234',
+});
+
+const client = await connect('123.123.123.123:8888', { endpoint });
+```
+
+## `quic.listen(onsession,[options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `onsession` {quic.OnSessionCallback}
+* `options` {quic.SessionOptions}
+* Returns: {Promise} a promise for a {quic.QuicEndpoint}
+
+Configures the endpoint to listen as a server. When a new session is initiated by
+a remote peer, the given `onsession` callback will be invoked with the created
+session.
+
+```mjs
+import { listen } from 'node:quic';
+
+const endpoint = await listen((session) => {
+  // ... handle the session
+});
+
+// Closing the endpoint allows any sessions open when close is called
+// to complete naturally while preventing new sessions from being
+// initiated. Once all existing sessions have finished, the endpoint
+// will be destroyed. The call returns a promise that is resolved once
+// the endpoint is destroyed.
+await endpoint.close();
+```
+
+By default, every call to `listen(...)` will create a new local
+`QuicEndpoint` instance bound to a new random local IP port. To
+specify the exact local address to use, or to multiplex multiple
+QUIC sessions over a single local port, pass the `endpoint` option
+with either a `QuicEndpoint` or `EndpointOptions` as the argument.
+
+At most, any single `QuicEndpoint` can only be configured to listen as
+a server once.
+
+## Class: `QuicEndpoint`
+
+A `QuicEndpoint` encapsulates the local UDP-port binding for QUIC. It can be
+used as both a client and a server.
+
+### `new QuicEndpoint([options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `options` {quic.EndpointOptions}
+
+### `endpoint.address`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {net.SocketAddress|undefined}
+
+The local UDP socket address to which the endpoint is bound, if any.
+
+If the endpoint is not currently bound then the value will be `undefined`. Read only.
+
+### `endpoint.busy`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+When `endpoint.busy` is set to true, the endpoint will temporarily reject
+new sessions from being created. Read/write.
+
+```mjs
+// Mark the endpoint busy. New sessions will be prevented.
+endpoint.busy = true;
+
+// Mark the endpoint free. New session will be allowed.
+endpoint.busy = false;
+```
+
+The `busy` property is useful when the endpoint is under heavy load and needs to
+temporarily reject new sessions while it catches up.
+
+### `endpoint.close()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Returns: {Promise}
+
+Gracefully close the endpoint. The endpoint will close and destroy itself when
+all currently open sessions close. Once called, new sessions will be rejected.
+
+Returns a promise that is fulfilled when the endpoint is destroyed.
+
+### `endpoint.closed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {Promise}
+
+A promise that is fulfilled when the endpoint is destroyed. This will be the same promise that is
+returned by the `endpoint.close()` function. Read only.
+
+### `endpoint.closing`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True if `endpoint.close()` has been called and closing the endpoint has not yet completed.
+Read only.
+
+### `endpoint.destroy([error])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `error` {any}
+
+Forcefully closes the endpoint by forcing all open sessions to be immediately
+closed.
+
+### `endpoint.destroyed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True if `endpoint.destroy()` has been called. Read only.
+
+### `endpoint.stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.QuicEndpoint.Stats}
+
+The statistics collected for an active session. Read only.
+
+### `endpoint[Symbol.asyncDispose]()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+Calls `endpoint.close()` and returns a promise that fulfills when the
+endpoint has closed.
+
+## Class: `QuicEndpoint.Stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+A view of the collected statistics for an endpoint.
+
+### `endpointStats.createdAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} A timestamp indicating the moment the endpoint was created. Read only.
+
+### `endpointStats.destroyedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} A timestamp indicating the moment the endpoint was destroyed. Read only.
+
+### `endpointStats.bytesReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of bytes received by this endpoint. Read only.
+
+### `endpointStats.bytesSent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of bytes sent by this endpoint. Read only.
+
+### `endpointStats.packetsReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of QUIC packets successfully received by this endpoint. Read only.
+
+### `endpointStats.packetsSent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of QUIC packets successfully sent by this endpoint. Read only.
+
+### `endpointStats.serverSessions`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of peer-initiated sessions received by this endpoint. Read only.
+
+### `endpointStats.clientSessions`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of sessions initiated by this endpoint. Read only.
+
+### `endpointStats.serverBusyCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of times an initial packet was rejected due to the
+  endpoint being marked busy. Read only.
+
+### `endpointStats.retryCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of QUIC retry attempts on this endpoint. Read only.
+
+### `endpointStats.versionNegotiationCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number sessions rejected due to QUIC version mismatch. Read only.
+
+### `endpointStats.statelessResetCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of stateless resets handled by this endpoint. Read only.
+
+### `endpointStats.immediateCloseCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint} The total number of sessions that were closed before handshake completed. Read only.
+
+## Class: `QuicSession`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+A `QuicSession` represents the local side of a QUIC connection.
+
+### `session.close()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Returns: {Promise}
+
+Initiate a graceful close of the session. Existing streams will be allowed
+to complete but no new streams will be opened. Once all streams have closed,
+the session will be destroyed. The returned promise will be fulfilled once
+the session has been destroyed.
+
+### `session.closed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {Promise}
+
+A promise that is fulfilled once the session is destroyed.
+
+### `session.destroy([error])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `error` {any}
+
+Immediately destroy the session. All streams will be destroys and the
+session will be closed.
+
+### `session.destroyed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True if `session.destroy()` has been called. Read only.
+
+### `session.endpoint`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.QuicEndpoint}
+
+The endpoint that created this session. Read only.
+
+### `session.onstream`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnStreamCallback}
+
+The callback to invoke when a new stream is initiated by a remote peer. Read/write.
+
+### `session.ondatagram`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnDatagramCallback}
+
+The callback to invoke when a new datagram is received from a remote peer. Read/write.
+
+### `session.ondatagramstatus`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnDatagramStatusCallback}
+
+The callback to invoke when the status of a datagram is updated. Read/write.
+
+### `session.onpathvalidation`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnPathValidationCallback}
+
+The callback to invoke when the path validation is updated. Read/write.
+
+### `seesion.onsessionticket`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnSessionTicketCallback}
+
+The callback to invoke when a new session ticket is received. Read/write.
+
+### `session.onversionnegotiation`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnVersionNegotiationCallback}
+
+The callback to invoke when a version negotiation is initiated. Read/write.
+
+### `session.onhandshake`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnHandshakeCallback}
+
+The callback to invoke when the TLS handshake is completed. Read/write.
+
+### `session.createBidirectionalStream([options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `options` {Object}
+  * `body` {ArrayBuffer | ArrayBufferView | Blob}
+  * `sendOrder` {number}
+* Returns: {Promise} for a {quic.QuicStream}
+
+Open a new bidirectional stream. If the `body` option is not specified,
+the outgoing stream will be half-closed.
+
+### `session.createUnidirectionalStream([options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `options` {Object}
+  * `body` {ArrayBuffer | ArrayBufferView | Blob}
+  * `sendOrder` {number}
+* Returns: {Promise} for a {quic.QuicStream}
+
+Open a new unidirectional stream. If the `body` option is not specified,
+the outgoing stream will be closed.
+
+### `session.path`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {Object|undefined}
+  * `local` {net.SocketAddress}
+  * `remote` {net.SocketAddress}
+
+The local and remote socket addresses associated with the session. Read only.
+
+### `session.sendDatagram(datagram)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `datagram` {string|ArrayBufferView}
+* Returns: {bigint}
+
+Sends an unreliable datagram to the remote peer, returning the datagram ID.
+If the datagram payload is specified as an `ArrayBufferView`, then ownership of
+that view will be transfered to the underlying stream.
+
+### `session.stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.QuicSession.Stats}
+
+Return the current statistics for the session. Read only.
+
+### `session.updateKey()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+Initiate a key update for the session.
+
+### `session[Symbol.asyncDispose]()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+Calls `session.close()` and returns a promise that fulfills when the
+session has closed.
+
+## Class: `QuicSession.Stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### `sessionStats.createdAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.closingAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.handshakeCompletedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.handshakeConfirmedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.bytesReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.bytesSent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.bidiInStreamCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.bidiOutStreamCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.uniInStreamCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.uniOutStreamCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.maxBytesInFlights`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.bytesInFlight`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.blockCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.cwnd`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.latestRtt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.minRtt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.rttVar`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.smoothedRtt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.ssthresh`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.datagramsReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.datagramsSent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.datagramsAcknowledged`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `sessionStats.datagramsLost`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+## Class: `QuicStream`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### `stream.closed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {Promise}
+
+A promise that is fulfilled when the stream is fully closed.
+
+### `stream.destroy([error])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `error` {any}
+
+Immediately and abruptly destroys the stream.
+
+### `stream.destroyed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True if `stream.destroy()` has been called.
+
+### `stream.direction`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string} One of either `'bidi'` or `'uni'`.
+
+The directionality of the stream. Read only.
+
+### `stream.id`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+The stream ID. Read only.
+
+### `stream.onblocked`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnBlockedCallback}
+
+The callback to invoke when the stream is blocked. Read/write.
+
+### `stream.onreset`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.OnStreamErrorCallback}
+
+The callback to invoke when the stream is reset. Read/write.
+
+### `stream.readable`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ReadableStream}
+
+### `stream.session`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.QuicSession}
+
+The session that created this stream. Read only.
+
+### `stream.stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.QuicStream.Stats}
+
+The current statistics for the stream. Read only.
+
+## Class: `QuicStream.Stats`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### `streamStats.ackedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.bytesReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.bytesSent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.createdAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.destroyedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.finalSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.isConnected`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.maxOffset`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.maxOffsetAcknowledged`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.maxOffsetReceived`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.openedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+### `streamStats.receivedAt`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint}
+
+## Types
+
+### Type: `EndpointOptions`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {Object}
+
+The endpoint configuration options passed when constructing a new `QuicEndpoint` instance.
+
+#### `endpointOptions.address`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {net.SocketAddress | string} The local UDP address and port the endpoint should bind to.
+
+If not specified the endpoint will bind to IPv4 `localhost` on a random port.
+
+#### `endpointOptions.addressLRUSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+The endpoint maintains an internal cache of validated socket addresses as a
+performance optimization. This option sets the maximum number of addresses
+that are cache. This is an advanced option that users typically won't have
+need to specify.
+
+#### `endpointOptions.ipv6Only`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+When `true`, indicates that the endpoint should bind only to IPv6 addresses.
+
+#### `endpointOptions.maxConnectionsPerHost`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum number of concurrent sessions allowed per remote peer address.
+
+#### `endpointOptions.maxConnectionsTotal`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum total number of concurrent sessions.
+
+#### `endpointOptions.maxRetries`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum number of QUIC retry attempts allowed per remote peer address.
+
+#### `endpointOptions.maxStatelessResetsPerHost`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum number of stateless resets that are allowed per remote peer address.
+
+#### `endpointOptions.retryTokenExpiration`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the length of time a QUIC retry token is considered valid.
+
+#### `endpointOptions.resetTokenSecret`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBufferView}
+
+Specifies the 16-byte secret used to generate QUIC retry tokens.
+
+#### `endpointOptions.tokenExpiration`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the length of time a QUIC token is considered valid.
+
+#### `endpointOptions.tokenSecret`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBufferView}
+
+Specifies the 16-byte secret used to generate QUIC tokens.
+
+#### `endpointOptions.udpReceiveBufferSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {number}
+
+#### `endpointOptions.udpSendBufferSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {number}
+
+#### `endpointOptions.udpTTL`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {number}
+
+#### `endpointOptions.validateAddress`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+When `true`, requires that the endpoint validate peer addresses using retry packets
+while establishing a new connection.
+
+### Type: `SessionOptions`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+#### `sessionOptions.alpn`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string}
+
+The ALPN protocol identifier.
+
+#### `sessionOptions.ca`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+
+The CA certificates to use for sessions.
+
+#### `sessionOptions.cc`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string}
+
+Specifies the congestion control algorithm that will be used
+. Must be set to one of either `'reno'`, `'cubic'`, or `'bbr'`.
+
+This is an advanced option that users typically won't have need to specify.
+
+#### `sessionOptions.certs`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+
+The TLS certificates to use for sessions.
+
+#### `sessionOptions.ciphers`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string}
+
+The list of supported TLS 1.3 cipher algorithms.
+
+#### `sessionOptions.crl`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+
+The CRL to use for sessions.
+
+#### `sessionOptions.groups`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string}
+
+The list of support TLS 1.3 cipher groups.
+
+#### `sessionOptions.keylog`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True to enable TLS keylogging output.
+
+#### `sessionOptions.keys`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {KeyObject|CryptoKey|KeyObject\[]|CryptoKey\[]}
+
+The TLS crypto keys to use for sessions.
+
+#### `sessionOptions.maxPayloadSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum UDP packet payload size.
+
+#### `sessionOptions.maxStreamWindow`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum stream flow-control window size.
+
+#### `sessionOptions.maxWindow`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maxumum session flow-control window size.
+
+#### `sessionOptions.minVersion`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {number}
+
+The minimum QUIC version number to allow. This is an advanced option that users
+typically won't have need to specify.
+
+#### `sessionOptions.preferredAddressPolicy`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string} One of `'use'`, `'ignore'`, or `'default'`.
+
+When the remote peer advertises a preferred address, this option specifies whether
+to use it or ignore it.
+
+#### `sessionOptions.qlog`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True if qlog output should be enabled.
+
+#### `sessionOptions.sessionTicket`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {ArrayBufferView} A session ticket to use for 0RTT session resumption.
+
+#### `sessionOptions.handshakeTimeout`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum number of milliseconds a TLS handshake is permitted to take
+to complete before timing out.
+
+#### `sessionOptions.sni`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {string}
+
+The peer server name to target.
+
+#### `sessionOptions.tlsTrace`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True to enable TLS tracing output.
+
+#### `sessionOptions.transportParams`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {quic.TransportParams}
+
+The QUIC transport parameters to use for the session.
+
+#### `sessionOptions.unacknowledgedPacketThreshold`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+Specifies the maximum number of unacknowledged packets a session should allow.
+
+#### `sessionOptions.verifyClient`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True to require verification of TLS client certificate.
+
+#### `sessionOptions.verifyPrivateKey`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {boolean}
+
+True to require private key verification.
+
+#### `sessionOptions.version`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {number}
+
+The QUIC version number to use. This is an advanced option that users typically
+won't have need to specify.
+
+### Type: `TransportParams`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+#### `transportParams.preferredAddressIpv4`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {net.SocketAddress} The preferred IPv4 address to advertise.
+
+#### `transportParams.preferredAddressIpv6`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {net.SocketAddress} The preferred IPv6 address to advertise.
+
+#### `transportParams.initialMaxStreamDataBidiLocal`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.initialMaxStreamDataBidiRemote`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.initialMaxStreamDataUni`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.initialMaxData`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.initialMaxStreamsBidi`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.initialMaxStreamsUni`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.maxIdleTimeout`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.activeConnectionIDLimit`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.ackDelayExponent`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.maxAckDelay`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+#### `transportParams.maxDatagramFrameSize`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {bigint|number}
+
+## Callbacks
+
+### Callback: `OnSessionCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicEndpoint}
+* `session` {quic.QuicSession}
+
+The callback function that is invoked when a new session is initiated by a remote peer.
+
+### Callback: `OnStreamCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `stream` {quic.QuicStream}
+
+### Callback: `OnDatagramCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `datagram` {Uint8Array}
+* `early` {boolean}
+
+### Callback: `OnDatagramStatusCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `id` {bigint}
+* `status` {string} One of either `'lost'` or `'acknowledged'`.
+
+### Callback: `OnPathValidationCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `result` {string} One of either `'success'`, `'failure'`, or `'aborted'`.
+* `newLocalAddress` {net.SocketAddress}
+* `newRemoteAddress` {net.SocketAddress}
+* `oldLocalAddress` {net.SocketAddress}
+* `oldRemoteAddress` {net.SocketAddress}
+* `preferredAddress` {boolean}
+
+### Callback: `OnSessionTicketCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `ticket` {Object}
+
+### Callback: `OnVersionNegotiationCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `version` {number}
+* `requestedVersions` {number\[]}
+* `supportedVersions` {number\[]}
+
+### Callback: `OnHandshakeCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicSession}
+* `sni` {string}
+* `alpn` {string}
+* `cipher` {string}
+* `cipherVersion` {string}
+* `validationErrorReason` {string}
+* `validationErrorCode` {number}
+* `earlyDataAccepted` {boolean}
+
+### Callback: `OnBlockedCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicStream}
+
+### Callback: `OnStreamErrorCallback`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `this` {quic.QuicStream}
+* `error` {any}
+
+## Diagnostic Channels
+
+### Channel: `quic.endpoint.created`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+* `config` {quic.EndpointOptions}
+
+### Channel: `quic.endpoint.listen`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+* `optoins` {quic.SessionOptions}
+
+### Channel: `quic.endpoint.closing`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+* `hasPendingError` {boolean}
+
+### Channel: `quic.endpoint.closed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+
+### Channel: `quic.endpoint.error`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+* `error` {any}
+
+### Channel: `quic.endpoint.busy.change`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `endpoint` {quic.QuicEndpoint}
+* `busy` {boolean}
+
+### Channel: `quic.session.created.client`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.created.server`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.open.stream`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.received.stream`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.send.datagram`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.update.key`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.closing`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.closed`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.receive.datagram`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.receive.datagram.status`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.path.validation`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.ticket`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.version.negotiation`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+### Channel: `quic.session.handshake`
+
+<!-- YAML
+added: REPLACEME
+-->
diff --git a/doc/node.1 b/doc/node.1
index 9f6ad04564fe04..d33bb82b7670e7 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -217,6 +217,9 @@ flag is no longer required as WASI is enabled by default.
 .It Fl -experimental-wasm-modules
 Enable experimental WebAssembly module support.
 .
+.It Fl -experimental-quic
+Enable the experimental QUIC support.
+.
 .It Fl -force-context-aware
 Disable loading native addons that are not context-aware.
 .
diff --git a/lib/internal/blob.js b/lib/internal/blob.js
index 43a7ae5ac34d9c..6a526de7bfeb73 100644
--- a/lib/internal/blob.js
+++ b/lib/internal/blob.js
@@ -71,8 +71,8 @@ const {
 } = require('internal/validators');
 
 const {
-  CountQueuingStrategy,
-} = require('internal/webstreams/queuingstrategies');
+  setImmediate,
+} = require('timers');
 
 const { queueMicrotask } = require('internal/process/task_queues');
 
@@ -315,80 +315,7 @@ class Blob {
   stream() {
     if (!isBlob(this))
       throw new ERR_INVALID_THIS('Blob');
-
-    const reader = this[kHandle].getReader();
-    return new lazyReadableStream({
-      type: 'bytes',
-      start(c) {
-        // There really should only be one read at a time so using an
-        // array here is purely defensive.
-        this.pendingPulls = [];
-      },
-      pull(c) {
-        const { promise, resolve, reject } = PromiseWithResolvers();
-        this.pendingPulls.push({ resolve, reject });
-        const readNext = () => {
-          reader.pull((status, buffer) => {
-            // If pendingPulls is empty here, the stream had to have
-            // been canceled, and we don't really care about the result.
-            // We can simply exit.
-            if (this.pendingPulls.length === 0) {
-              return;
-            }
-            if (status === 0) {
-              // EOS
-              c.close();
-              // This is to signal the end for byob readers
-              // see https://streams.spec.whatwg.org/#example-rbs-pull
-              c.byobRequest?.respond(0);
-              const pending = this.pendingPulls.shift();
-              pending.resolve();
-              return;
-            } else if (status < 0) {
-              // The read could fail for many different reasons when reading
-              // from a non-memory resident blob part (e.g. file-backed blob).
-              // The error details the system error code.
-              const error = lazyDOMException('The blob could not be read', 'NotReadableError');
-              const pending = this.pendingPulls.shift();
-              c.error(error);
-              pending.reject(error);
-              return;
-            }
-            // ReadableByteStreamController.enqueue errors if we submit a 0-length
-            // buffer. We need to check for that here.
-            if (buffer !== undefined && buffer.byteLength !== 0) {
-              c.enqueue(new Uint8Array(buffer));
-            }
-            // We keep reading until we either reach EOS, some error, or we
-            // hit the flow rate of the stream (c.desiredSize).
-            queueMicrotask(() => {
-              if (c.desiredSize < 0) {
-                // A manual backpressure check.
-                if (this.pendingPulls.length !== 0) {
-                  // A case of waiting pull finished (= not yet canceled)
-                  const pending = this.pendingPulls.shift();
-                  pending.resolve();
-                }
-                return;
-              }
-              readNext();
-            });
-          });
-        };
-        readNext();
-        return promise;
-      },
-      cancel(reason) {
-        // Reject any currently pending pulls here.
-        for (const pending of this.pendingPulls) {
-          pending.reject(reason);
-        }
-        this.pendingPulls = [];
-      },
-    // We set the highWaterMark to 0 because we do not want the stream to
-    // start reading immediately on creation. We want it to wait until read
-    // is called.
-    }, new CountQueuingStrategy({ highWaterMark: 0 }));
+    return createBlobReaderStream(this[kHandle].getReader());
   }
 }
 
@@ -505,6 +432,84 @@ function arrayBuffer(blob) {
   return promise;
 }
 
+function createBlobReaderStream(reader) {
+  return new lazyReadableStream({
+    type: 'bytes',
+    start(c) {
+      // There really should only be one read at a time so using an
+      // array here is purely defensive.
+      this.pendingPulls = [];
+    },
+    pull(c) {
+      const { promise, resolve, reject } = PromiseWithResolvers();
+      this.pendingPulls.push({ resolve, reject });
+      const readNext = () => {
+        reader.pull((status, buffer) => {
+          // If pendingPulls is empty here, the stream had to have
+          // been canceled, and we don't really care about the result.
+          // We can simply exit.
+          if (this.pendingPulls.length === 0) {
+            return;
+          }
+          if (status === 0) {
+            // EOS
+            c.close();
+            // This is to signal the end for byob readers
+            // see https://streams.spec.whatwg.org/#example-rbs-pull
+            c.byobRequest?.respond(0);
+            const pending = this.pendingPulls.shift();
+            pending.resolve();
+            return;
+          } else if (status < 0) {
+            // The read could fail for many different reasons when reading
+            // from a non-memory resident blob part (e.g. file-backed blob).
+            // The error details the system error code.
+            const error = lazyDOMException('The blob could not be read', 'NotReadableError');
+            const pending = this.pendingPulls.shift();
+            c.error(error);
+            pending.reject(error);
+            return;
+          }
+          // ReadableByteStreamController.enqueue errors if we submit a 0-length
+          // buffer. We need to check for that here.
+          if (buffer !== undefined && buffer.byteLength !== 0) {
+            c.enqueue(new Uint8Array(buffer));
+          }
+          // We keep reading until we either reach EOS, some error, or we
+          // hit the flow rate of the stream (c.desiredSize).
+          // We use set immediate here because we have to allow the event
+          // loop to turn in order to proecss any pending i/o. Using
+          // queueMicrotask won't allow the event loop to turn.
+          setImmediate(() => {
+            if (c.desiredSize < 0) {
+              // A manual backpressure check.
+              if (this.pendingPulls.length !== 0) {
+                // A case of waiting pull finished (= not yet canceled)
+                const pending = this.pendingPulls.shift();
+                pending.resolve();
+              }
+              return;
+            }
+            readNext();
+          });
+        });
+      };
+      readNext();
+      return promise;
+    },
+    cancel(reason) {
+      // Reject any currently pending pulls here.
+      for (const pending of this.pendingPulls) {
+        pending.reject(reason);
+      }
+      this.pendingPulls = [];
+    },
+  // We set the highWaterMark to 0 because we do not want the stream to
+  // start reading immediately on creation. We want it to wait until read
+  // is called.
+  }, { highWaterMark: 0 });
+}
+
 module.exports = {
   Blob,
   createBlob,
@@ -513,4 +518,5 @@ module.exports = {
   kHandle,
   resolveObjectURL,
   TransferableBlob,
+  createBlobReaderStream,
 };
diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js
index 7e87f1ad1ab5b6..3c3a83ed611a66 100644
--- a/lib/internal/bootstrap/realm.js
+++ b/lib/internal/bootstrap/realm.js
@@ -131,11 +131,12 @@ const legacyWrapperList = new SafeSet([
 const schemelessBlockList = new SafeSet([
   'sea',
   'sqlite',
+  'quic',
   'test',
   'test/reporters',
 ]);
 // Modules that will only be enabled at run time.
-const experimentalModuleList = new SafeSet(['sqlite']);
+const experimentalModuleList = new SafeSet(['sqlite', 'quic']);
 
 // Set up process.binding() and process._linkedBinding().
 {
diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js
index b3aba59674b82b..3ea9a934726462 100644
--- a/lib/internal/process/pre_execution.js
+++ b/lib/internal/process/pre_execution.js
@@ -101,6 +101,7 @@ function prepareExecution(options) {
   setupNavigator();
   setupWarningHandler();
   setupSQLite();
+  setupQuic();
   setupWebStorage();
   setupWebsocket();
   setupEventsource();
@@ -311,6 +312,15 @@ function setupSQLite() {
   BuiltinModule.allowRequireByUsers('sqlite');
 }
 
+function setupQuic() {
+  if (!getOptionValue('--experimental-quic')) {
+    return;
+  }
+
+  const { BuiltinModule } = require('internal/bootstrap/realm');
+  BuiltinModule.allowRequireByUsers('quic');
+}
+
 function setupWebStorage() {
   if (getEmbedderOptions().noBrowserGlobals ||
       !getOptionValue('--experimental-webstorage')) {
diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index a76708a37ec1d2..afe057de5bd951 100644
--- a/lib/internal/quic/quic.js
+++ b/lib/internal/quic/quic.js
@@ -8,10 +8,10 @@ const {
   ArrayBufferPrototypeTransfer,
   ArrayIsArray,
   ArrayPrototypePush,
+  BigInt,
   ObjectDefineProperties,
   SafeSet,
   SymbolAsyncDispose,
-  SymbolIterator,
   Uint8Array,
 } = primordials;
 
@@ -23,14 +23,16 @@ assertCrypto();
 
 const { inspect } = require('internal/util/inspect');
 
+let debug = require('internal/util/debuglog').debuglog('quic', (fn) => {
+  debug = fn;
+});
+
 const {
   Endpoint: Endpoint_,
+  Http3Application: Http3,
   setCallbacks,
 
   // The constants to be exposed to end users for various options.
-  CC_ALGO_RENO,
-  CC_ALGO_CUBIC,
-  CC_ALGO_BBR,
   CC_ALGO_RENO_STR,
   CC_ALGO_CUBIC_STR,
   CC_ALGO_BBR_STR,
@@ -67,6 +69,7 @@ const {
     ERR_INVALID_ARG_TYPE,
     ERR_INVALID_ARG_VALUE,
     ERR_INVALID_STATE,
+    ERR_MISSING_ARGS,
     ERR_QUIC_APPLICATION_ERROR,
     ERR_QUIC_CONNECTION_FAILED,
     ERR_QUIC_ENDPOINT_CLOSED,
@@ -82,42 +85,61 @@ const {
   kHandle: kSocketAddressHandle,
 } = require('internal/socketaddress');
 
+const {
+  createBlobReaderStream,
+  isBlob,
+  kHandle: kBlobHandle,
+} = require('internal/blob');
+
 const {
   isKeyObject,
   isCryptoKey,
 } = require('internal/crypto/keys');
 
 const {
+  validateBoolean,
   validateFunction,
+  validateNumber,
   validateObject,
   validateString,
-  validateBoolean,
 } = require('internal/validators');
 
+const {
+  mapToHeaders,
+} = require('internal/http2/util');
+
 const kEmptyObject = { __proto__: null };
 
 const {
+  kApplicationProvider,
   kBlocked,
+  kConnect,
   kDatagram,
   kDatagramStatus,
-  kError,
   kFinishClose,
   kHandshake,
   kHeaders,
   kOwner,
   kRemoveSession,
+  kListen,
   kNewSession,
   kRemoveStream,
   kNewStream,
+  kOnHeaders,
+  kOnTrailers,
   kPathValidation,
+  kPrivateConstructor,
   kReset,
+  kSendHeaders,
   kSessionTicket,
+  kState,
   kTrailers,
   kVersionNegotiation,
   kInspect,
   kKeyObjectHandle,
   kKeyObjectInner,
-  kPrivateConstructor,
+  kWantsHeaders,
+  kWantsTrailers,
 } = require('internal/quic/symbols');
 
 const {
@@ -132,7 +154,7 @@ const {
   QuicStreamState,
 } = require('internal/quic/state');
 
-const { assert } = require('internal/assert');
+const assert = require('internal/assert');
 
 const dc = require('diagnostics_channel');
 const onEndpointCreatedChannel = dc.channel('quic.endpoint.created');
@@ -162,49 +184,29 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
  * @typedef {import('../crypto/keys.js').CryptoKey} CryptoKey
  */
 
+/**
+ * @typedef {object} OpenStreamOptions
+ * @property {ArrayBuffer|ArrayBufferView|Blob} [body] The outbound payload
+ * @property {number} [sendOrder] The ordering of this stream relative to others in the same session.
+ */
+
 /**
  * @typedef {object} EndpointOptions
- * @property {SocketAddress} [address] The local address to bind to
- * @property {bigint|number} [retryTokenExpiration] The retry token expiration
- * @property {bigint|number} [tokenExpiration] The token expiration
+ * @property {string|SocketAddress} [address] The local address to bind to
+ * @property {bigint|number} [addressLRUSize] The size of the address LRU cache
+ * @property {boolean} [ipv6Only] Use IPv6 only
  * @property {bigint|number} [maxConnectionsPerHost] The maximum number of connections per host
  * @property {bigint|number} [maxConnectionsTotal] The maximum number of total connections
- * @property {bigint|number} [maxStatelessResetsPerHost] The maximum number of stateless resets per host
- * @property {bigint|number} [addressLRUSize] The size of the address LRU cache
  * @property {bigint|number} [maxRetries] The maximum number of retries
- * @property {bigint|number} [maxPayloadSize] The maximum payload size
- * @property {bigint|number} [unacknowledgedPacketThreshold] The unacknowledged packet threshold
- * @property {bigint|number} [handshakeTimeout] The handshake timeout
- * @property {bigint|number} [maxStreamWindow] The maximum stream window
- * @property {bigint|number} [maxWindow] The maximum window
- * @property {number} [rxDiagnosticLoss] The receive diagnostic loss probability (range 0.0-1.0)
- * @property {number} [txDiagnosticLoss] The transmit diagnostic loss probability (range 0.0-1.0)
+ * @property {bigint|number} [maxStatelessResetsPerHost] The maximum number of stateless resets per host
+ * @property {ArrayBufferView} [resetTokenSecret] The reset token secret
+ * @property {bigint|number} [retryTokenExpiration] The retry token expiration
+ * @property {bigint|number} [tokenExpiration] The token expiration
+ * @property {ArrayBufferView} [tokenSecret] The token secret
  * @property {number} [udpReceiveBufferSize] The UDP receive buffer size
  * @property {number} [udpSendBufferSize] The UDP send buffer size
  * @property {number} [udpTTL] The UDP TTL
- * @property {boolean} [noUdpPayloadSizeShaping] Disable UDP payload size shaping
- * @property {boolean} [validateAddress] Validate the address
- * @property {boolean} [disableActiveMigration] Disable active migration
- * @property {boolean} [ipv6Only] Use IPv6 only
- * @property {'reno'|'cubic'|'bbr'|number} [cc] The congestion control algorithm
- * @property {ArrayBufferView} [resetTokenSecret] The reset token secret
- * @property {ArrayBufferView} [tokenSecret] The token secret
- */
-
-/**
- * @typedef {object} TlsOptions
- * @property {string} [sni] The server name indication
- * @property {string} [alpn] The application layer protocol negotiation
- * @property {string} [ciphers] The ciphers
- * @property {string} [groups] The groups
- * @property {boolean} [keylog] Enable key logging
- * @property {boolean} [verifyClient] Verify the client
- * @property {boolean} [tlsTrace] Enable TLS tracing
- * @property {boolean} [verifyPrivateKey] Verify the private key
- * @property {KeyObject|CryptoKey|Array<KeyObject|CryptoKey>} [keys] The keys
- * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [certs] The certificates
- * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [ca] The certificate authority
- * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [crl] The certificate revocation list
+ * @property {boolean} [validateAddress] Validate the address using retry packets
  */
 
 /**
@@ -222,7 +224,6 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
  * @property {bigint|number} [ackDelayExponent] The acknowledgment delay exponent
  * @property {bigint|number} [maxAckDelay] The maximum acknowledgment delay
  * @property {bigint|number} [maxDatagramFrameSize] The maximum datagram frame size
- * @property {boolean} [disableActiveMigration] Disable active migration
  */
 
 /**
@@ -239,14 +240,32 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
 
 /**
  * @typedef {object} SessionOptions
+ * @property {EndpointOptions|QuicEndpoint} [endpoint] An endpoint to use.
  * @property {number} [version] The version
  * @property {number} [minVersion] The minimum version
  * @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
  * @property {ApplicationOptions} [application] The application options
  * @property {TransportParams} [transportParams] The transport parameters
- * @property {TlsOptions} [tls] The TLS options
+ * @property {string} [servername] The server name identifier
+ * @property {string} [protocol] The application layer protocol negotiation
+ * @property {string} [ciphers] The ciphers
+ * @property {string} [groups] The groups
+ * @property {boolean} [keylog] Enable key logging
+ * @property {boolean} [verifyClient] Verify the client
+ * @property {boolean} [tlsTrace] Enable TLS tracing
+ * @property {boolean} [verifyPrivateKey] Verify the private key
+ * @property {KeyObject|CryptoKey|Array<KeyObject|CryptoKey>} [keys] The keys
+ * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [certs] The certificates
+ * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [ca] The certificate authority
+ * @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [crl] The certificate revocation list
  * @property {boolean} [qlog] Enable qlog
  * @property {ArrayBufferView} [sessionTicket] The session ticket
+ * @property {bigint|number} [handshakeTimeout] The handshake timeout
+ * @property {bigint|number} [maxStreamWindow] The maximum stream window
+ * @property {bigint|number} [maxWindow] The maximum window
+ * @property {bigint|number} [maxPayloadSize] The maximum payload size
+ * @property {bigint|number} [unacknowledgedPacketThreshold] The unacknowledged packet threshold
+ * @property {'reno'|'cubic'|'bbr'} [cc] The congestion control algorithm
  */
 
 /**
@@ -284,26 +303,6 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
  * @returns {void}
  */
 
-/**
- * @callback OnDatagramStatusCallback
- * @this {QuicSession}
- * @param {bigint} id
- * @param {'lost'|'acknowledged'} status
- * @returns {void}
- */
-
-/**
- * @callback OnPathValidationCallback
- * @this {QuicSession}
- * @param {'aborted'|'failure'|'success'} result
- * @param {SocketAddress} newLocalAddress
- * @param {SocketAddress} newRemoteAddress
- * @param {SocketAddress} oldLocalAddress
- * @param {SocketAddress} oldRemoteAddress
- * @param {boolean} preferredAddress
- * @returns {void}
- */
-
 /**
  * @callback OnSessionTicketCallback
  * @this {QuicSession}
@@ -311,133 +310,65 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
  * @returns {void}
  */
 
-/**
- * @callback OnVersionNegotiationCallback
- * @this {QuicSession}
- * @param {number} version
- * @param {number[]} requestedVersions
- * @param {number[]} supportedVersions
- * @returns {void}
- */
-
-/**
- * @callback OnHandshakeCallback
- * @this {QuicSession}
- * @param {string} sni
- * @param {string} alpn
- * @param {string} cipher
- * @param {string} cipherVersion
- * @param {string} validationErrorReason
- * @param {number} validationErrorCode
- * @param {boolean} earlyDataAccepted
- * @returns {void}
- */
-
 /**
  * @callback OnBlockedCallback
- * @param {QuicStream} stream
+ * @this {QuicStream} stream
  * @returns {void}
  */
 
 /**
  * @callback OnStreamErrorCallback
+ * @this {QuicStream}
  * @param {any} error
- * @param {QuicStream} stream
  * @returns {void}
  */
 
 /**
  * @callback OnHeadersCallback
+ * @this {QuicStream}
  * @param {object} headers
  * @param {string} kind
- * @param {QuicStream} stream
  * @returns {void}
  */
 
 /**
  * @callback OnTrailersCallback
- * @param {QuicStream} stream
+ * @this {QuicStream}
  * @returns {void}
  */
 
 /**
- * @typedef {object} StreamCallbackConfiguration
- * @property {OnBlockedCallback} [onblocked] The blocked callback
- * @property {OnStreamErrorCallback} [onreset] The reset callback
- * @property {OnHeadersCallback} [onheaders] The headers callback
- * @property {OnTrailersCallback} [ontrailers] The trailers callback
- */
-
-/**
- * Provdes the callback configuration for Sessions.
- * @typedef {object} SessionCallbackConfiguration
- * @property {OnStreamCallback} onstream The stream callback
- * @property {OnDatagramCallback} [ondatagram] The datagram callback
- * @property {OnDatagramStatusCallback} [ondatagramstatus] The datagram status callback
- * @property {OnPathValidationCallback} [onpathvalidation] The path validation callback
- * @property {OnSessionTicketCallback} [onsessionticket] The session ticket callback
- * @property {OnVersionNegotiationCallback} [onversionnegotiation] The version negotiation callback
- * @property {OnHandshakeCallback} [onhandshake] The handshake callback
- */
-
-/**
- * @typedef {object} ProcessedSessionCallbackConfiguration
- * @property {OnStreamCallback} onstream The stream callback
- * @property {OnDatagramCallback} [ondatagram] The datagram callback
- * @property {OnDatagramStatusCallback} [ondatagramstatus] The datagram status callback
- * @property {OnPathValidationCallback} [onpathvalidation] The path validation callback
- * @property {OnSessionTicketCallback} [onsessionticket] The session ticket callback
- * @property {OnVersionNegotiationCallback} [onversionnegotiation] The version negotation callback
- * @property {OnHandshakeCallback} [onhandshake] The handshake callback
- * @property {StreamCallbackConfiguration} stream The processed stream callbacks
- */
-
-/**
- * Provides the callback configuration for the Endpoint.
- * @typedef {object} EndpointCallbackConfiguration
- * @property {OnSessionCallback} onsession The session callback
- * @property {OnStreamCallback} onstream The stream callback
- * @property {OnDatagramCallback} [ondatagram] The datagram callback
- * @property {OnDatagramStatusCallback} [ondatagramstatus] The datagram status callback
- * @property {OnPathValidationCallback} [onpathvalidation] The path validation callback
- * @property {OnSessionTicketCallback} [onsessionticket] The session ticket callback
- * @property {OnVersionNegotiationCallback} [onversionnegotiation] The version negotiation callback
- * @property {OnHandshakeCallback} [onhandshake] The handshake callback
- * @property {OnBlockedCallback} [onblocked] The blocked callback
- * @property {OnStreamErrorCallback} [onreset] The reset callback
- * @property {OnHeadersCallback} [onheaders] The headers callback
- * @property {OnTrailersCallback} [ontrailers] The trailers callback
- * @property {SocketAddress} [address] The local address to bind to
+ * Provides the callback configuration for the Endpoint|undefined.
+ * @typedef {object} EndpointOptions
+ * @property {SocketAddress | string} [address] The local address to bind to
  * @property {bigint|number} [retryTokenExpiration] The retry token expiration
  * @property {bigint|number} [tokenExpiration] The token expiration
  * @property {bigint|number} [maxConnectionsPerHost] The maximum number of connections per host
  * @property {bigint|number} [maxConnectionsTotal] The maximum number of total connections
  * @property {bigint|number} [maxStatelessResetsPerHost] The maximum number of stateless resets per host
  * @property {bigint|number} [addressLRUSize] The size of the address LRU cache
- * @property {bigint|number} [maxRetries] The maximum number of retries
- * @property {bigint|number} [maxPayloadSize] The maximum payload size
- * @property {bigint|number} [unacknowledgedPacketThreshold] The unacknowledged packet threshold
- * @property {bigint|number} [handshakeTimeout] The handshake timeout
- * @property {bigint|number} [maxStreamWindow] The maximum stream window
- * @property {bigint|number} [maxWindow] The maximum window
+ * @property {bigint|number} [maxRetries] The maximum number of retriesw
  * @property {number} [rxDiagnosticLoss] The receive diagnostic loss probability (range 0.0-1.0)
  * @property {number} [txDiagnosticLoss] The transmit diagnostic loss probability (range 0.0-1.0)
  * @property {number} [udpReceiveBufferSize] The UDP receive buffer size
  * @property {number} [udpSendBufferSize] The UDP send buffer size
  * @property {number} [udpTTL] The UDP TTL
- * @property {boolean} [noUdpPayloadSizeShaping] Disable UDP payload size shaping
  * @property {boolean} [validateAddress] Validate the address
- * @property {boolean} [disableActiveMigration] Disable active migration
  * @property {boolean} [ipv6Only] Use IPv6 only
- * @property {'reno'|'cubic'|'bbr'|number} [cc] The congestion control algorithm
  * @property {ArrayBufferView} [resetTokenSecret] The reset token secret
  * @property {ArrayBufferView} [tokenSecret] The token secret
  */
 
 /**
- * @typedef {object} ProcessedEndpointCallbackConfiguration
- * @property {OnSessionCallback} onsession The session callback
- * @property {SessionCallbackConfiguration} session The processesd session callbacks
+ * @typedef {object} QuicSessionInfo
+ * @property {SocketAddress} local The local address
+ * @property {SocketAddress} remote The remote address
+ * @property {string} protocol The alpn protocol identifier negotiated for this session
+ * @property {string} servername The servername identifier for this session
+ * @property {string} cipher The cipher suite negotiated for this session
+ * @property {string} cipherVersion The version of the cipher suite negotiated for this session
+ * @property {string} [validationErrorReason] The reason the session failed validation (if any)
+ * @property {string} [validationErrorCode] The error code for the validation failure (if any)
  */
 
 setCallbacks({
@@ -450,6 +381,7 @@ setCallbacks({
    * @param {number} status If context indicates an error, provides the error code.
    */
   onEndpointClose(context, status) {
+    debug('endpoint close callback', status);
     this[kOwner][kFinishClose](context, status);
   },
   /**
@@ -457,6 +389,7 @@ setCallbacks({
    * @param {*} session The QuicSession C++ handle
    */
   onSessionNew(session) {
+    debug('new server session callback', this[kOwner], session);
     this[kOwner][kNewSession](session);
   },
 
@@ -470,6 +403,7 @@ setCallbacks({
    * @param {string} [reason]
    */
   onSessionClose(errorType, code, reason) {
+    debug('session close callback', errorType, code, reason);
     this[kOwner][kFinishClose](errorType, code, reason);
   },
 
@@ -479,6 +413,7 @@ setCallbacks({
    * @param {boolean} early
    */
   onSessionDatagram(uint8Array, early) {
+    debug('session datagram callback', uint8Array.byteLength, early);
     this[kOwner][kDatagram](uint8Array, early);
   },
 
@@ -488,26 +423,26 @@ setCallbacks({
    * @param {'lost' | 'acknowledged'} status
    */
   onSessionDatagramStatus(id, status) {
+    debug('session datagram status callback', id, status);
     this[kOwner][kDatagramStatus](id, status);
   },
 
   /**
    * Called when the session handshake completes.
-   * @param {string} sni
-   * @param {string} alpn
+   * @param {string} servername
+   * @param {string} protocol
    * @param {string} cipher
    * @param {string} cipherVersion
    * @param {string} validationErrorReason
    * @param {number} validationErrorCode
-   * @param {boolean} earlyDataAccepted
    */
-  onSessionHandshake(sni, alpn, cipher, cipherVersion,
+  onSessionHandshake(servername, protocol, cipher, cipherVersion,
                      validationErrorReason,
-                     validationErrorCode,
-                     earlyDataAccepted) {
-    this[kOwner][kHandshake](sni, alpn, cipher, cipherVersion,
-                             validationErrorReason, validationErrorCode,
-                             earlyDataAccepted);
+                     validationErrorCode) {
+    debug('session handshake callback', servername, protocol, cipher, cipherVersion,
+          validationErrorReason, validationErrorCode);
+    this[kOwner][kHandshake](servername, protocol, cipher, cipherVersion,
+                             validationErrorReason, validationErrorCode);
   },
 
   /**
@@ -521,8 +456,12 @@ setCallbacks({
    */
   onSessionPathValidation(result, newLocalAddress, newRemoteAddress,
                           oldLocalAddress, oldRemoteAddress, preferredAddress) {
-    this[kOwner][kPathValidation](result, newLocalAddress, newRemoteAddress,
-                                  oldLocalAddress, oldRemoteAddress,
+    debug('session path validation callback', this[kOwner]);
+    this[kOwner][kPathValidation](result,
+                                  new InternalSocketAddress(newLocalAddress),
+                                  new InternalSocketAddress(newRemoteAddress),
+                                  new InternalSocketAddress(oldLocalAddress),
+                                  new InternalSocketAddress(oldRemoteAddress),
                                   preferredAddress);
   },
 
@@ -531,6 +470,7 @@ setCallbacks({
    * @param {object} ticket An opaque session ticket
    */
   onSessionTicket(ticket) {
+    debug('session ticket callback', this[kOwner]);
     this[kOwner][kSessionTicket](ticket);
   },
 
@@ -543,6 +483,8 @@ setCallbacks({
   onSessionVersionNegotiation(version,
                               requestedVersions,
                               supportedVersions) {
+    debug('session version negotiation callback', version, requestedVersions, supportedVersions,
+          this[kOwner]);
     this[kOwner][kVersionNegotiation](version, requestedVersions, supportedVersions);
     // Note that immediately following a version negotiation event, the
     // session will be destroyed.
@@ -556,6 +498,7 @@ setCallbacks({
   onStreamCreated(stream, direction) {
     const session = this[kOwner];
     // The event is ignored and the stream destroyed if the session has been destroyed.
+    debug('stream created callback', session, direction);
     if (session.destroyed) {
       stream.destroy();
       return;
@@ -565,27 +508,54 @@ setCallbacks({
 
   // QuicStream callbacks
   onStreamBlocked() {
+    debug('stream blocked callback', this[kOwner]);
     // Called when the stream C++ handle has been blocked by flow control.
     this[kOwner][kBlocked]();
   },
+
   onStreamClose(error) {
     // Called when the stream C++ handle has been closed.
-    this[kOwner][kError](error);
+    debug(`stream ${this[kOwner].id} closed callback with error: ${error}`);
+    this[kOwner][kFinishClose](error);
   },
+
   onStreamReset(error) {
     // Called when the stream C++ handle has received a stream reset.
+    debug('stream reset callback', this[kOwner], error);
     this[kOwner][kReset](error);
   },
+
   onStreamHeaders(headers, kind) {
     // Called when the stream C++ handle has received a full block of headers.
+    debug(`stream ${this[kOwner].id} headers callback`, headers, kind);
     this[kOwner][kHeaders](headers, kind);
   },
+
   onStreamTrailers() {
     // Called when the stream C++ handle is ready to receive trailing headers.
+    debug('stream want trailers callback', this[kOwner]);
     this[kOwner][kTrailers]();
   },
 });
 
+function validateBody(body) {
+  // TODO(@jasnell): Support streaming sources
+  if (body === undefined) return body;
+  if (isArrayBuffer(body)) return ArrayBufferPrototypeTransfer(body);
+  if (isArrayBufferView(body)) {
+    const size = body.byteLength;
+    const offset = body.byteOffset;
+    return new Uint8Array(ArrayBufferPrototypeTransfer(body.buffer), offset, size);
+  }
+  if (isBlob(body)) return body[kBlobHandle];
+
+  throw new ERR_INVALID_ARG_TYPE('options.body', [
+    'ArrayBuffer',
+    'ArrayBufferView',
+    'Blob',
+  ], body);
+}
+
 class QuicStream {
   /** @type {object} */
   #handle;
@@ -596,59 +566,111 @@ class QuicStream {
   /** @type {QuicStreamState} */
   #state;
   /** @type {number} */
-  #direction;
+  #direction = undefined;
   /** @type {OnBlockedCallback|undefined} */
-  #onblocked;
+  #onblocked = undefined;
   /** @type {OnStreamErrorCallback|undefined} */
-  #onreset;
+  #onreset = undefined;
   /** @type {OnHeadersCallback|undefined} */
-  #onheaders;
+  #onheaders = undefined;
   /** @type {OnTrailersCallback|undefined} */
-  #ontrailers;
+  #ontrailers = undefined;
+  /** @type {Promise<void>} */
+  #pendingClose = Promise.withResolvers();  // eslint-disable-line node-core/prefer-primordials
+  #reader;
+  #readable;
 
   /**
    * @param {symbol} privateSymbol
-   * @param {StreamCallbackConfiguration} config
    * @param {object} handle
    * @param {QuicSession} session
+   * @param {number} direction
    */
-  constructor(privateSymbol, config, handle, session, direction) {
+  constructor(privateSymbol, handle, session, direction) {
     if (privateSymbol !== kPrivateConstructor) {
       throw new ERR_ILLEGAL_CONSTRUCTOR();
     }
 
-    const {
-      onblocked,
-      onreset,
-      onheaders,
-      ontrailers,
-    } = config;
+    this.#handle = handle;
+    this.#handle[kOwner] = this;
+    this.#session = session;
+    this.#direction = direction;
+    this.#stats = new QuicStreamStats(kPrivateConstructor, this.#handle.stats);
+    this.#state = new QuicStreamState(kPrivateConstructor, this.#handle.state);
+    this.#reader = this.#handle.getReader();
 
-    if (onblocked !== undefined) {
-      this.#onblocked = onblocked.bind(this);
+    if (this.pending) {
+      debug(`pending ${this.direction} stream created`);
+    } else {
+      debug(`${this.direction} stream ${this.id} created`);
     }
-    if (onreset !== undefined) {
-      this.#onreset = onreset.bind(this);
+  }
+
+  get readable() {
+    if (this.#readable === undefined) {
+      assert(this.#reader);
+      this.#readable = createBlobReaderStream(this.#reader);
     }
-    if (onheaders !== undefined) {
-      this.#onheaders = onheaders.bind(this);
+    return this.#readable;
+  }
+
+  /** @type {boolean} */
+  get pending() { return this.#state.pending; }
+
+  /** @type {OnBlockedCallback} */
+  get onblocked() { return this.#onblocked; }
+
+  set onblocked(fn) {
+    if (fn === undefined) {
+      this.#onblocked = undefined;
+      this.#state.wantsBlock = false;
+    } else {
+      validateFunction(fn, 'onblocked');
+      this.#onblocked = fn.bind(this);
+      this.#state.wantsBlock = true;
     }
-    if (ontrailers !== undefined) {
-      this.#ontrailers = ontrailers.bind(this);
+  }
+
+  /** @type {OnStreamErrorCallback} */
+  get onreset() { return this.#onreset; }
+
+  set onreset(fn) {
+    if (fn === undefined) {
+      this.#onreset = undefined;
+      this.#state.wantsReset = false;
+    } else {
+      validateFunction(fn, 'onreset');
+      this.#onreset = fn.bind(this);
+      this.#state.wantsReset = true;
     }
-    this.#handle = handle;
-    this.#handle[kOwner] = true;
+  }
 
-    this.#session = session;
-    this.#direction = direction;
+  /** @type {OnHeadersCallback} */
+  get [kOnHeaders]() { return this.#onheaders; }
 
-    this.#stats = new QuicStreamStats(kPrivateConstructor, this.#handle.stats);
+  set [kOnHeaders](fn) {
+    if (fn === undefined) {
+      this.#onheaders = undefined;
+      this.#state[kWantsHeaders] = false;
+    } else {
+      validateFunction(fn, 'onheaders');
+      this.#onheaders = fn.bind(this);
+      this.#state[kWantsHeaders] = true;
+    }
+  }
+
+  /** @type {OnTrailersCallback} */
+  get [kOnTrailers]() { return this.#ontrailers; }
 
-    this.#state = new QuicStreamState(kPrivateConstructor, this.#handle.stats);
-    this.#state.wantsBlock = !!this.#onblocked;
-    this.#state.wantsReset = !!this.#onreset;
-    this.#state.wantsHeaders = !!this.#onheaders;
-    this.#state.wantsTrailers = !!this.#ontrailers;
+  set [kOnTrailers](fn) {
+    if (fn === undefined) {
+      this.#ontrailers = undefined;
+      this.#state[kWantsTrailers] = false;
+    } else {
+      validateFunction(fn, 'ontrailers');
+      this.#ontrailers = fn.bind(this);
+      this.#state[kWantsTrailers] = true;
+    }
   }
 
   /** @type {QuicStreamStats} */
@@ -660,12 +682,19 @@ class QuicStream {
   /** @type {QuicSession} */
   get session() { return this.#session; }
 
-  /** @type {bigint} */
-  get id() { return this.#state.id; }
+  /**
+   * Returns the id for this stream. If the stream is destroyed or still pending,
+   * `undefined` will be returned.
+   * @type {bigint}
+   */
+  get id() {
+    if (this.destroyed || this.pending) return undefined;
+    return this.#state.id;
+  }
 
   /** @type {'bidi'|'uni'} */
   get direction() {
-    return this.#direction === 0 ? 'bidi' : 'uni';
+    return this.#direction === STREAM_DIRECTION_BIDIRECTIONAL ? 'bidi' : 'uni';
   }
 
   /** @returns {boolean} */
@@ -673,18 +702,115 @@ class QuicStream {
     return this.#handle === undefined;
   }
 
-  destroy(error) {
-    if (this.destroyed) return;
-    // TODO(@jasnell): pass an error code
+  /** @type {Promise<void>} */
+  get closed() {
+    return this.#pendingClose.promise;
+  }
+
+  /**
+   * @param {ArrayBuffer|ArrayBufferView|Blob} outbound
+   */
+  setOutbound(outbound) {
+    if (this.destroyed) {
+      throw new ERR_INVALID_STATE('Stream is destroyed');
+    }
+    if (this.#state.hasOutbound) {
+      throw new ERR_INVALID_STATE('Stream already has an outbound data source');
+    }
+    this.#handle.attachSource(validateBody(outbound));
+  }
+
+  /**
+   * @param {bigint} code
+   */
+  stopSending(code = 0n) {
+    if (this.destroyed) {
+      throw new ERR_INVALID_STATE('Stream is destroyed');
+    }
+    this.#handle.stopSending(BigInt(code));
+  }
+
+  /**
+   * @param {bigint} code
+   */
+  resetStream(code = 0n) {
+    if (this.destroyed) {
+      throw new ERR_INVALID_STATE('Stream is destroyed');
+    }
+    this.#handle.resetStream(BigInt(code));
+  }
+
+  /** @type {'default' | 'low' | 'high'} */
+  get priority() {
+    if (this.destroyed || !this.session.state.isPrioritySupported) return undefined;
+    switch (this.#handle.getPriority()) {
+      case 3: return 'default';
+      case 7: return 'low';
+      case 0: return 'high';
+      default: return 'default';
+    }
+  }
+
+  set priority(val) {
+    if (this.destroyed || !this.session.state.isPrioritySupported) return;
+    switch (val) {
+      case 'default': this.#handle.setPriority(3, 1); break;
+      case 'low': this.#handle.setPriority(7, 1); break;
+      case 'high': this.#handle.setPriority(0, 1); break;
+    }
+    // Otherwise ignore the value as invalid.
+  }
+
+  /**
+   * Send a block of headers. The headers are formatted as an array
+   * of key, value pairs. The reason we don't use a Headers object
+   * here is because this needs to be able to represent headers like
+   * :method which the high-level Headers API does not allow.
+   *
+   * Note that QUIC in general does not support headers. This method
+   * is in place to support HTTP3 and is therefore not generally
+   * exposed except via a private symbol.
+   * @param {object} headers
+   * @returns {boolean} true if the headers were scheduled to be sent.
+   */
+  [kSendHeaders](headers) {
+    validateObject(headers, 'headers');
+    if (this.pending) {
+      debug('pending stream enqueing headers', headers);
+    } else {
+      debug(`stream ${this.id} sending headers`, headers);
+    }
+    // TODO(@jasnell): Support differentiating between early headers, primary headers, etc
+    return this.#handle.sendHeaders(1, mapToHeaders(headers), 1);
+  }
+
+  [kFinishClose](error) {
+    if (this.destroyed) return this.#pendingClose.promise;
+    if (error !== undefined) {
+      if (this.pending) {
+        debug(`destroying pending stream with error: ${error}`);
+      } else {
+        debug(`destroying stream ${this.id} with error: ${error}`);
+      }
+      this.#pendingClose.reject(error);
+    } else {
+      if (this.pending) {
+        debug('destroying pending stream with no error');
+      } else {
+        debug(`destroying stream ${this.id} with no error`);
+      }
+      this.#pendingClose.resolve();
+    }
     this.#stats[kFinishClose]();
     this.#state[kFinishClose]();
+    this.#session[kRemoveStream](this);
+    this.#session = undefined;
+    this.#pendingClose.reject = undefined;
+    this.#pendingClose.resolve = undefined;
     this.#onblocked = undefined;
     this.#onreset = undefined;
     this.#onheaders = undefined;
     this.#ontrailers = undefined;
-    this.#session[kRemoveStream](this);
-    this.#session = undefined;
-    this.#handle.destroy();
     this.#handle = undefined;
   }
 
@@ -692,32 +818,41 @@ class QuicStream {
     // The blocked event should only be called if the stream was created with
     // an onblocked callback. The callback should always exist here.
     assert(this.#onblocked, 'Unexpected stream blocked event');
-    this.#onblocked(this);
-  }
-
-  [kError](error) {
-    this.destroy(error);
+    this.#onblocked();
   }
 
   [kReset](error) {
     // The reset event should only be called if the stream was created with
     // an onreset callback. The callback should always exist here.
     assert(this.#onreset, 'Unexpected stream reset event');
-    this.#onreset(error, this);
+    this.#onreset(error);
   }
 
   [kHeaders](headers, kind) {
     // The headers event should only be called if the stream was created with
     // an onheaders callback. The callback should always exist here.
     assert(this.#onheaders, 'Unexpected stream headers event');
-    this.#onheaders(headers, kind, this);
+    assert(ArrayIsArray(headers));
+    assert(headers.length % 2 === 0);
+    const block = {
+      __proto__: null,
+    };
+    for (let n = 0; n + 1 < headers.length; n += 2) {
+      if (block[headers[n]] !== undefined) {
+        block[headers[n]] = [block[headers[n]], headers[n + 1]];
+      } else {
+        block[headers[n]] = headers[n + 1];
+      }
+    }
+
+    this.#onheaders(block, kind);
   }
 
   [kTrailers]() {
     // The trailers event should only be called if the stream was created with
     // an ontrailers callback. The callback should always exist here.
     assert(this.#ontrailers, 'Unexpected stream trailers event');
-    this.#ontrailers(this);
+    this.#ontrailers();
   }
 
   [kInspect](depth, options) {
@@ -732,8 +867,9 @@ class QuicStream {
     return `Stream ${inspect({
       id: this.id,
       direction: this.direction,
+      pending: this.pending,
       stats: this.stats,
-      state: this.state,
+      state: this.#state,
       session: this.session,
     }, opts)}`;
   }
@@ -748,8 +884,8 @@ class QuicSession {
   #handle;
   /** @type {PromiseWithResolvers<void>} */
   #pendingClose = Promise.withResolvers();  // eslint-disable-line node-core/prefer-primordials
-  /** @type {SocketAddress|undefined} */
-  #remoteAddress = undefined;
+  /** @type {PromiseWithResolvers<QuicSessionInfo>} */
+  #pendingOpen = Promise.withResolvers();  // eslint-disable-line node-core/prefer-primordials
   /** @type {QuicSessionState} */
   #state;
   /** @type {QuicSessionStats} */
@@ -757,83 +893,33 @@ class QuicSession {
   /** @type {Set<QuicStream>} */
   #streams = new SafeSet();
   /** @type {OnStreamCallback} */
-  #onstream;
+  #onstream = undefined;
   /** @type {OnDatagramCallback|undefined} */
-  #ondatagram;
-  /** @type {OnDatagramStatusCallback|undefined} */
-  #ondatagramstatus;
-  /** @type {OnPathValidationCallback|undefined} */
-  #onpathvalidation;
-  /** @type {OnSessionTicketCallback|undefined} */
-  #onsessionticket;
-  /** @type {OnVersionNegotiationCallback|undefined} */
-  #onversionnegotiation;
-  /** @type {OnHandshakeCallback} */
-  #onhandshake;
-  /** @type {StreamCallbackConfiguration} */
-  #streamConfig;
+  #ondatagram = undefined;
+  /** @type {{}} */
+  #sessionticket = undefined;
 
   /**
    * @param {symbol} privateSymbol
-   * @param {ProcessedSessionCallbackConfiguration} config
    * @param {object} handle
    * @param {QuicEndpoint} endpoint
    */
-  constructor(privateSymbol, config, handle, endpoint) {
+  constructor(privateSymbol, handle, endpoint) {
     // Instances of QuicSession can only be created internally.
     if (privateSymbol !== kPrivateConstructor) {
       throw new ERR_ILLEGAL_CONSTRUCTOR();
     }
-    // The config should have already been validated by the QuicEndpoing
-    const {
-      ondatagram,
-      ondatagramstatus,
-      onhandshake,
-      onpathvalidation,
-      onsessionticket,
-      onstream,
-      onversionnegotiation,
-      stream,
-    } = config;
-
-    if (ondatagram !== undefined) {
-      this.#ondatagram = ondatagram.bind(this);
-    }
-    if (ondatagramstatus !== undefined) {
-      this.#ondatagramstatus = ondatagramstatus.bind(this);
-    }
-    if (onpathvalidation !== undefined) {
-      this.#onpathvalidation = onpathvalidation.bind(this);
-    }
-    if (onsessionticket !== undefined) {
-      this.#onsessionticket = onsessionticket.bind(this);
-    }
-    if (onversionnegotiation !== undefined) {
-      this.#onversionnegotiation = onversionnegotiation.bind(this);
-    }
-    if (onhandshake !== undefined) {
-      this.#onhandshake = onhandshake.bind(this);
-    }
 
-    // It is ok for onstream to be undefined if the session is not expecting
-    // or wanting to receive incoming streams. If a stream is received and
-    // no onstream callback is specified, a warning will be emitted and the
-    // stream will just be immediately destroyed.
-    if (onstream !== undefined) {
-      this.#onstream = onstream.bind(this);
-    }
     this.#endpoint = endpoint;
-    this.#streamConfig = stream;
-
     this.#handle = handle;
     this.#handle[kOwner] = this;
     this.#stats = new QuicSessionStats(kPrivateConstructor, handle.stats);
-
     this.#state = new QuicSessionState(kPrivateConstructor, handle.state);
-    this.#state.hasDatagramListener = !!ondatagram;
-    this.#state.hasPathValidationListener = !!onpathvalidation;
-    this.#state.hasSessionTicketListener = !!onsessionticket;
-    this.#state.hasVersionNegotiationListener = !!onversionnegotiation;
+    this.#state.hasVersionNegotiationListener = true;
+    this.#state.hasPathValidationListener = true;
+    this.#state.hasSessionTicketListener = true;
+
+    debug('session created');
   }
 
   /** @type {boolean} */
@@ -841,86 +927,114 @@ class QuicSession {
     return this.#handle === undefined || this.#isPendingClose;
   }
 
+  /** @type {any} */
+  get sessionticket() { return this.#sessionticket; }
+
+  /** @type {OnStreamCallback} */
+  get onstream() { return this.#onstream; }
+
+  set onstream(fn) {
+    if (fn === undefined) {
+      this.#onstream = undefined;
+    } else {
+      validateFunction(fn, 'onstream');
+      this.#onstream = fn.bind(this);
+    }
+  }
+
+  /** @type {OnDatagramCallback} */
+  get ondatagram() { return this.#ondatagram; }
+
+  set ondatagram(fn) {
+    if (fn === undefined) {
+      this.#ondatagram = undefined;
+      this.#state.hasDatagramListener = false;
+    } else {
+      validateFunction(fn, 'ondatagram');
+      this.#ondatagram = fn.bind(this);
+      this.#state.hasDatagramListener = true;
+    }
+  }
+
   /** @type {QuicSessionStats} */
   get stats() { return this.#stats; }
 
   /** @type {QuicSessionState} */
-  get state() { return this.#state; }
+  get [kState]() { return this.#state; }
 
   /** @type {QuicEndpoint} */
   get endpoint() { return this.#endpoint; }
 
   /**
-   * The path is the local and remote addresses of the session.
-   * @type {Path}
-   */
-  get path() {
-    if (this.destroyed) return undefined;
-    if (this.#remoteAddress === undefined) {
-      const addr = this.#handle.getRemoteAddress();
-      if (addr !== undefined) {
-        this.#remoteAddress = new InternalSocketAddress(addr);
-      }
-    }
-    return {
-      local: this.#endpoint.address,
-      remote: this.#remoteAddress,
-    };
-  }
-
-  /**
+   * @param {number} direction
+   * @param {OpenStreamOptions} options
    * @returns {QuicStream}
    */
-  openBidirectionalStream() {
+  async #createStream(direction, options = kEmptyObject) {
     if (this.#isClosedOrClosing) {
-      throw new ERR_INVALID_STATE('Session is closed');
+      throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.');
     }
-    if (!this.state.isStreamOpenAllowed) {
-      throw new ERR_QUIC_OPEN_STREAM_FAILED();
+    const dir = direction === STREAM_DIRECTION_BIDIRECTIONAL ? 'bidi' : 'uni';
+    if (this.#state.isStreamOpenAllowed) {
+      debug(`opening new pending ${dir} stream`);
+    } else {
+      debug(`opening new ${dir} stream`);
+    }
+
+    validateObject(options, 'options');
+    const {
+      body,
+      sendOrder = 50,
+      [kHeaders]: headers,
+    } = options;
+    if (headers !== undefined) {
+      validateObject(headers, 'options.headers');
     }
-    const handle = this.#handle.openStream(STREAM_DIRECTION_BIDIRECTIONAL);
+
+    validateNumber(sendOrder, 'options.sendOrder');
+    // TODO(@jasnell): Make use of sendOrder to set the priority
+
+    const validatedBody = validateBody(body);
+
+    const handle = this.#handle.openStream(direction, validatedBody);
     if (handle === undefined) {
       throw new ERR_QUIC_OPEN_STREAM_FAILED();
     }
-    const stream = new QuicStream(kPrivateConstructor, this.#streamConfig, handle,
-                                  this, 0 /* Bidirectional */);
+
+    if (headers !== undefined) {
+      // If headers are specified and there's no body, then we assume
+      // that the headers are terminal.
+      handle.sendHeaders(1, mapToHeaders(headers),
+                         validatedBody === undefined ? 1 : 0);
+    }
+
+    const stream = new QuicStream(kPrivateConstructor, handle, this, direction);
     this.#streams.add(stream);
 
     if (onSessionOpenStreamChannel.hasSubscribers) {
       onSessionOpenStreamChannel.publish({
         stream,
         session: this,
+        direction: dir,
       });
     }
     return stream;
   }
 
   /**
-   * @returns {QuicStream}
+   * @param {OpenStreamOptions} [options]
+   * @returns {Promise<QuicStream>}
    */
-  openUnidirectionalStream() {
-    if (this.#isClosedOrClosing) {
-      throw new ERR_INVALID_STATE('Session is closed');
-    }
-    if (!this.state.isStreamOpenAllowed) {
-      throw new ERR_QUIC_OPEN_STREAM_FAILED();
-    }
-    const handle = this.#handle.openStream(STREAM_DIRECTION_UNIDIRECTIONAL);
-    if (handle === undefined) {
-      throw new ERR_QUIC_OPEN_STREAM_FAILED();
-    }
-    const stream = new QuicStream(kPrivateConstructor, this.#streamConfig, handle,
-                                  this, 1 /* Unidirectional */);
-    this.#streams.add(stream);
-
-    if (onSessionOpenStreamChannel.hasSubscribers) {
-      onSessionOpenStreamChannel.publish({
-        stream,
-        session: this,
-      });
-    }
+  async createBidirectionalStream(options = kEmptyObject) {
+    return await this.#createStream(STREAM_DIRECTION_BIDIRECTIONAL, options);
+  }
 
-    return stream;
+  /**
+   * @param {OpenStreamOptions} [options]
+   * @returns {Promise<QuicStream>}
+   */
+  async createUnidirectionalStream(options = kEmptyObject) {
+    return await this.#createStream(STREAM_DIRECTION_UNIDIRECTIONAL, options);
   }
 
   /**
@@ -932,9 +1046,9 @@ class QuicSession {
    *
    * If an ArrayBufferView is given, the view will be copied.
    * @param {ArrayBufferView|string} datagram The datagram payload
-   * @returns {bigint} The datagram ID
+   * @returns {Promise<void>}
    */
-  sendDatagram(datagram) {
+  async sendDatagram(datagram) {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Session is closed');
     }
@@ -946,10 +1060,14 @@ class QuicSession {
                                        ['ArrayBufferView', 'string'],
                                        datagram);
       }
+      const length = datagram.byteLength;
+      const offset = datagram.byteOffset;
       datagram = new Uint8Array(ArrayBufferPrototypeTransfer(datagram.buffer),
-                                datagram.byteOffset,
-                                datagram.byteLength);
+                                length, offset);
     }
+
+    debug(`sending datagram with ${datagram.byteLength} bytes`);
+
     const id = this.#handle.sendDatagram(datagram);
 
     if (onSessionSendDatagramChannel.hasSubscribers) {
@@ -959,8 +1077,6 @@ class QuicSession {
         session: this,
       });
     }
-
-    return id;
   }
 
   /**
@@ -970,6 +1086,9 @@ class QuicSession {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Session is closed');
     }
+
+    debug('updating session key');
+
     this.#handle.updateKey();
     if (onSessionUpdateKeyChannel.hasSubscribers) {
       onSessionUpdateKeyChannel.publish({
@@ -991,6 +1110,9 @@ class QuicSession {
   close() {
     if (!this.#isClosedOrClosing) {
       this.#isPendingClose = true;
+
+      debug('gracefully closing the session');
+
       this.#handle?.gracefulClose();
       if (onSessionClosingChannel.hasSubscribers) {
         onSessionClosingChannel.publish({
@@ -1001,6 +1123,9 @@ class QuicSession {
     return this.closed;
   }
 
+  /** @type {Promise<QuicSessionInfo>} */
+  get opened() { return this.#pendingOpen.promise; }
+
   /**
    * A promise that is resolved when the session is closed, or is rejected if
    * the session is closed abruptly due to an error.
@@ -1018,10 +1143,12 @@ class QuicSession {
    * the closed promise will be rejected with that error. If no error is given,
    * the closed promise will be resolved.
    * @param {any} error
-   * @return {Promise<void>} Returns this.closed
    */
   destroy(error) {
     if (this.destroyed) return;
+
+    debug('destroying the session');
+
     // First, forcefully and immediately destroy all open streams, if any.
     for (const stream of this.#streams) {
       stream.destroy(error);
@@ -1047,24 +1174,22 @@ class QuicSession {
       // If the session is still waiting to be closed, and error
       // is specified, reject the closed promise.
       this.#pendingClose.reject?.(error);
+      this.#pendingOpen.reject?.(error);
     } else {
       this.#pendingClose.resolve?.();
     }
+
     this.#pendingClose.reject = undefined;
     this.#pendingClose.resolve = undefined;
+    this.#pendingOpen.reject = undefined;
+    this.#pendingOpen.resolve = undefined;
 
-    this.#remoteAddress = undefined;
     this.#state[kFinishClose]();
     this.#stats[kFinishClose]();
 
     this.#onstream = undefined;
     this.#ondatagram = undefined;
-    this.#ondatagramstatus = undefined;
-    this.#onpathvalidation = undefined;
-    this.#onsessionticket = undefined;
-    this.#onversionnegotiation = undefined;
-    this.#onhandshake = undefined;
-    this.#streamConfig = undefined;
+    this.#sessionticket = undefined;
 
     // Destroy the underlying C++ handle
     this.#handle.destroy();
@@ -1073,10 +1198,9 @@ class QuicSession {
     if (onSessionClosedChannel.hasSubscribers) {
       onSessionClosedChannel.publish({
         session: this,
+        error,
       });
     }
-
-    return this.closed;
   }
 
   /**
@@ -1087,19 +1211,29 @@ class QuicSession {
   [kFinishClose](errorType, code, reason) {
     // If code is zero, then we closed without an error. Yay! We can destroy
     // safely without specifying an error.
-    if (code === 0) {
+    if (code === 0n) {
+      debug('finishing closing the session with no error');
       this.destroy();
       return;
     }
 
+    debug('finishing closing the session with an error', errorType, code, reason);
     // Otherwise, errorType indicates the type of error that occurred, code indicates
     // the specific error, and reason is an optional string describing the error.
     switch (errorType) {
       case 0: /* Transport Error */
-        this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
+        if (code === 0n) {
+          this.destroy();
+        } else {
+          this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
+        }
         break;
       case 1: /* Application Error */
-        this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason));
+        if (code === 0n) {
+          this.destroy();
+        } else {
+          this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason));
+        }
         break;
       case 2: /* Version Negotiation Error */
         this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
@@ -1121,11 +1255,12 @@ class QuicSession {
     // an ondatagram callback. The callback should always exist here.
     assert(this.#ondatagram, 'Unexpected datagram event');
     if (this.destroyed) return;
+    const length = u8.byteLength;
     this.#ondatagram(u8, early);
 
     if (onSessionReceiveDatagramChannel.hasSubscribers) {
       onSessionReceiveDatagramChannel.publish({
-        length: u8.byteLength,
+        length,
         early,
         session: this,
       });
@@ -1138,10 +1273,6 @@ class QuicSession {
    */
   [kDatagramStatus](id, status) {
     if (this.destroyed) return;
-    // The ondatagramstatus callback may not have been specified. That's ok.
-    // We'll just ignore the event in that case.
-    this.#ondatagramstatus?.(id, status);
-
     if (onSessionReceiveDatagramStatusChannel.hasSubscribers) {
       onSessionReceiveDatagramStatusChannel.publish({
         id,
@@ -1161,13 +1292,7 @@ class QuicSession {
    */
   [kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress,
                     oldRemoteAddress, preferredAddress) {
-    // The path validation event should only be called if the session was created
-    // with an onpathvalidation callback. The callback should always exist here.
-    assert(this.#onpathvalidation, 'Unexpected path validation event');
     if (this.destroyed) return;
-    this.#onpathvalidation(result, newLocalAddress, newRemoteAddress,
-                           oldLocalAddress, oldRemoteAddress, preferredAddress);
-
     if (onSessionPathValidationChannel.hasSubscribers) {
       onSessionPathValidationChannel.publish({
         result,
@@ -1185,11 +1310,8 @@ class QuicSession {
    * @param {object} ticket
    */
   [kSessionTicket](ticket) {
-    // The session ticket event should only be called if the session was created
-    // with an onsessionticket callback. The callback should always exist here.
-    assert(this.#onsessionticket, 'Unexpected session ticket event');
     if (this.destroyed) return;
-    this.#onsessionticket(ticket);
+    this.#sessionticket = ticket;
     if (onSessionTicketChannel.hasSubscribers) {
       onSessionTicketChannel.publish({
         ticket,
@@ -1204,13 +1326,8 @@ class QuicSession {
    * @param {number[]} supportedVersions
    */
   [kVersionNegotiation](version, requestedVersions, supportedVersions) {
-    // The version negotiation event should only be called if the session was
-    // created with an onversionnegotiation callback. The callback should always
-    // exist here.
     if (this.destroyed) return;
-    this.#onversionnegotiation(version, requestedVersions, supportedVersions);
     this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
-
     if (onSessionVersionNegotiationChannel.hasSubscribers) {
       onSessionVersionNegotiationChannel.publish({
         version,
@@ -1222,32 +1339,40 @@ class QuicSession {
   }
 
   /**
-   * @param {string} sni
-   * @param {string} alpn
+   * @param {string} servername
+   * @param {string} protocol
    * @param {string} cipher
    * @param {string} cipherVersion
    * @param {string} validationErrorReason
    * @param {number} validationErrorCode
-   * @param {boolean} earlyDataAccepted
    */
-  [kHandshake](sni, alpn, cipher, cipherVersion, validationErrorReason,
-               validationErrorCode, earlyDataAccepted) {
-    if (this.destroyed) return;
-    // The onhandshake callback may not have been specified. That's ok.
-    // We'll just ignore the event in that case.
-    this.#onhandshake?.(sni, alpn, cipher, cipherVersion, validationErrorReason,
-                        validationErrorCode, earlyDataAccepted);
+  [kHandshake](servername, protocol, cipher, cipherVersion, validationErrorReason,
+               validationErrorCode) {
+    if (this.destroyed || !this.#pendingOpen.resolve) return;
+
+    const addr = this.#handle.getRemoteAddress();
+
+    const info = {
+      local: this.#endpoint.address,
+      remote: addr !== undefined ?
+        new InternalSocketAddress(addr) :
+        undefined,
+      servername,
+      protocol,
+      cipher,
+      cipherVersion,
+      validationErrorReason,
+      validationErrorCode,
+    };
+
+    this.#pendingOpen.resolve?.(info);
+    this.#pendingOpen.resolve = undefined;
+    this.#pendingOpen.reject = undefined;
 
     if (onSessionHandshakeChannel.hasSubscribers) {
       onSessionHandshakeChannel.publish({
-        sni,
-        alpn,
-        cipher,
-        cipherVersion,
-        validationErrorReason,
-        validationErrorCode,
-        earlyDataAccepted,
         session: this,
+        ...info,
       });
     }
   }
@@ -1257,12 +1382,11 @@ class QuicSession {
    * @param {number} direction
    */
   [kNewStream](handle, direction) {
-    const stream = new QuicStream(kPrivateConstructor, this.#streamConfig, handle,
-                                  this, direction);
+    const stream = new QuicStream(kPrivateConstructor, handle, this, direction);
 
     // A new stream was received. If we don't have an onstream callback, then
     // there's nothing we can do about it. Destroy the stream in this case.
-    if (this.#onstream === undefined) {
+    if (typeof this.#onstream !== 'function') {
       process.emitWarning('A new stream was received but no onstream callback was provided');
       stream.destroy();
       return;
@@ -1298,7 +1422,7 @@ class QuicSession {
       destroyed: this.destroyed,
       endpoint: this.endpoint,
       path: this.path,
-      state: this.state,
+      state: this.#state,
       stats: this.stats,
       streams: this.#streams,
     }, opts)}`;
@@ -1307,6 +1431,10 @@ class QuicSession {
   async [SymbolAsyncDispose]() { await this.close(); }
 }
 
+// The QuicEndpoint represents a local UDP port binding. It can act as both a
+// server for receiving peer sessions, or a client for initiating them. The
+// local UDP port will be lazily bound only when connect() or listen() are
+// called.
 class QuicEndpoint {
   /**
    * The local socket address on which the endpoint is listening (lazily created)
@@ -1369,121 +1497,10 @@ class QuicEndpoint {
    * (used only when the endpoint is acting as a server)
    * @type {OnSessionCallback}
    */
-  #onsession;
-  /**
-   * The callback configuration used for new sessions (client or server)
-   * @type {ProcessedSessionCallbackConfiguration}
-   */
-  #sessionConfig;
-
-  /**
-   * @param {EndpointCallbackConfiguration} config
-   * @returns {StreamCallbackConfiguration}
-   */
-  #processStreamConfig(config) {
-    validateObject(config, 'config');
-    const {
-      onblocked,
-      onreset,
-      onheaders,
-      ontrailers,
-    } = config;
-
-    if (onblocked !== undefined) {
-      validateFunction(onblocked, 'config.onblocked');
-    }
-    if (onreset !== undefined) {
-      validateFunction(onreset, 'config.onreset');
-    }
-    if (onheaders !== undefined) {
-      validateFunction(onheaders, 'config.onheaders');
-    }
-    if (ontrailers !== undefined) {
-      validateFunction(ontrailers, 'ontrailers');
-    }
-
-    return {
-      __proto__: null,
-      onblocked,
-      onreset,
-      onheaders,
-      ontrailers,
-    };
-  }
-
-  /**
-   *
-   * @param {EndpointCallbackConfiguration} config
-   * @returns {ProcessedSessionCallbackConfiguration}
-   */
-  #processSessionConfig(config) {
-    validateObject(config, 'config');
-    const {
-      onstream,
-      ondatagram,
-      ondatagramstatus,
-      onpathvalidation,
-      onsessionticket,
-      onversionnegotiation,
-      onhandshake,
-    } = config;
-    if (onstream !== undefined) {
-      validateFunction(onstream, 'config.onstream');
-    }
-    if (ondatagram !== undefined) {
-      validateFunction(ondatagram, 'config.ondatagram');
-    }
-    if (ondatagramstatus !== undefined) {
-      validateFunction(ondatagramstatus, 'config.ondatagramstatus');
-    }
-    if (onpathvalidation !== undefined) {
-      validateFunction(onpathvalidation, 'config.onpathvalidation');
-    }
-    if (onsessionticket !== undefined) {
-      validateFunction(onsessionticket, 'config.onsessionticket');
-    }
-    if (onversionnegotiation !== undefined) {
-      validateFunction(onversionnegotiation, 'config.onversionnegotiation');
-    }
-    if (onhandshake !== undefined) {
-      validateFunction(onhandshake, 'config.onhandshake');
-    }
-    return {
-      __proto__: null,
-      onstream,
-      ondatagram,
-      ondatagramstatus,
-      onpathvalidation,
-      onsessionticket,
-      onversionnegotiation,
-      onhandshake,
-      stream: this.#processStreamConfig(config),
-    };
-  }
-
-  /**
-   * @param {EndpointCallbackConfiguration} config
-   * @returns {ProcessedEndpointCallbackConfiguration}
-   */
-  #processEndpointConfig(config) {
-    validateObject(config, 'config');
-    const {
-      onsession,
-    } = config;
-
-    if (onsession !== undefined) {
-      validateFunction(config.onsession, 'config.onsession');
-    }
-
-    return {
-      __proto__: null,
-      onsession,
-      session: this.#processSessionConfig(config),
-    };
-  }
+  #onsession = undefined;
 
   /**
-   * @param {EndpointCallbackConfiguration} options
+   * @param {EndpointOptions} options
    * @returns {EndpointOptions}
    */
   #processEndpointOptions(options) {
@@ -1497,19 +1514,12 @@ class QuicEndpoint {
       maxStatelessResetsPerHost,
       addressLRUSize,
       maxRetries,
-      maxPayloadSize,
-      unacknowledgedPacketThreshold,
-      handshakeTimeout,
-      maxStreamWindow,
-      maxWindow,
       rxDiagnosticLoss,
       txDiagnosticLoss,
       udpReceiveBufferSize,
       udpSendBufferSize,
       udpTTL,
-      noUdpPayloadSizeShaping,
       validateAddress,
-      disableActiveMigration,
       ipv6Only,
       cc,
       resetTokenSecret,
@@ -1518,10 +1528,12 @@ class QuicEndpoint {
 
     // All of the other options will be validated internally by the C++ code
     if (address !== undefined && !SocketAddress.isSocketAddress(address)) {
-      if (typeof address === 'object' && address !== null) {
+      if (typeof address === 'string') {
+        address = SocketAddress.parse(address);
+      } else if (typeof address === 'object' && address !== null) {
         address = new SocketAddress(address);
       } else {
-        throw new ERR_INVALID_ARG_TYPE('options.address', 'SocketAddress', address);
+        throw new ERR_INVALID_ARG_TYPE('options.address', ['SocketAddress', 'string'], address);
       }
     }
 
@@ -1535,19 +1547,12 @@ class QuicEndpoint {
       maxStatelessResetsPerHost,
       addressLRUSize,
       maxRetries,
-      maxPayloadSize,
-      unacknowledgedPacketThreshold,
-      handshakeTimeout,
-      maxStreamWindow,
-      maxWindow,
       rxDiagnosticLoss,
       txDiagnosticLoss,
       udpReceiveBufferSize,
       udpSendBufferSize,
       udpTTL,
-      noUdpPayloadSizeShaping,
       validateAddress,
-      disableActiveMigration,
       ipv6Only,
       cc,
       resetTokenSecret,
@@ -1556,28 +1561,15 @@ class QuicEndpoint {
   }
 
   #newSession(handle) {
-    const session = new QuicSession(kPrivateConstructor, this.#sessionConfig, handle, this);
+    const session = new QuicSession(kPrivateConstructor, handle, this);
     this.#sessions.add(session);
     return session;
   }
 
   /**
-   * @param {EndpointCallbackConfiguration} config
+   * @param {EndpointOptions} config
    */
   constructor(config = kEmptyObject) {
-    const {
-      onsession,
-      session,
-    } = this.#processEndpointConfig(config);
-
-    // Note that the onsession callback is only used for server sessions.
-    // If the callback is not specified, calling listen() will fail but
-    // connect() can still be called.
-    if (onsession !== undefined) {
-      this.#onsession = onsession.bind(this);
-    }
-    this.#sessionConfig = session;
-
     this.#handle = new Endpoint_(this.#processEndpointOptions(config));
     this.#handle[kOwner] = this;
     this.#stats = new QuicEndpointStats(kPrivateConstructor, this.#handle.stats);
@@ -1589,16 +1581,21 @@ class QuicEndpoint {
         config,
       });
     }
+
+    debug('endpoint created');
   }
 
-  /** @type {QuicEndpointStats} */
+  /**
+   * Statistics collected while the endpoint is operational.
+   * @type {QuicEndpointStats}
+   */
   get stats() { return this.#stats; }
 
   /** @type {QuicEndpointState} */
-  get state() { return this.#state; }
+  get [kState]() { return this.#state; }
 
   get #isClosedOrClosing() {
-    return this.#handle === undefined || this.#isPendingClose;
+    return this.destroyed || this.#isPendingClose;
   }
 
   /**
@@ -1618,6 +1615,7 @@ class QuicEndpoint {
     // The val is allowed to be any truthy value
     // Non-op if there is no change
     if (!!val !== this.#busy) {
+      debug('toggling endpoint busy status to ', !this.#busy);
       this.#busy = !this.#busy;
       this.#handle.markBusy(this.#busy);
       if (onEndpointBusyChangeChannel.hasSubscribers) {
@@ -1642,226 +1640,60 @@ class QuicEndpoint {
     return this.#address;
   }
 
-  /**
-   * @param {TlsOptions} tls
-   */
-  #processTlsOptions(tls) {
-    validateObject(tls, 'options.tls');
-    const {
-      sni,
-      alpn,
-      ciphers = DEFAULT_CIPHERS,
-      groups = DEFAULT_GROUPS,
-      keylog = false,
-      verifyClient = false,
-      tlsTrace = false,
-      verifyPrivateKey = false,
-      keys,
-      certs,
-      ca,
-      crl,
-    } = tls;
-
-    if (sni !== undefined) {
-      validateString(sni, 'options.tls.sni');
-    }
-    if (alpn !== undefined) {
-      validateString(alpn, 'options.tls.alpn');
-    }
-    if (ciphers !== undefined) {
-      validateString(ciphers, 'options.tls.ciphers');
-    }
-    if (groups !== undefined) {
-      validateString(groups, 'options.tls.groups');
-    }
-    validateBoolean(keylog, 'options.tls.keylog');
-    validateBoolean(verifyClient, 'options.tls.verifyClient');
-    validateBoolean(tlsTrace, 'options.tls.tlsTrace');
-    validateBoolean(verifyPrivateKey, 'options.tls.verifyPrivateKey');
-
-    if (certs !== undefined) {
-      const certInputs = ArrayIsArray(certs) ? certs : [certs];
-      for (const cert of certInputs) {
-        if (!isArrayBufferView(cert) && !isArrayBuffer(cert)) {
-          throw new ERR_INVALID_ARG_TYPE('options.tls.certs',
-                                         ['ArrayBufferView', 'ArrayBuffer'], cert);
-        }
-      }
-    }
-
-    if (ca !== undefined) {
-      const caInputs = ArrayIsArray(ca) ? ca : [ca];
-      for (const caCert of caInputs) {
-        if (!isArrayBufferView(caCert) && !isArrayBuffer(caCert)) {
-          throw new ERR_INVALID_ARG_TYPE('options.tls.ca',
-                                         ['ArrayBufferView', 'ArrayBuffer'], caCert);
-        }
-      }
-    }
-
-    if (crl !== undefined) {
-      const crlInputs = ArrayIsArray(crl) ? crl : [crl];
-      for (const crlCert of crlInputs) {
-        if (!isArrayBufferView(crlCert) && !isArrayBuffer(crlCert)) {
-          throw new ERR_INVALID_ARG_TYPE('options.tls.crl',
-                                         ['ArrayBufferView', 'ArrayBuffer'], crlCert);
-        }
-      }
-    }
-
-    const keyHandles = [];
-    if (keys !== undefined) {
-      const keyInputs = ArrayIsArray(keys) ? keys : [keys];
-      for (const key of keyInputs) {
-        if (isKeyObject(key)) {
-          if (key.type !== 'private') {
-            throw new ERR_INVALID_ARG_VALUE('options.tls.keys', key, 'must be a private key');
-          }
-          ArrayPrototypePush(keyHandles, key[kKeyObjectHandle]);
-        } else if (isCryptoKey(key)) {
-          if (key.type !== 'private') {
-            throw new ERR_INVALID_ARG_VALUE('options.tls.keys', key, 'must be a private key');
-          }
-          ArrayPrototypePush(keyHandles, key[kKeyObjectInner][kKeyObjectHandle]);
-        } else {
-          throw new ERR_INVALID_ARG_TYPE('options.tls.keys', ['KeyObject', 'CryptoKey'], key);
-        }
-      }
-    }
-
-    return {
-      __proto__: null,
-      sni,
-      alpn,
-      ciphers,
-      groups,
-      keylog,
-      verifyClient,
-      tlsTrace,
-      verifyPrivateKey,
-      keys: keyHandles,
-      certs,
-      ca,
-      crl,
-    };
-  }
-
-  /**
-   * @param {'use'|'ignore'|'default'} policy
-   * @returns {number}
-   */
-  #getPreferredAddressPolicy(policy = 'default') {
-    switch (policy) {
-      case 'use': return PREFERRED_ADDRESS_USE;
-      case 'ignore': return PREFERRED_ADDRESS_IGNORE;
-      case 'default': return DEFAULT_PREFERRED_ADDRESS_POLICY;
-    }
-    throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy);
-  }
-
-  /**
-   * @param {SessionOptions} options
-   */
-  #processSessionOptions(options) {
-    validateObject(options, 'options');
-    const {
-      version,
-      minVersion,
-      preferredAddressPolicy = 'default',
-      application = kEmptyObject,
-      transportParams = kEmptyObject,
-      tls = kEmptyObject,
-      qlog = false,
-      sessionTicket,
-    } = options;
-
-    return {
-      __proto__: null,
-      version,
-      minVersion,
-      preferredAddressPolicy: this.#getPreferredAddressPolicy(preferredAddressPolicy),
-      application,
-      transportParams,
-      tls: this.#processTlsOptions(tls),
-      qlog,
-      sessionTicket,
-    };
-  }
-
   /**
    * Configures the endpoint to listen for incoming connections.
+   * @param {OnSessionCallback|SessionOptions} [onsession]
    * @param {SessionOptions} [options]
    */
-  listen(options = kEmptyObject) {
+  [kListen](onsession, options) {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Endpoint is closed');
     }
-    if (this.#onsession === undefined) {
-      throw new ERR_INVALID_STATE(
-        'Endpoint is not configured to accept sessions. Specify an onsession ' +
-        'callback when creating the endpoint',
-      );
-    }
     if (this.#listening) {
       throw new ERR_INVALID_STATE('Endpoint is already listening');
     }
-    this.#handle.listen(this.#processSessionOptions(options));
-    this.#listening = true;
-
-    if (onEndpointListeningChannel.hasSubscribers) {
-      onEndpointListeningChannel.publish({
-        endpoint: this,
-        options,
-      });
+    if (this.#state.isBusy) {
+      throw new ERR_INVALID_STATE('Endpoint is busy');
     }
+    validateObject(options, 'options');
+    this.#onsession = onsession.bind(this);
+
+    debug('endpoint listening as a server');
+    this.#handle.listen(options);
+    this.#listening = true;
   }
 
   /**
    * Initiates a session with a remote endpoint.
-   * @param {SocketAddress} address
+   * @param {{}} address
    * @param {SessionOptions} [options]
    * @returns {QuicSession}
    */
-  connect(address, options = kEmptyObject) {
+  [kConnect](address, options) {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Endpoint is closed');
     }
-
-    if (!SocketAddress.isSocketAddress(address)) {
-      if (address == null || typeof address !== 'object') {
-        throw new ERR_INVALID_ARG_TYPE('address', 'SocketAddress', address);
-      }
-      address = new SocketAddress(address);
+    if (this.#state.isBusy) {
+      throw new ERR_INVALID_STATE('Endpoint is busy');
     }
+    validateObject(options, 'options');
+    const { sessionTicket, ...rest } = options;
 
-    const processedOptions = this.#processSessionOptions(options);
-    const { sessionTicket } = processedOptions;
-
-    const handle = this.#handle.connect(address[kSocketAddressHandle],
-                                        processedOptions, sessionTicket);
-
+    debug('endpoint connecting as a client');
+    const handle = this.#handle.connect(address, rest, sessionTicket);
     if (handle === undefined) {
       throw new ERR_QUIC_CONNECTION_FAILED();
     }
     const session = this.#newSession(handle);
 
-    if (onEndpointClientSessionChannel.hasSubscribers) {
-      onEndpointClientSessionChannel.publish({
-        endpoint: this,
-        session,
-        address,
-        options,
-      });
-    }
-
     return session;
   }
 
   /**
    * Gracefully closes the endpoint. Any existing sessions will be permitted to
-   * end gracefully, after which the endpoint will be closed. New sessions will
-   * not be accepted or created. The returned promise will be resolved when
-   * closing is complete, or will be rejected if the endpoint is closed abruptly
+   * end gracefully, after which the endpoint will be closed immediately. New
+   * sessions will not be accepted or created. The returned promise will be resolved
+   * when closing is complete, or will be rejected if the endpoint is closed abruptly
    * due to an error.
    * @returns {Promise<void>} Returns this.closed
    */
@@ -1874,6 +1706,9 @@ class QuicEndpoint {
         });
       }
       this.#isPendingClose = true;
+
+      debug('gracefully closing the endpoint');
+
       this.#handle?.closeGracefully();
     }
     return this.closed;
@@ -1887,17 +1722,13 @@ class QuicEndpoint {
    */
   get closed() { return this.#pendingClose.promise; }
 
-  /** @type {boolean} */
-  get destroyed() { return this.#handle === undefined; }
-
   /**
-   * Return an iterator over all currently active sessions associated
-   * with this endpoint.
-   * @type {SetIterator<QuicSession>}
+   * @type {boolean}
    */
-  get sessions() {
-    return this.#sessions[SymbolIterator]();
-  }
+  get closing() { return this.#isPendingClose; }
+
+  /** @type {boolean} */
+  get destroyed() { return this.#handle === undefined; }
 
   /**
    * Forcefully terminates the endpoint by immediately destroying all sessions
@@ -1908,11 +1739,14 @@ class QuicEndpoint {
    * @returns {Promise<void>} Returns this.closed
    */
   destroy(error) {
+    debug('destroying the endpoint');
     if (!this.#isClosedOrClosing) {
-      // Start closing the endpoint.
       this.#pendingError = error;
       // Trigger a graceful close of the endpoint that'll ensure that the
-      // endpoint is closed down after all sessions are closed...
+      // endpoint is closed down after all sessions are closed... Because
+      // we force all sessions to be abruptly destroyed as the next step,
+      // the endpoint will be closed immediately after all the sessions
+      // are destroyed.
       this.close();
     }
     // Now, force all sessions to be abruptly closed...
@@ -1922,14 +1756,6 @@ class QuicEndpoint {
     return this.closed;
   }
 
-  ref() {
-    if (this.#handle !== undefined) this.#handle.ref(true);
-  }
-
-  unref() {
-    if (this.#handle !== undefined) this.#handle.ref(false);
-  }
-
   #maybeGetCloseError(context, status) {
     switch (context) {
       case kCloseContextClose: {
@@ -1956,6 +1782,7 @@ class QuicEndpoint {
 
   [kFinishClose](context, status) {
     if (this.#handle === undefined) return;
+    debug('endpoint is finishing close', context, status);
     this.#handle = undefined;
     this.#stats[kFinishClose]();
     this.#state[kFinishClose]();
@@ -2017,6 +1844,8 @@ class QuicEndpoint {
         session,
       });
     }
+    assert(typeof this.#onsession === 'function',
+           'onsession callback not specified');
     this.#onsession(session);
   }
 
@@ -2046,7 +1875,7 @@ class QuicEndpoint {
       listening: this.#listening,
       sessions: this.#sessions,
       stats: this.stats,
-      state: this.state,
+      state: this.#state,
     }, opts)}`;
   }
 };
@@ -2061,29 +1890,322 @@ function readOnlyConstant(value) {
   };
 }
 
+/**
+ * @param {EndpointOptions} endpoint
+ */
+function processEndpointOption(endpoint) {
+  if (endpoint === undefined) {
+    return {
+      endpoint: new QuicEndpoint(),
+      created: true,
+    };
+  } else if (endpoint instanceof QuicEndpoint) {
+    return {
+      endpoint,
+      created: false,
+    };
+  }
+  validateObject(endpoint, 'options.endpoint');
+  return {
+    endpoint: new QuicEndpoint(endpoint),
+    created: true,
+  };
+}
+
+/**
+ * @param {SessionOptions} tls
+ */
+function processTlsOptions(tls, forServer) {
+  const {
+    servername,
+    protocol,
+    ciphers = DEFAULT_CIPHERS,
+    groups = DEFAULT_GROUPS,
+    keylog = false,
+    verifyClient = false,
+    tlsTrace = false,
+    verifyPrivateKey = false,
+    keys,
+    certs,
+    ca,
+    crl,
+  } = tls;
+
+  if (servername !== undefined) {
+    validateString(servername, 'options.servername');
+  }
+  if (protocol !== undefined) {
+    validateString(protocol, 'options.protocol');
+  }
+  if (ciphers !== undefined) {
+    validateString(ciphers, 'options.ciphers');
+  }
+  if (groups !== undefined) {
+    validateString(groups, 'options.groups');
+  }
+  validateBoolean(keylog, 'options.keylog');
+  validateBoolean(verifyClient, 'options.verifyClient');
+  validateBoolean(tlsTrace, 'options.tlsTrace');
+  validateBoolean(verifyPrivateKey, 'options.verifyPrivateKey');
+
+  if (certs !== undefined) {
+    const certInputs = ArrayIsArray(certs) ? certs : [certs];
+    for (const cert of certInputs) {
+      if (!isArrayBufferView(cert) && !isArrayBuffer(cert)) {
+        throw new ERR_INVALID_ARG_TYPE('options.certs',
+                                       ['ArrayBufferView', 'ArrayBuffer'], cert);
+      }
+    }
+  }
+
+  if (ca !== undefined) {
+    const caInputs = ArrayIsArray(ca) ? ca : [ca];
+    for (const caCert of caInputs) {
+      if (!isArrayBufferView(caCert) && !isArrayBuffer(caCert)) {
+        throw new ERR_INVALID_ARG_TYPE('options.ca',
+                                       ['ArrayBufferView', 'ArrayBuffer'], caCert);
+      }
+    }
+  }
+
+  if (crl !== undefined) {
+    const crlInputs = ArrayIsArray(crl) ? crl : [crl];
+    for (const crlCert of crlInputs) {
+      if (!isArrayBufferView(crlCert) && !isArrayBuffer(crlCert)) {
+        throw new ERR_INVALID_ARG_TYPE('options.crl',
+                                       ['ArrayBufferView', 'ArrayBuffer'], crlCert);
+      }
+    }
+  }
+
+  const keyHandles = [];
+  if (keys !== undefined) {
+    const keyInputs = ArrayIsArray(keys) ? keys : [keys];
+    for (const key of keyInputs) {
+      if (isKeyObject(key)) {
+        if (key.type !== 'private') {
+          throw new ERR_INVALID_ARG_VALUE('options.keys', key, 'must be a private key');
+        }
+        ArrayPrototypePush(keyHandles, key[kKeyObjectHandle]);
+      } else if (isCryptoKey(key)) {
+        if (key.type !== 'private') {
+          throw new ERR_INVALID_ARG_VALUE('options.keys', key, 'must be a private key');
+        }
+        ArrayPrototypePush(keyHandles, key[kKeyObjectInner][kKeyObjectHandle]);
+      } else {
+        throw new ERR_INVALID_ARG_TYPE('options.keys', ['KeyObject', 'CryptoKey'], key);
+      }
+    }
+  }
+
+  // For a server we require key and cert at least
+  if (forServer) {
+    if (keyHandles.length === 0) {
+      throw new ERR_MISSING_ARGS('options.keys');
+    }
+    if (certs === undefined) {
+      throw new ERR_MISSING_ARGS('options.certs');
+    }
+  }
+
+  return {
+    __proto__: null,
+    servername,
+    protocol,
+    ciphers,
+    groups,
+    keylog,
+    verifyClient,
+    tlsTrace,
+    verifyPrivateKey,
+    keys: keyHandles,
+    certs,
+    ca,
+    crl,
+  };
+}
+
+/**
+ * @param {'use'|'ignore'|'default'} policy
+ * @returns {number}
+ */
+function getPreferredAddressPolicy(policy = 'default') {
+  switch (policy) {
+    case 'use': return PREFERRED_ADDRESS_USE;
+    case 'ignore': return PREFERRED_ADDRESS_IGNORE;
+    case 'default': return DEFAULT_PREFERRED_ADDRESS_POLICY;
+  }
+  throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy);
+}
+
+/**
+ * @param {SessionOptions} options
+ * @param {boolean} [forServer]
+ * @returns {SessionOptions}
+ */
+function processSessionOptions(options, forServer = false) {
+  validateObject(options, 'options');
+  const {
+    endpoint,
+    version,
+    minVersion,
+    preferredAddressPolicy = 'default',
+    transportParams = kEmptyObject,
+    qlog = false,
+    sessionTicket,
+    maxPayloadSize,
+    unacknowledgedPacketThreshold = 0,
+    handshakeTimeout,
+    maxStreamWindow,
+    maxWindow,
+    cc,
+    [kApplicationProvider]: provider,
+  } = options;
+
+  if (provider !== undefined) {
+    validateObject(provider, 'options[kApplicationProvider]');
+  }
+
+  if (cc !== undefined) {
+    validateString(cc, 'options.cc');
+    if (cc !== 'reno' || cc !== 'bbr' || cc !== 'cubic') {
+      throw new ERR_INVALID_ARG_VALUE(cc, 'options.cc');
+    }
+  }
+
+  const {
+    endpoint: actualEndpoint,
+    created: endpointCreated,
+  } = processEndpointOption(endpoint);
+
+  return {
+    __proto__: null,
+    endpoint: actualEndpoint,
+    endpointCreated,
+    version,
+    minVersion,
+    preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy),
+    transportParams,
+    tls: processTlsOptions(options, forServer),
+    qlog,
+    maxPayloadSize,
+    unacknowledgedPacketThreshold,
+    handshakeTimeout,
+    maxStreamWindow,
+    maxWindow,
+    sessionTicket,
+    provider,
+    cc,
+  };
+}
+
+// ============================================================================
+
+/**
+ * @param {OnSessionCallback} callback
+ * @param {SessionOptions} [options]
+ * @returns {Promise<QuicEndpoint>}
+ */
+async function listen(callback, options = kEmptyObject) {
+  validateFunction(callback, 'callback');
+  const {
+    endpoint,
+    ...sessionOptions
+  } = processSessionOptions(options, true /* for server */);
+  endpoint[kListen](callback, sessionOptions);
+
+  if (onEndpointListeningChannel.hasSubscribers) {
+    onEndpointListeningChannel.publish({
+      endpoint,
+      options,
+    });
+  }
+
+  return endpoint;
+}
+
+/**
+ * @param {string|SocketAddress} address
+ * @param {SessionOptions} [options]
+ * @returns {Promise<QuicSession>}
+ */
+async function connect(address, options = kEmptyObject) {
+  if (typeof address === 'string') {
+    address = SocketAddress.parse(address);
+  }
+
+  if (!SocketAddress.isSocketAddress(address)) {
+    if (address == null || typeof address !== 'object') {
+      throw new ERR_INVALID_ARG_TYPE('address', ['SocketAddress', 'string'], address);
+    }
+    address = new SocketAddress(address);
+  }
+
+  const {
+    endpoint,
+    ...rest
+  } = processSessionOptions(options);
+
+  const session = endpoint[kConnect](address[kSocketAddressHandle], rest);
+
+  if (onEndpointClientSessionChannel.hasSubscribers) {
+    onEndpointClientSessionChannel.publish({
+      endpoint,
+      session,
+      address,
+      options,
+    });
+  }
+
+  return session;
+}
+
 ObjectDefineProperties(QuicEndpoint, {
-  CC_ALGO_RENO: readOnlyConstant(CC_ALGO_RENO),
-  CC_ALGO_CUBIC: readOnlyConstant(CC_ALGO_CUBIC),
-  CC_ALGO_BBR: readOnlyConstant(CC_ALGO_BBR),
-  CC_ALGP_RENO_STR: readOnlyConstant(CC_ALGO_RENO_STR),
-  CC_ALGO_CUBIC_STR: readOnlyConstant(CC_ALGO_CUBIC_STR),
-  CC_ALGO_BBR_STR: readOnlyConstant(CC_ALGO_BBR_STR),
+  Stats: {
+    __proto__: null,
+    writable: true,
+    configurable: true,
+    enumerable: true,
+    value: QuicEndpointStats,
+  },
 });
 ObjectDefineProperties(QuicSession, {
-  DEFAULT_CIPHERS: readOnlyConstant(DEFAULT_CIPHERS),
-  DEFAULT_GROUPS: readOnlyConstant(DEFAULT_GROUPS),
+  Stats: {
+    __proto__: null,
+    writable: true,
+    configurable: true,
+    enumerable: true,
+    value: QuicSessionStats,
+  },
+});
+ObjectDefineProperties(QuicStream, {
+  Stats: {
+    __proto__: null,
+    writable: true,
+    configurable: true,
+    enumerable: true,
+    value: QuicStreamStats,
+  },
 });
 
+// ============================================================================
+
 module.exports = {
+  listen,
+  connect,
   QuicEndpoint,
   QuicSession,
   QuicStream,
-  QuicSessionState,
-  QuicSessionStats,
-  QuicStreamState,
-  QuicStreamStats,
-  QuicEndpointState,
-  QuicEndpointStats,
+  Http3,
 };
 
+ObjectDefineProperties(module.exports, {
+  CC_ALGO_RENO: readOnlyConstant(CC_ALGO_RENO_STR),
+  CC_ALGO_CUBIC: readOnlyConstant(CC_ALGO_CUBIC_STR),
+  CC_ALGO_BBR: readOnlyConstant(CC_ALGO_BBR_STR),
+  DEFAULT_CIPHERS: readOnlyConstant(DEFAULT_CIPHERS),
+  DEFAULT_GROUPS: readOnlyConstant(DEFAULT_GROUPS),
+});
+
+
 /* c8 ignore stop */
diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js
index 8bfb2ac83302fb..da880501d8cd61 100644
--- a/lib/internal/quic/state.js
+++ b/lib/internal/quic/state.js
@@ -14,7 +14,6 @@ const {
   codes: {
     ERR_ILLEGAL_CONSTRUCTOR,
     ERR_INVALID_ARG_TYPE,
-    ERR_INVALID_STATE,
   },
 } = require('internal/errors');
 
@@ -23,11 +22,14 @@ const {
 } = require('util/types');
 
 const { inspect } = require('internal/util/inspect');
+const assert = require('internal/assert');
 
 const {
   kFinishClose,
   kInspect,
   kPrivateConstructor,
+  kWantsHeaders,
+  kWantsTrailers,
 } = require('internal/quic/symbols');
 
 // This file defines the helper objects for accessing state for
@@ -47,7 +49,6 @@ const {
   IDX_STATE_SESSION_GRACEFUL_CLOSE,
   IDX_STATE_SESSION_SILENT_CLOSE,
   IDX_STATE_SESSION_STATELESS_RESET,
-  IDX_STATE_SESSION_DESTROYED,
   IDX_STATE_SESSION_HANDSHAKE_COMPLETED,
   IDX_STATE_SESSION_HANDSHAKE_CONFIRMED,
   IDX_STATE_SESSION_STREAM_OPEN_ALLOWED,
@@ -63,13 +64,14 @@ const {
   IDX_STATE_ENDPOINT_PENDING_CALLBACKS,
 
   IDX_STATE_STREAM_ID,
+  IDX_STATE_STREAM_PENDING,
   IDX_STATE_STREAM_FIN_SENT,
   IDX_STATE_STREAM_FIN_RECEIVED,
   IDX_STATE_STREAM_READ_ENDED,
   IDX_STATE_STREAM_WRITE_ENDED,
-  IDX_STATE_STREAM_DESTROYED,
   IDX_STATE_STREAM_PAUSED,
   IDX_STATE_STREAM_RESET,
+  IDX_STATE_STREAM_HAS_OUTBOUND,
   IDX_STATE_STREAM_HAS_READER,
   IDX_STATE_STREAM_WANTS_BLOCK,
   IDX_STATE_STREAM_WANTS_HEADERS,
@@ -77,6 +79,41 @@ const {
   IDX_STATE_STREAM_WANTS_TRAILERS,
 } = internalBinding('quic');
 
+assert(IDX_STATE_SESSION_PATH_VALIDATION !== undefined);
+assert(IDX_STATE_SESSION_VERSION_NEGOTIATION !== undefined);
+assert(IDX_STATE_SESSION_DATAGRAM !== undefined);
+assert(IDX_STATE_SESSION_SESSION_TICKET !== undefined);
+assert(IDX_STATE_SESSION_CLOSING !== undefined);
+assert(IDX_STATE_SESSION_GRACEFUL_CLOSE !== undefined);
+assert(IDX_STATE_SESSION_SILENT_CLOSE !== undefined);
+assert(IDX_STATE_SESSION_STATELESS_RESET !== undefined);
+assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined);
+assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined);
+assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined);
+assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined);
+assert(IDX_STATE_SESSION_WRAPPED !== undefined);
+assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined);
+assert(IDX_STATE_ENDPOINT_BOUND !== undefined);
+assert(IDX_STATE_ENDPOINT_RECEIVING !== undefined);
+assert(IDX_STATE_ENDPOINT_LISTENING !== undefined);
+assert(IDX_STATE_ENDPOINT_CLOSING !== undefined);
+assert(IDX_STATE_ENDPOINT_BUSY !== undefined);
+assert(IDX_STATE_ENDPOINT_PENDING_CALLBACKS !== undefined);
+assert(IDX_STATE_STREAM_ID !== undefined);
+assert(IDX_STATE_STREAM_PENDING !== undefined);
+assert(IDX_STATE_STREAM_FIN_SENT !== undefined);
+assert(IDX_STATE_STREAM_FIN_RECEIVED !== undefined);
+assert(IDX_STATE_STREAM_READ_ENDED !== undefined);
+assert(IDX_STATE_STREAM_WRITE_ENDED !== undefined);
+assert(IDX_STATE_STREAM_PAUSED !== undefined);
+assert(IDX_STATE_STREAM_RESET !== undefined);
+assert(IDX_STATE_STREAM_HAS_OUTBOUND !== undefined);
+assert(IDX_STATE_STREAM_HAS_READER !== undefined);
+assert(IDX_STATE_STREAM_WANTS_BLOCK !== undefined);
+assert(IDX_STATE_STREAM_WANTS_HEADERS !== undefined);
+assert(IDX_STATE_STREAM_WANTS_RESET !== undefined);
+assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined);
+
 class QuicEndpointState {
   /** @type {DataView} */
   #handle;
@@ -95,39 +132,33 @@ class QuicEndpointState {
     this.#handle = new DataView(buffer);
   }
 
-  #assertNotClosed() {
-    if (this.#handle.byteLength === 0) {
-      throw new ERR_INVALID_STATE('Endpoint is closed');
-    }
-  }
-
   /** @type {boolean} */
   get isBound() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND);
   }
 
   /** @type {boolean} */
   get isReceiving() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING);
   }
 
   /** @type {boolean} */
   get isListening() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING);
   }
 
   /** @type {boolean} */
   get isClosing() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING);
   }
 
   /** @type {boolean} */
   get isBusy() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BUSY);
   }
 
@@ -138,7 +169,7 @@ class QuicEndpointState {
    * @type {bigint}
    */
   get pendingCallbacks() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS);
   }
 
@@ -208,123 +239,111 @@ class QuicSessionState {
     this.#handle = new DataView(buffer);
   }
 
-  #assertNotClosed() {
-    if (this.#handle.byteLength === 0) {
-      throw new ERR_INVALID_STATE('Session is closed');
-    }
-  }
-
   /** @type {boolean} */
   get hasPathValidationListener() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION);
   }
 
   /** @type {boolean} */
   set hasPathValidationListener(val) {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION, val ? 1 : 0);
   }
 
   /** @type {boolean} */
   get hasVersionNegotiationListener() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION);
   }
 
   /** @type {boolean} */
   set hasVersionNegotiationListener(val) {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION, val ? 1 : 0);
   }
 
   /** @type {boolean} */
   get hasDatagramListener() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM);
   }
 
   /** @type {boolean} */
   set hasDatagramListener(val) {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0);
   }
 
   /** @type {boolean} */
   get hasSessionTicketListener() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET);
   }
 
   /** @type {boolean} */
   set hasSessionTicketListener(val) {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET, val ? 1 : 0);
   }
 
   /** @type {boolean} */
   get isClosing() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING);
   }
 
   /** @type {boolean} */
   get isGracefulClose() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE);
   }
 
   /** @type {boolean} */
   get isSilentClose() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE);
   }
 
   /** @type {boolean} */
   get isStatelessReset() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET);
   }
 
-  /** @type {boolean} */
-  get isDestroyed() {
-    this.#assertNotClosed();
-    return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DESTROYED);
-  }
-
   /** @type {boolean} */
   get isHandshakeCompleted() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED);
   }
 
   /** @type {boolean} */
   get isHandshakeConfirmed() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED);
   }
 
   /** @type {boolean} */
   get isStreamOpenAllowed() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED);
   }
 
   /** @type {boolean} */
   get isPrioritySupported() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED);
   }
 
   /** @type {boolean} */
   get isWrapped() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED);
   }
 
   /** @type {bigint} */
   get lastDatagramId() {
-    this.#assertNotClosed();
+    if (this.#handle.byteLength === 0) return undefined;
     return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID);
   }
 
@@ -414,86 +433,109 @@ class QuicStreamState {
 
   /** @type {bigint} */
   get id() {
+    if (this.#handle.byteLength === 0) return undefined;
     return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID);
   }
 
+  /** @type {boolean} */
+  get pending() {
+    if (this.#handle.byteLength === 0) return undefined;
+    return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PENDING);
+  }
+
   /** @type {boolean} */
   get finSent() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT);
   }
 
   /** @type {boolean} */
   get finReceived() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED);
   }
 
   /** @type {boolean} */
   get readEnded() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED);
   }
 
   /** @type {boolean} */
   get writeEnded() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED);
   }
 
-  /** @type {boolean} */
-  get destroyed() {
-    return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_DESTROYED);
-  }
-
   /** @type {boolean} */
   get paused() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PAUSED);
   }
 
   /** @type {boolean} */
   get reset() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET);
   }
 
+  /** @type {boolean} */
+  get hasOutbound() {
+    if (this.#handle.byteLength === 0) return undefined;
+    return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_OUTBOUND);
+  }
+
   /** @type {boolean} */
   get hasReader() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER);
   }
 
   /** @type {boolean} */
   get wantsBlock() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK);
   }
 
   /** @type {boolean} */
   set wantsBlock(val) {
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0);
   }
 
   /** @type {boolean} */
-  get wantsHeaders() {
+  get [kWantsHeaders]() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS);
   }
 
   /** @type {boolean} */
-  set wantsHeaders(val) {
+  set [kWantsHeaders](val) {
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0);
   }
 
   /** @type {boolean} */
   get wantsReset() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET);
   }
 
   /** @type {boolean} */
   set wantsReset(val) {
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0);
   }
 
   /** @type {boolean} */
-  get wantsTrailers() {
+  get [kWantsTrailers]() {
+    if (this.#handle.byteLength === 0) return undefined;
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS);
   }
 
   /** @type {boolean} */
-  set wantsTrailers(val) {
+  set [kWantsTrailers](val) {
+    if (this.#handle.byteLength === 0) return;
     DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0);
   }
 
@@ -506,18 +548,17 @@ class QuicStreamState {
     return {
       __proto__: null,
       id: `${this.id}`,
+      pending: this.pending,
       finSent: this.finSent,
       finReceived: this.finReceived,
       readEnded: this.readEnded,
       writeEnded: this.writeEnded,
-      destroyed: this.destroyed,
       paused: this.paused,
       reset: this.reset,
+      hasOutbound: this.hasOutbound,
       hasReader: this.hasReader,
       wantsBlock: this.wantsBlock,
-      wantsHeaders: this.wantsHeaders,
       wantsReset: this.wantsReset,
-      wantsTrailers: this.wantsTrailers,
     };
   }
 
@@ -536,18 +577,17 @@ class QuicStreamState {
 
     return `QuicStreamState ${inspect({
       id: this.id,
+      pending: this.pending,
       finSent: this.finSent,
       finReceived: this.finReceived,
       readEnded: this.readEnded,
       writeEnded: this.writeEnded,
-      destroyed: this.destroyed,
       paused: this.paused,
       reset: this.reset,
+      hasOutbound: this.hasOutbound,
       hasReader: this.hasReader,
       wantsBlock: this.wantsBlock,
-      wantsHeaders: this.wantsHeaders,
       wantsReset: this.wantsReset,
-      wantsTrailers: this.wantsTrailers,
     }, opts)}`;
   }
 
diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js
index 3ac9523d9aeca4..d12a85745bd79a 100644
--- a/lib/internal/quic/stats.js
+++ b/lib/internal/quic/stats.js
@@ -17,6 +17,7 @@ const {
 } = require('internal/errors');
 
 const { inspect } = require('internal/util/inspect');
+const assert = require('internal/assert');
 
 const {
   kFinishClose,
@@ -50,17 +51,14 @@ const {
 
   IDX_STATS_SESSION_CREATED_AT,
   IDX_STATS_SESSION_CLOSING_AT,
-  IDX_STATS_SESSION_DESTROYED_AT,
   IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT,
   IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT,
-  IDX_STATS_SESSION_GRACEFUL_CLOSING_AT,
   IDX_STATS_SESSION_BYTES_RECEIVED,
   IDX_STATS_SESSION_BYTES_SENT,
   IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT,
   IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT,
   IDX_STATS_SESSION_UNI_IN_STREAM_COUNT,
   IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT,
-  IDX_STATS_SESSION_LOSS_RETRANSMIT_COUNT,
   IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT,
   IDX_STATS_SESSION_BYTES_IN_FLIGHT,
   IDX_STATS_SESSION_BLOCK_COUNT,
@@ -76,9 +74,9 @@ const {
   IDX_STATS_SESSION_DATAGRAMS_LOST,
 
   IDX_STATS_STREAM_CREATED_AT,
+  IDX_STATS_STREAM_OPENED_AT,
   IDX_STATS_STREAM_RECEIVED_AT,
   IDX_STATS_STREAM_ACKED_AT,
-  IDX_STATS_STREAM_CLOSING_AT,
   IDX_STATS_STREAM_DESTROYED_AT,
   IDX_STATS_STREAM_BYTES_RECEIVED,
   IDX_STATS_STREAM_BYTES_SENT,
@@ -88,6 +86,54 @@ const {
   IDX_STATS_STREAM_FINAL_SIZE,
 } = internalBinding('quic');
 
+assert(IDX_STATS_ENDPOINT_CREATED_AT !== undefined);
+assert(IDX_STATS_ENDPOINT_DESTROYED_AT !== undefined);
+assert(IDX_STATS_ENDPOINT_BYTES_RECEIVED !== undefined);
+assert(IDX_STATS_ENDPOINT_BYTES_SENT !== undefined);
+assert(IDX_STATS_ENDPOINT_PACKETS_RECEIVED !== undefined);
+assert(IDX_STATS_ENDPOINT_PACKETS_SENT !== undefined);
+assert(IDX_STATS_ENDPOINT_SERVER_SESSIONS !== undefined);
+assert(IDX_STATS_ENDPOINT_CLIENT_SESSIONS !== undefined);
+assert(IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT !== undefined);
+assert(IDX_STATS_ENDPOINT_RETRY_COUNT !== undefined);
+assert(IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT !== undefined);
+assert(IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT !== undefined);
+assert(IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT !== undefined);
+assert(IDX_STATS_SESSION_CREATED_AT !== undefined);
+assert(IDX_STATS_SESSION_CLOSING_AT !== undefined);
+assert(IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT !== undefined);
+assert(IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT !== undefined);
+assert(IDX_STATS_SESSION_BYTES_RECEIVED !== undefined);
+assert(IDX_STATS_SESSION_BYTES_SENT !== undefined);
+assert(IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT !== undefined);
+assert(IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT !== undefined);
+assert(IDX_STATS_SESSION_UNI_IN_STREAM_COUNT !== undefined);
+assert(IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT !== undefined);
+assert(IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT !== undefined);
+assert(IDX_STATS_SESSION_BYTES_IN_FLIGHT !== undefined);
+assert(IDX_STATS_SESSION_BLOCK_COUNT !== undefined);
+assert(IDX_STATS_SESSION_CWND !== undefined);
+assert(IDX_STATS_SESSION_LATEST_RTT !== undefined);
+assert(IDX_STATS_SESSION_MIN_RTT !== undefined);
+assert(IDX_STATS_SESSION_RTTVAR !== undefined);
+assert(IDX_STATS_SESSION_SMOOTHED_RTT !== undefined);
+assert(IDX_STATS_SESSION_SSTHRESH !== undefined);
+assert(IDX_STATS_SESSION_DATAGRAMS_RECEIVED !== undefined);
+assert(IDX_STATS_SESSION_DATAGRAMS_SENT !== undefined);
+assert(IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED !== undefined);
+assert(IDX_STATS_SESSION_DATAGRAMS_LOST !== undefined);
+assert(IDX_STATS_STREAM_CREATED_AT !== undefined);
+assert(IDX_STATS_STREAM_OPENED_AT !== undefined);
+assert(IDX_STATS_STREAM_RECEIVED_AT !== undefined);
+assert(IDX_STATS_STREAM_ACKED_AT !== undefined);
+assert(IDX_STATS_STREAM_DESTROYED_AT !== undefined);
+assert(IDX_STATS_STREAM_BYTES_RECEIVED !== undefined);
+assert(IDX_STATS_STREAM_BYTES_SENT !== undefined);
+assert(IDX_STATS_STREAM_MAX_OFFSET !== undefined);
+assert(IDX_STATS_STREAM_MAX_OFFSET_ACK !== undefined);
+assert(IDX_STATS_STREAM_MAX_OFFSET_RECV !== undefined);
+assert(IDX_STATS_STREAM_FINAL_SIZE !== undefined);
+
 class QuicEndpointStats {
   /** @type {BigUint64Array} */
   #handle;
@@ -278,11 +324,6 @@ class QuicSessionStats {
     return this.#handle[IDX_STATS_SESSION_CLOSING_AT];
   }
 
-  /** @type {bigint} */
-  get destroyedAt() {
-    return this.#handle[IDX_STATS_SESSION_DESTROYED_AT];
-  }
-
   /** @type {bigint} */
   get handshakeCompletedAt() {
     return this.#handle[IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT];
@@ -293,11 +334,6 @@ class QuicSessionStats {
     return this.#handle[IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT];
   }
 
-  /** @type {bigint} */
-  get gracefulClosingAt() {
-    return this.#handle[IDX_STATS_SESSION_GRACEFUL_CLOSING_AT];
-  }
-
   /** @type {bigint} */
   get bytesReceived() {
     return this.#handle[IDX_STATS_SESSION_BYTES_RECEIVED];
@@ -328,11 +364,6 @@ class QuicSessionStats {
     return this.#handle[IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT];
   }
 
-  /** @type {bigint} */
-  get lossRetransmitCount() {
-    return this.#handle[IDX_STATS_SESSION_LOSS_RETRANSMIT_COUNT];
-  }
-
   /** @type {bigint} */
   get maxBytesInFlights() {
     return this.#handle[IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT];
@@ -420,7 +451,6 @@ class QuicSessionStats {
       bidiOutStreamCount: `${this.bidiOutStreamCount}`,
       uniInStreamCount: `${this.uniInStreamCount}`,
       uniOutStreamCount: `${this.uniOutStreamCount}`,
-      lossRetransmitCount: `${this.lossRetransmitCount}`,
       maxBytesInFlights: `${this.maxBytesInFlights}`,
       bytesInFlight: `${this.bytesInFlight}`,
       blockCount: `${this.blockCount}`,
@@ -460,7 +490,6 @@ class QuicSessionStats {
       bidiOutStreamCount: this.bidiOutStreamCount,
       uniInStreamCount: this.uniInStreamCount,
       uniOutStreamCount: this.uniOutStreamCount,
-      lossRetransmitCount: this.lossRetransmitCount,
       maxBytesInFlights: this.maxBytesInFlights,
       bytesInFlight: this.bytesInFlight,
       blockCount: this.blockCount,
@@ -522,6 +551,11 @@ class QuicStreamStats {
     return this.#handle[IDX_STATS_STREAM_CREATED_AT];
   }
 
+  /** @type {bigint} */
+  get openedAt() {
+    return this.#handle[IDX_STATS_STREAM_OPENED_AT];
+  }
+
   /** @type {bigint} */
   get receivedAt() {
     return this.#handle[IDX_STATS_STREAM_RECEIVED_AT];
@@ -532,11 +566,6 @@ class QuicStreamStats {
     return this.#handle[IDX_STATS_STREAM_ACKED_AT];
   }
 
-  /** @type {bigint} */
-  get closingAt() {
-    return this.#handle[IDX_STATS_STREAM_CLOSING_AT];
-  }
-
   /** @type {bigint} */
   get destroyedAt() {
     return this.#handle[IDX_STATS_STREAM_DESTROYED_AT];
@@ -583,9 +612,9 @@ class QuicStreamStats {
       // We need to convert the values to strings because JSON does not
       // support BigInts.
       createdAt: `${this.createdAt}`,
+      openedAt: `${this.openedAt}`,
       receivedAt: `${this.receivedAt}`,
       ackedAt: `${this.ackedAt}`,
-      closingAt: `${this.closingAt}`,
       destroyedAt: `${this.destroyedAt}`,
       bytesReceived: `${this.bytesReceived}`,
       bytesSent: `${this.bytesSent}`,
@@ -608,9 +637,9 @@ class QuicStreamStats {
     return `StreamStats ${inspect({
       connected: this.isConnected,
       createdAt: this.createdAt,
+      openedAt: this.openedAt,
       receivedAt: this.receivedAt,
       ackedAt: this.ackedAt,
-      closingAt: this.closingAt,
       destroyedAt: this.destroyedAt,
       bytesReceived: this.bytesReceived,
       bytesSent: this.bytesSent,
diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js
index c436b5c4b787ff..15f2339fc95504 100644
--- a/lib/internal/quic/symbols.js
+++ b/lib/internal/quic/symbols.js
@@ -16,45 +16,61 @@ const {
 // Symbols used to hide various private properties and methods from the
 // public API.
 
+const kApplicationProvider = Symbol('kApplicationProvider');
 const kBlocked = Symbol('kBlocked');
+const kConnect = Symbol('kConnect');
 const kDatagram = Symbol('kDatagram');
 const kDatagramStatus = Symbol('kDatagramStatus');
-const kError = Symbol('kError');
 const kFinishClose = Symbol('kFinishClose');
 const kHandshake = Symbol('kHandshake');
 const kHeaders = Symbol('kHeaders');
-const kOwner = Symbol('kOwner');
-const kRemoveSession = Symbol('kRemoveSession');
+const kListen = Symbol('kListen');
 const kNewSession = Symbol('kNewSession');
-const kRemoveStream = Symbol('kRemoveStream');
 const kNewStream = Symbol('kNewStream');
+const kOnHeaders = Symbol('kOnHeaders');
+const kOnTrailers = Symbol('kOwnTrailers');
+const kOwner = Symbol('kOwner');
 const kPathValidation = Symbol('kPathValidation');
+const kPrivateConstructor = Symbol('kPrivateConstructor');
+const kRemoveSession = Symbol('kRemoveSession');
+const kRemoveStream = Symbol('kRemoveStream');
 const kReset = Symbol('kReset');
+const kSendHeaders = Symbol('kSendHeaders');
 const kSessionTicket = Symbol('kSessionTicket');
+const kState = Symbol('kState');
 const kTrailers = Symbol('kTrailers');
 const kVersionNegotiation = Symbol('kVersionNegotiation');
-const kPrivateConstructor = Symbol('kPrivateConstructor');
+const kWantsHeaders = Symbol('kWantsHeaders');
+const kWantsTrailers = Symbol('kWantsTrailers');
 
 module.exports = {
+  kApplicationProvider,
   kBlocked,
+  kConnect,
   kDatagram,
   kDatagramStatus,
-  kError,
   kFinishClose,
   kHandshake,
   kHeaders,
-  kOwner,
-  kRemoveSession,
+  kInspect,
+  kKeyObjectHandle,
+  kKeyObjectInner,
+  kListen,
   kNewSession,
-  kRemoveStream,
   kNewStream,
+  kOnHeaders,
+  kOnTrailers,
+  kOwner,
   kPathValidation,
+  kPrivateConstructor,
+  kRemoveSession,
+  kRemoveStream,
   kReset,
+  kSendHeaders,
   kSessionTicket,
+  kState,
   kTrailers,
   kVersionNegotiation,
-  kInspect,
-  kKeyObjectHandle,
-  kKeyObjectInner,
-  kPrivateConstructor,
+  kWantsHeaders,
+  kWantsTrailers,
 };
diff --git a/lib/quic.js b/lib/quic.js
new file mode 100644
index 00000000000000..a6ca37825fbe71
--- /dev/null
+++ b/lib/quic.js
@@ -0,0 +1,32 @@
+'use strict';
+
+const {
+  emitExperimentalWarning,
+} = require('internal/util');
+emitExperimentalWarning('quic');
+
+const {
+  connect,
+  listen,
+  QuicEndpoint,
+  QuicSession,
+  QuicStream,
+  CC_ALGO_RENO,
+  CC_ALGO_CUBIC,
+  CC_ALGO_BBR,
+  DEFAULT_CIPHERS,
+  DEFAULT_GROUPS,
+} = require('internal/quic/quic');
+
+module.exports = {
+  connect,
+  listen,
+  QuicEndpoint,
+  QuicSession,
+  QuicStream,
+  CC_ALGO_RENO,
+  CC_ALGO_CUBIC,
+  CC_ALGO_BBR,
+  DEFAULT_CIPHERS,
+  DEFAULT_GROUPS,
+};
diff --git a/src/node_builtins.cc b/src/node_builtins.cc
index 791c16ce3942d7..c3ab61b014885e 100644
--- a/src/node_builtins.cc
+++ b/src/node_builtins.cc
@@ -138,6 +138,7 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const {
         "internal/quic/quic", "internal/quic/symbols", "internal/quic/stats",
         "internal/quic/state",
 #endif             // !NODE_OPENSSL_HAS_QUIC
+        "quic",    // Experimental.
         "sqlite",  // Experimental.
         "sys",     // Deprecated.
         "wasi",    // Experimental.
diff --git a/src/node_http_common-inl.h b/src/node_http_common-inl.h
index dba1a5e051b3e0..f7f4408ecb6eaa 100644
--- a/src/node_http_common-inl.h
+++ b/src/node_http_common-inl.h
@@ -93,17 +93,13 @@ bool NgHeader<T>::IsZeroLength(
 }
 
 template <typename T>
-bool NgHeader<T>::IsZeroLength(
-    int32_t token,
-    NgHeader<T>::rcbuf_t* name,
-    NgHeader<T>::rcbuf_t* value) {
-
+bool NgHeader<T>::IsZeroLength(int32_t token,
+                               NgHeader<T>::rcbuf_t* name,
+                               NgHeader<T>::rcbuf_t* value) {
   if (NgHeader<T>::rcbufferpointer_t::IsZeroLength(value))
     return true;
 
-  const char* header_name = T::ToHttpHeaderName(token);
-  return header_name != nullptr ||
-      NgHeader<T>::rcbufferpointer_t::IsZeroLength(name);
+  return NgHeader<T>::rcbufferpointer_t::IsZeroLength(name);
 }
 
 template <typename T>
diff --git a/src/node_options.cc b/src/node_options.cc
index eb04af9dabb4d8..8d529651342ba6 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -436,6 +436,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
             &EnvironmentOptions::experimental_sqlite,
             kAllowedInEnvvar,
             true);
+  AddOption("--experimental-quic",
+            "experimental QUIC API",
+            &EnvironmentOptions::experimental_quic,
+            kAllowedInEnvvar);
   AddOption("--experimental-webstorage",
             "experimental Web Storage API",
             &EnvironmentOptions::experimental_webstorage,
diff --git a/src/node_options.h b/src/node_options.h
index 8b9f8a825e61c4..9563f90f41f7d8 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -126,6 +126,7 @@ class EnvironmentOptions : public Options {
   bool experimental_websocket = true;
   bool experimental_sqlite = true;
   bool experimental_webstorage = false;
+  bool experimental_quic = false;
   std::string localstorage_file;
   bool experimental_global_navigator = true;
   bool experimental_global_web_crypto = true;
diff --git a/src/quic/application.cc b/src/quic/application.cc
index 876290bbbbb2c1..4c126a64ed7138 100644
--- a/src/quic/application.cc
+++ b/src/quic/application.cc
@@ -3,6 +3,7 @@
 #include "application.h"
 #include <async_wrap-inl.h>
 #include <debug_utils-inl.h>
+#include <nghttp3/nghttp3.h>
 #include <ngtcp2/ngtcp2.h>
 #include <node_bob.h>
 #include <node_sockaddr-inl.h>
@@ -30,15 +31,17 @@ namespace quic {
 const Session::Application_Options Session::Application_Options::kDefault = {};
 
 Session::Application_Options::operator const nghttp3_settings() const {
-  // In theory, Application_Options might contain options for more than just
+  // In theory, Application::Options might contain options for more than just
   // HTTP/3. Here we extract only the properties that are relevant to HTTP/3.
   return nghttp3_settings{
-      max_field_section_size,
-      static_cast<size_t>(qpack_max_dtable_capacity),
-      static_cast<size_t>(qpack_encoder_max_dtable_capacity),
-      static_cast<size_t>(qpack_blocked_streams),
-      enable_connect_protocol,
-      enable_datagrams,
+      .max_field_section_size = max_field_section_size,
+      .qpack_max_dtable_capacity =
+          static_cast<size_t>(qpack_max_dtable_capacity),
+      .qpack_encoder_max_dtable_capacity =
+          static_cast<size_t>(qpack_encoder_max_dtable_capacity),
+      .qpack_blocked_streams = static_cast<size_t>(qpack_blocked_streams),
+      .enable_connect_protocol = enable_connect_protocol,
+      .h3_datagram = enable_datagrams,
   };
 }
 
@@ -66,29 +69,33 @@ std::string Session::Application_Options::ToString() const {
 
 Maybe<Session::Application_Options> Session::Application_Options::From(
     Environment* env, Local<Value> value) {
-  if (value.IsEmpty() || (!value->IsUndefined() && !value->IsObject())) {
+  if (value.IsEmpty()) [[unlikely]] {
     THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object");
     return Nothing<Application_Options>();
   }
 
   Application_Options options;
   auto& state = BindingData::Get(env);
-  if (value->IsUndefined()) {
-    return Just<Application_Options>(options);
-  }
-
-  auto params = value.As<Object>();
 
 #define SET(name)                                                              \
   SetOption<Session::Application_Options,                                      \
             &Session::Application_Options::name>(                              \
       env, &options, params, state.name##_string())
 
-  if (!SET(max_header_pairs) || !SET(max_header_length) ||
-      !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) ||
-      !SET(qpack_encoder_max_dtable_capacity) || !SET(qpack_blocked_streams) ||
-      !SET(enable_connect_protocol) || !SET(enable_datagrams)) {
-    return Nothing<Application_Options>();
+  if (!value->IsUndefined()) {
+    if (!value->IsObject()) {
+      THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object");
+      return Nothing<Application_Options>();
+    }
+    auto params = value.As<Object>();
+    if (!SET(max_header_pairs) || !SET(max_header_length) ||
+        !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) ||
+        !SET(qpack_encoder_max_dtable_capacity) ||
+        !SET(qpack_blocked_streams) || !SET(enable_connect_protocol) ||
+        !SET(enable_datagrams)) {
+      // The call to SetOption should have scheduled an exception to be thrown.
+      return Nothing<Application_Options>();
+    }
   }
 
 #undef SET
@@ -100,12 +107,18 @@ Maybe<Session::Application_Options> Session::Application_Options::From(
 
 std::string Session::Application::StreamData::ToString() const {
   DebugIndentScope indent;
+
+  size_t total_bytes = 0;
+  for (size_t n = 0; n < count; n++) {
+    total_bytes += data[n].len;
+  }
+
   auto prefix = indent.Prefix();
   std::string res("{");
   res += prefix + "count: " + std::to_string(count);
-  res += prefix + "remaining: " + std::to_string(remaining);
   res += prefix + "id: " + std::to_string(id);
   res += prefix + "fin: " + std::to_string(fin);
+  res += prefix + "total: " + std::to_string(total_bytes);
   res += indent.Close();
   return res;
 }
@@ -120,27 +133,23 @@ bool Session::Application::Start() {
   return true;
 }
 
-void Session::Application::AcknowledgeStreamData(Stream* stream,
+bool Session::Application::AcknowledgeStreamData(int64_t stream_id,
                                                  size_t datalen) {
-  Debug(session_,
-        "Application acknowledging stream %" PRIi64 " data: %zu",
-        stream->id(),
-        datalen);
-  DCHECK_NOT_NULL(stream);
-  stream->Acknowledge(datalen);
+  if (auto stream = session().FindStream(stream_id)) [[likely]] {
+    stream->Acknowledge(datalen);
+    return true;
+  }
+  return false;
 }
 
 void Session::Application::BlockStream(int64_t id) {
-  Debug(session_, "Application blocking stream %" PRIi64, id);
-  auto stream = session().FindStream(id);
-  if (stream) stream->EmitBlocked();
+  // By default do nothing.
 }
 
 bool Session::Application::CanAddHeader(size_t current_count,
                                         size_t current_headers_length,
                                         size_t this_header_length) {
   // By default headers are not supported.
-  Debug(session_, "Application cannot add header");
   return false;
 }
 
@@ -149,19 +158,16 @@ bool Session::Application::SendHeaders(const Stream& stream,
                                        const v8::Local<v8::Array>& headers,
                                        HeadersFlags flags) {
   // By default do nothing.
-  Debug(session_, "Application cannot send headers");
   return false;
 }
 
 void Session::Application::ResumeStream(int64_t id) {
-  Debug(session_, "Application resuming stream %" PRIi64, id);
   // By default do nothing.
 }
 
 void Session::Application::ExtendMaxStreams(EndpointLabel label,
                                             Direction direction,
                                             uint64_t max_streams) {
-  Debug(session_, "Application extending max streams");
   // By default do nothing.
 }
 
@@ -173,7 +179,6 @@ void Session::Application::ExtendMaxStreamData(Stream* stream,
 
 void Session::Application::CollectSessionTicketAppData(
     SessionTicket::AppData* app_data) const {
-  Debug(session_, "Application collecting session ticket app data");
   // By default do nothing.
 }
 
@@ -181,7 +186,6 @@ SessionTicket::AppData::Status
 Session::Application::ExtractSessionTicketAppData(
     const SessionTicket::AppData& app_data,
     SessionTicket::AppData::Source::Flag flag) {
-  Debug(session_, "Application extracting session ticket app data");
   // By default we do not have any application data to retrieve.
   return flag == SessionTicket::AppData::Source::Flag::STATUS_RENEW
              ? SessionTicket::AppData::Status::TICKET_USE_RENEW
@@ -191,8 +195,6 @@ Session::Application::ExtractSessionTicketAppData(
 void Session::Application::SetStreamPriority(const Stream& stream,
                                              StreamPriority priority,
                                              StreamPriorityFlags flags) {
-  Debug(
-      session_, "Application setting stream %" PRIi64 " priority", stream.id());
   // By default do nothing.
 }
 
@@ -200,68 +202,73 @@ StreamPriority Session::Application::GetStreamPriority(const Stream& stream) {
   return StreamPriority::DEFAULT;
 }
 
-Packet* Session::Application::CreateStreamDataPacket() {
+BaseObjectPtr<Packet> Session::Application::CreateStreamDataPacket() {
   return Packet::Create(env(),
-                        session_->endpoint_.get(),
-                        session_->remote_address_,
+                        session_->endpoint(),
+                        session_->remote_address(),
                         session_->max_packet_size(),
                         "stream data");
 }
 
-void Session::Application::StreamClose(Stream* stream, QuicError error) {
-  Debug(session_,
-        "Application closing stream %" PRIi64 " with error %s",
-        stream->id(),
-        error);
-  stream->Destroy(error);
+void Session::Application::StreamClose(Stream* stream, QuicError&& error) {
+  DCHECK_NOT_NULL(stream);
+  stream->Destroy(std::move(error));
 }
 
-void Session::Application::StreamStopSending(Stream* stream, QuicError error) {
-  Debug(session_,
-        "Application stopping sending on stream %" PRIi64 " with error %s",
-        stream->id(),
-        error);
+void Session::Application::StreamStopSending(Stream* stream,
+                                             QuicError&& error) {
   DCHECK_NOT_NULL(stream);
-  stream->ReceiveStopSending(error);
+  stream->ReceiveStopSending(std::move(error));
 }
 
 void Session::Application::StreamReset(Stream* stream,
                                        uint64_t final_size,
-                                       QuicError error) {
-  Debug(session_,
-        "Application resetting stream %" PRIi64 " with error %s",
-        stream->id(),
-        error);
-  stream->ReceiveStreamReset(final_size, error);
+                                       QuicError&& error) {
+  stream->ReceiveStreamReset(final_size, std::move(error));
 }
 
 void Session::Application::SendPendingData() {
+  DCHECK(!session().is_destroyed());
+  if (!session().can_send_packets()) [[unlikely]] {
+    return;
+  }
   static constexpr size_t kMaxPackets = 32;
   Debug(session_, "Application sending pending data");
   PathStorage path;
   StreamData stream_data;
 
+  auto update_stats = OnScopeLeave([&] {
+    auto& s = session();
+    if (!s.is_destroyed()) [[likely]] {
+      s.UpdatePacketTxTime();
+      s.UpdateTimer();
+      s.UpdateDataStats();
+    }
+  });
+
   // The maximum size of packet to create.
   const size_t max_packet_size = session_->max_packet_size();
 
   // The maximum number of packets to send in this call to SendPendingData.
   const size_t max_packet_count = std::min(
       kMaxPackets, ngtcp2_conn_get_send_quantum(*session_) / max_packet_size);
+  if (max_packet_count == 0) return;
 
   // The number of packets that have been sent in this call to SendPendingData.
   size_t packet_send_count = 0;
 
-  Packet* packet = nullptr;
+  BaseObjectPtr<Packet> packet;
   uint8_t* pos = nullptr;
   uint8_t* begin = nullptr;
 
   auto ensure_packet = [&] {
-    if (packet == nullptr) {
+    if (!packet) {
       packet = CreateStreamDataPacket();
-      if (packet == nullptr) return false;
+      if (!packet) [[unlikely]]
+        return false;
       pos = begin = ngtcp2_vec(*packet).base;
     }
-    DCHECK_NOT_NULL(packet);
+    DCHECK(packet);
     DCHECK_NOT_NULL(pos);
     DCHECK_NOT_NULL(begin);
     return true;
@@ -274,29 +281,43 @@ void Session::Application::SendPendingData() {
     ssize_t ndatalen = 0;
 
     // Make sure we have a packet to write data into.
-    if (!ensure_packet()) {
+    if (!ensure_packet()) [[unlikely]] {
       Debug(session_, "Failed to create packet for stream data");
       // Doh! Could not create a packet. Time to bail.
-      session_->last_error_ = QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL);
+      session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
       return session_->Close(Session::CloseMethod::SILENT);
     }
 
     // The stream_data is the next block of data from the application stream.
     if (GetStreamData(&stream_data) < 0) {
       Debug(session_, "Application failed to get stream data");
-      session_->last_error_ = QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL);
       packet->Done(UV_ECANCELED);
+      session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
       return session_->Close(Session::CloseMethod::SILENT);
     }
 
     // If we got here, we were at least successful in checking for stream data.
     // There might not be any stream data to send.
-    Debug(session_, "Application using stream data: %s", stream_data);
+    if (stream_data.id >= 0) {
+      Debug(session_, "Application using stream data: %s", stream_data);
+    }
 
     // Awesome, let's write our packet!
     ssize_t nwrite =
         WriteVStream(&path, pos, &ndatalen, max_packet_size, stream_data);
-    Debug(session_, "Application accepted %zu bytes into packet", ndatalen);
+
+    if (ndatalen > 0) {
+      Debug(session_,
+            "Application accepted %zu bytes from stream %" PRIi64
+            " into packet",
+            ndatalen,
+            stream_data.id);
+    } else if (stream_data.id >= 0) {
+      Debug(session_,
+            "Application did not accept any bytes from stream %" PRIi64
+            " into packet",
+            stream_data.id);
+    }
 
     // A negative nwrite value indicates either an error or that there is more
     // data to write into the packet.
@@ -309,7 +330,9 @@ void Session::Application::SendPendingData() {
           // ndatalen = -1 means that no stream data was accepted into the
           // packet, which is what we want here.
           DCHECK_EQ(ndatalen, -1);
-          DCHECK(stream_data.stream);
+          // We should only have received this error if there was an actual
+          // stream identified in the stream data, but let's double check.
+          DCHECK_GE(stream_data.id, 0);
           session_->StreamDataBlocked(stream_data.id);
           continue;
         }
@@ -318,22 +341,26 @@ void Session::Application::SendPendingData() {
           // locally or the stream is being reset. In either case, we can't send
           // any stream data!
           Debug(session_,
-                "Stream %" PRIi64 " should be closed for writing",
+                "Closing stream %" PRIi64 " for writing",
                 stream_data.id);
           // ndatalen = -1 means that no stream data was accepted into the
           // packet, which is what we want here.
           DCHECK_EQ(ndatalen, -1);
-          DCHECK(stream_data.stream);
-          stream_data.stream->EndWritable();
+          // We should only have received this error if there was an actual
+          // stream identified in the stream data, but let's double check.
+          DCHECK_GE(stream_data.id, 0);
+          if (stream_data.stream) [[likely]] {
+            stream_data.stream->EndWritable();
+          }
           continue;
         }
         case NGTCP2_ERR_WRITE_MORE: {
-          // This return value indicates that we should call into WriteVStream
-          // again to write more data into the same packet.
-          Debug(session_, "Application should write more to packet");
-          DCHECK_GE(ndatalen, 0);
-          if (!StreamCommit(&stream_data, ndatalen)) {
+          if (ndatalen >= 0 && !StreamCommit(&stream_data, ndatalen)) {
+            Debug(session_,
+                  "Failed to commit stream data while writing packets");
             packet->Done(UV_ECANCELED);
+            session_->SetLastError(
+                QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
             return session_->Close(CloseMethod::SILENT);
           }
           continue;
@@ -345,39 +372,33 @@ void Session::Application::SendPendingData() {
       Debug(session_,
             "Application encountered error while writing packet: %s",
             ngtcp2_strerror(nwrite));
-      session_->SetLastError(QuicError::ForNgtcp2Error(nwrite));
       packet->Done(UV_ECANCELED);
+      session_->SetLastError(QuicError::ForNgtcp2Error(nwrite));
       return session_->Close(Session::CloseMethod::SILENT);
-    } else if (ndatalen >= 0) {
-      // We wrote some data into the packet. We need to update the flow control
-      // by committing the data.
-      if (!StreamCommit(&stream_data, ndatalen)) {
-        packet->Done(UV_ECANCELED);
-        return session_->Close(CloseMethod::SILENT);
-      }
+    } else if (ndatalen >= 0 && !StreamCommit(&stream_data, ndatalen)) {
+      packet->Done(UV_ECANCELED);
+      session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
+      return session_->Close(CloseMethod::SILENT);
     }
 
-    // When nwrite is zero, it means we are congestion limited.
-    // We should stop trying to send additional packets.
+    // When nwrite is zero, it means we are congestion limited or it is
+    // just not our turn now to send something. Stop sending packets.
     if (nwrite == 0) {
-      Debug(session_, "Congestion limited.");
+      // If there was stream data selected, we should reschedule it to try
+      // sending again.
+      if (stream_data.id >= 0) ResumeStream(stream_data.id);
+
       // There might be a partial packet already prepared. If so, send it.
       size_t datalen = pos - begin;
       if (datalen) {
-        Debug(session_, "Packet has %zu bytes to send", datalen);
-        // At least some data had been written into the packet. We should send
-        // it.
+        Debug(session_, "Sending packet with %zu bytes", datalen);
         packet->Truncate(datalen);
         session_->Send(packet, path);
       } else {
         packet->Done(UV_ECANCELED);
       }
 
-      // If there was stream data selected, we should reschedule it to try
-      // sending again.
-      if (stream_data.id >= 0) ResumeStream(stream_data.id);
-
-      return session_->UpdatePacketTxTime();
+      return;
     }
 
     // At this point we have a packet prepared to send.
@@ -389,11 +410,11 @@ void Session::Application::SendPendingData() {
 
     // If we have sent the maximum number of packets, we're done.
     if (++packet_send_count == max_packet_count) {
-      return session_->UpdatePacketTxTime();
+      return;
     }
 
     // Prepare to loop back around to prepare a new packet.
-    packet = nullptr;
+    packet.reset();
     pos = begin = nullptr;
   }
 }
@@ -406,16 +427,15 @@ ssize_t Session::Application::WriteVStream(PathStorage* path,
   DCHECK_LE(stream_data.count, kMaxVectorCount);
   uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE;
   if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN;
-  ngtcp2_pkt_info pi;
   return ngtcp2_conn_writev_stream(*session_,
                                    &path->path,
-                                   &pi,
+                                   nullptr,
                                    dest,
                                    max_packet_size,
                                    ndatalen,
                                    flags,
                                    stream_data.id,
-                                   stream_data.buf,
+                                   stream_data,
                                    stream_data.count,
                                    uv_hrtime());
 }
@@ -429,17 +449,44 @@ class DefaultApplication final : public Session::Application {
   // of the namespace.
   using Application::Application;  // NOLINT
 
-  bool ReceiveStreamData(Stream* stream,
+  bool ReceiveStreamData(int64_t stream_id,
                          const uint8_t* data,
                          size_t datalen,
-                         Stream::ReceiveDataFlags flags) override {
-    Debug(&session(), "Default application receiving stream data");
-    DCHECK_NOT_NULL(stream);
-    if (!stream->is_destroyed()) stream->ReceiveData(data, datalen, flags);
+                         const Stream::ReceiveDataFlags& flags,
+                         void* stream_user_data) override {
+    BaseObjectPtr<Stream> stream;
+    if (stream_user_data == nullptr) {
+      // This is the first time we're seeing this stream. Implicitly create it.
+      stream = session().CreateStream(stream_id);
+      if (!stream) [[unlikely]] {
+        // We couldn't actually create the stream for whatever reason.
+        Debug(&session(), "Default application failed to create new stream");
+        return false;
+      }
+    } else {
+      stream = BaseObjectPtr<Stream>(Stream::From(stream_user_data));
+      if (!stream) {
+        Debug(&session(),
+              "Default application failed to get existing stream "
+              "from user data");
+        return false;
+      }
+    }
+
+    CHECK(stream);
+
+    // Now we can actually receive the data! Woo!
+    stream->ReceiveData(data, datalen, flags);
     return true;
   }
 
   int GetStreamData(StreamData* stream_data) override {
+    // Reset the state of stream_data before proceeding...
+    stream_data->id = -1;
+    stream_data->count = 0;
+    stream_data->fin = 0;
+    stream_data->stream.reset();
+    stream_data->remaining = 0;
     Debug(&session(), "Default application getting stream data");
     DCHECK_NOT_NULL(stream_data);
     // If the queue is empty, there aren't any streams with data yet
@@ -467,6 +514,17 @@ class DefaultApplication final : public Session::Application {
               stream_data->fin = 1;
           }
 
+          // It is possible that the data pointers returned are not actually
+          // the data pointers in the stream_data. If that's the case, we need
+          // to copy over the pointers.
+          count = std::min(count, kMaxVectorCount);
+          ngtcp2_vec* dest = *stream_data;
+          if (dest != data) {
+            for (size_t n = 0; n < count; n++) {
+              dest[n] = data[n];
+            }
+          }
+
           stream_data->count = count;
 
           if (count > 0) {
@@ -496,45 +554,28 @@ class DefaultApplication final : public Session::Application {
     return 0;
   }
 
-  void ResumeStream(int64_t id) override {
-    Debug(&session(), "Default application resuming stream %" PRIi64, id);
-    ScheduleStream(id);
-  }
+  void ResumeStream(int64_t id) override { ScheduleStream(id); }
 
   bool ShouldSetFin(const StreamData& stream_data) override {
-    auto const is_empty = [](auto vec, size_t cnt) {
-      size_t i;
-      for (i = 0; i < cnt && vec[i].len == 0; ++i) {
-      }
-      return i == cnt;
+    auto const is_empty = [](const ngtcp2_vec* vec, size_t cnt) {
+      size_t i = 0;
+      for (size_t n = 0; n < cnt; n++) i += vec[n].len;
+      return i > 0;
     };
 
-    return stream_data.stream && is_empty(stream_data.buf, stream_data.count);
+    return stream_data.stream && is_empty(stream_data, stream_data.count);
+  }
+
+  void BlockStream(int64_t id) override {
+    if (auto stream = session().FindStream(id)) [[likely]] {
+      stream->EmitBlocked();
+    }
   }
 
   bool StreamCommit(StreamData* stream_data, size_t datalen) override {
-    Debug(&session(), "Default application committing stream data");
+    if (datalen == 0) return true;
     DCHECK_NOT_NULL(stream_data);
-    const auto consume = [](ngtcp2_vec** pvec, size_t* pcnt, size_t len) {
-      ngtcp2_vec* v = *pvec;
-      size_t cnt = *pcnt;
-
-      for (; cnt > 0; --cnt, ++v) {
-        if (v->len > len) {
-          v->len -= len;
-          v->base += len;
-          break;
-        }
-        len -= v->len;
-      }
-
-      *pvec = v;
-      *pcnt = cnt;
-    };
-
     CHECK(stream_data->stream);
-    stream_data->remaining -= datalen;
-    consume(&stream_data->buf, &stream_data->count, datalen);
     stream_data->stream->Commit(datalen);
     return true;
   }
@@ -545,34 +586,28 @@ class DefaultApplication final : public Session::Application {
 
  private:
   void ScheduleStream(int64_t id) {
-    Debug(&session(), "Default application scheduling stream %" PRIi64, id);
-    auto stream = session().FindStream(id);
-    if (stream && !stream->is_destroyed()) {
+    if (auto stream = session().FindStream(id)) [[likely]] {
       stream->Schedule(&stream_queue_);
     }
   }
 
   void UnscheduleStream(int64_t id) {
-    Debug(&session(), "Default application unscheduling stream %" PRIi64, id);
-    auto stream = session().FindStream(id);
-    if (stream && !stream->is_destroyed()) stream->Unschedule();
+    if (auto stream = session().FindStream(id)) [[likely]] {
+      stream->Unschedule();
+    }
   }
 
   Stream::Queue stream_queue_;
 };
 
-std::unique_ptr<Session::Application> Session::select_application() {
-  // In the future, we may end up supporting additional QUIC protocols. As they
-  // are added, extend the cases here to create and return them.
-
-  if (config_.options.tls_options.alpn == NGHTTP3_ALPN_H3) {
-    Debug(this, "Selecting HTTP/3 application");
-    return createHttp3Application(this, config_.options.application_options);
+std::unique_ptr<Session::Application> Session::SelectApplication(
+    Session* session, const Session::Config& config) {
+  if (config.options.application_provider) {
+    return config.options.application_provider->Create(session);
   }
 
-  Debug(this, "Selecting default application");
   return std::make_unique<DefaultApplication>(
-      this, config_.options.application_options);
+      session, Session::Application_Options::kDefault);
 }
 
 }  // namespace quic
diff --git a/src/quic/application.h b/src/quic/application.h
index 79b9941f62b2b4..346180229322a5 100644
--- a/src/quic/application.h
+++ b/src/quic/application.h
@@ -1,9 +1,9 @@
 #pragma once
 
-#include "quic/defs.h"
 #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
 #if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC
 
+#include "base_object.h"
 #include "bindingdata.h"
 #include "defs.h"
 #include "session.h"
@@ -27,14 +27,15 @@ class Session::Application : public MemoryRetainer {
   // Application. The only additional processing the Session does is to
   // automatically adjust the session-level flow control window. It is up to
   // the Application to do the same for the Stream-level flow control.
-  virtual bool ReceiveStreamData(Stream* stream,
+  virtual bool ReceiveStreamData(int64_t stream_id,
                                  const uint8_t* data,
                                  size_t datalen,
-                                 Stream::ReceiveDataFlags flags) = 0;
+                                 const Stream::ReceiveDataFlags& flags,
+                                 void* stream_user_data) = 0;
 
   // Session will forward all data acknowledgements for a stream to the
   // Application.
-  virtual void AcknowledgeStreamData(Stream* stream, size_t datalen);
+  virtual bool AcknowledgeStreamData(int64_t stream_id, size_t datalen);
 
   // Called to determine if a Header can be added to this application.
   // Applications that do not support headers will always return false.
@@ -78,15 +79,16 @@ class Session::Application : public MemoryRetainer {
       SessionTicket::AppData::Source::Flag flag);
 
   // Notifies the Application that the identified stream has been closed.
-  virtual void StreamClose(Stream* stream, QuicError error = QuicError());
+  virtual void StreamClose(Stream* stream, QuicError&& error = QuicError());
 
   // Notifies the Application that the identified stream has been reset.
   virtual void StreamReset(Stream* stream,
                            uint64_t final_size,
-                           QuicError error);
+                           QuicError&& error = QuicError());
 
   // Notifies the Application that the identified stream should stop sending.
-  virtual void StreamStopSending(Stream* stream, QuicError error);
+  virtual void StreamStopSending(Stream* stream,
+                                 QuicError&& error = QuicError());
 
   // Submits an outbound block of headers for the given stream. Not all
   // Application types will support headers, in which case this function
@@ -124,7 +126,7 @@ class Session::Application : public MemoryRetainer {
   inline const Session& session() const { return *session_; }
 
  private:
-  Packet* CreateStreamDataPacket();
+  BaseObjectPtr<Packet> CreateStreamDataPacket();
 
   // Write the given stream_data into the buffer.
   ssize_t WriteVStream(PathStorage* path,
@@ -145,10 +147,14 @@ struct Session::Application::StreamData final {
   int64_t id = -1;
   int fin = 0;
   ngtcp2_vec data[kMaxVectorCount]{};
-  ngtcp2_vec* buf = data;
   BaseObjectPtr<Stream> stream;
 
-  inline operator nghttp3_vec() const { return {data[0].base, data[0].len}; }
+  inline operator nghttp3_vec*() {
+    return reinterpret_cast<nghttp3_vec*>(data);
+  }
+
+  inline operator const ngtcp2_vec*() const { return data; }
+  inline operator ngtcp2_vec*() { return data; }
 
   std::string ToString() const;
 };
diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h
index cbc8c9436de928..9d8ac0c6fb1f6d 100644
--- a/src/quic/bindingdata.h
+++ b/src/quic/bindingdata.h
@@ -30,7 +30,8 @@ class Packet;
   V(packet)                                                                    \
   V(session)                                                                   \
   V(stream)                                                                    \
-  V(udp)
+  V(udp)                                                                       \
+  V(http3application)
 
 // The callbacks are persistent v8::Function references that are set in the
 // quic::BindingState used to communicate data and events back out to the JS
@@ -60,8 +61,7 @@ class Packet;
   V(ack_delay_exponent, "ackDelayExponent")                                    \
   V(active_connection_id_limit, "activeConnectionIDLimit")                     \
   V(address_lru_size, "addressLRUSize")                                        \
-  V(alpn, "alpn")                                                              \
-  V(application_options, "application")                                        \
+  V(application_provider, "provider")                                          \
   V(bbr, "bbr")                                                                \
   V(ca, "ca")                                                                  \
   V(certs, "certs")                                                            \
@@ -69,7 +69,6 @@ class Packet;
   V(crl, "crl")                                                                \
   V(ciphers, "ciphers")                                                        \
   V(cubic, "cubic")                                                            \
-  V(disable_active_migration, "disableActiveMigration")                        \
   V(disable_stateless_reset, "disableStatelessReset")                          \
   V(enable_connect_protocol, "enableConnectProtocol")                          \
   V(enable_datagrams, "enableDatagrams")                                       \
@@ -80,6 +79,7 @@ class Packet;
   V(groups, "groups")                                                          \
   V(handshake_timeout, "handshakeTimeout")                                     \
   V(http3_alpn, &NGHTTP3_ALPN_H3[1])                                           \
+  V(http3application, "Http3Application")                                      \
   V(initial_max_data, "initialMaxData")                                        \
   V(initial_max_stream_data_bidi_local, "initialMaxStreamDataBidiLocal")       \
   V(initial_max_stream_data_bidi_remote, "initialMaxStreamDataBidiRemote")     \
@@ -105,9 +105,9 @@ class Packet;
   V(max_stream_window, "maxStreamWindow")                                      \
   V(max_window, "maxWindow")                                                   \
   V(min_version, "minVersion")                                                 \
-  V(no_udp_payload_size_shaping, "noUdpPayloadSizeShaping")                    \
   V(packetwrap, "PacketWrap")                                                  \
   V(preferred_address_strategy, "preferredAddressPolicy")                      \
+  V(protocol, "protocol")                                                      \
   V(qlog, "qlog")                                                              \
   V(qpack_blocked_streams, "qpackBlockedStreams")                              \
   V(qpack_encoder_max_dtable_capacity, "qpackEncoderMaxDTableCapacity")        \
@@ -117,8 +117,8 @@ class Packet;
   V(retry_token_expiration, "retryTokenExpiration")                            \
   V(reset_token_secret, "resetTokenSecret")                                    \
   V(rx_loss, "rxDiagnosticLoss")                                               \
+  V(servername, "servername")                                                  \
   V(session, "Session")                                                        \
-  V(sni, "sni")                                                                \
   V(stream, "Stream")                                                          \
   V(success, "success")                                                        \
   V(tls_options, "tls")                                                        \
@@ -169,7 +169,7 @@ class BindingData final
   // bridge out to the JS API.
   static void SetCallbacks(const v8::FunctionCallbackInfo<v8::Value>& args);
 
-  std::vector<Packet*> packet_freelist;
+  std::vector<BaseObjectPtr<BaseObject>> packet_freelist;
 
   std::unordered_map<Endpoint*, BaseObjectPtr<BaseObject>> listening_endpoints;
 
diff --git a/src/quic/cid.cc b/src/quic/cid.cc
index fdc636145210b2..1b5fdd861b7a9a 100644
--- a/src/quic/cid.cc
+++ b/src/quic/cid.cc
@@ -20,14 +20,12 @@ CID::CID() : ptr_(&cid_) {
 CID::CID(const ngtcp2_cid& cid) : CID(cid.data, cid.datalen) {}
 
 CID::CID(const uint8_t* data, size_t len) : CID() {
-  DCHECK_GE(len, kMinLength);
   DCHECK_LE(len, kMaxLength);
   ngtcp2_cid_init(&cid_, data, len);
 }
 
 CID::CID(const ngtcp2_cid* cid) : ptr_(cid) {
   CHECK_NOT_NULL(cid);
-  DCHECK_GE(cid->datalen, kMinLength);
   DCHECK_LE(cid->datalen, kMaxLength);
 }
 
diff --git a/src/quic/data.cc b/src/quic/data.cc
index e3dd40605228f4..06120dd69591b1 100644
--- a/src/quic/data.cc
+++ b/src/quic/data.cc
@@ -257,6 +257,14 @@ std::optional<int> QuicError::crypto_error() const {
 }
 
 MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
+  if ((type() == QuicError::Type::TRANSPORT && code() == NGTCP2_NO_ERROR) ||
+      (type() == QuicError::Type::APPLICATION &&
+       code() == NGTCP2_APP_NOERROR) ||
+      (type() == QuicError::Type::APPLICATION &&
+       code() == NGHTTP3_H3_NO_ERROR)) {
+    return Undefined(env->isolate());
+  }
+
   Local<Value> argv[] = {
       Integer::New(env->isolate(), static_cast<int>(type())),
       BigInt::NewFromUnsigned(env->isolate(), code()),
diff --git a/src/quic/defs.h b/src/quic/defs.h
index 628b2b754a36a5..8c97d30d26f77f 100644
--- a/src/quic/defs.h
+++ b/src/quic/defs.h
@@ -212,6 +212,15 @@ enum class DatagramStatus : uint8_t {
   LOST,
 };
 
+#define CC_ALGOS(V)                                                            \
+  V(RENO, reno)                                                                \
+  V(CUBIC, cubic)                                                              \
+  V(BBR, bbr)
+
+#define V(name, _) static constexpr auto CC_ALGO_##name = NGTCP2_CC_ALGO_##name;
+CC_ALGOS(V)
+#undef V
+
 constexpr uint64_t NGTCP2_APP_NOERROR = 65280;
 constexpr size_t kDefaultMaxPacketLength = NGTCP2_MAX_UDP_PAYLOAD_SIZE;
 constexpr size_t kMaxSizeT = std::numeric_limits<size_t>::max();
diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc
index f116534a283ab1..bff3ced8a2b8ab 100644
--- a/src/quic/endpoint.cc
+++ b/src/quic/endpoint.cc
@@ -19,6 +19,7 @@
 #include "application.h"
 #include "bindingdata.h"
 #include "defs.h"
+#include "http3.h"
 #include "ncrypto.h"
 
 namespace node {
@@ -28,7 +29,6 @@ using v8::BackingStore;
 using v8::FunctionCallbackInfo;
 using v8::FunctionTemplate;
 using v8::HandleScope;
-using v8::Int32;
 using v8::Integer;
 using v8::Just;
 using v8::Local;
@@ -93,65 +93,7 @@ bool is_diagnostic_packet_loss(double probability) {
   CHECK(ncrypto::CSPRNG(&c, 1));
   return (static_cast<double>(c) / 255) < probability;
 }
-#endif  // DEBUG
-
-Maybe<ngtcp2_cc_algo> getAlgoFromString(Environment* env, Local<String> input) {
-  auto& state = BindingData::Get(env);
-#define V(name, str)                                                           \
-  if (input->StringEquals(state.str##_string())) {                             \
-    return Just(NGTCP2_CC_ALGO_##name);                                        \
-  }
-
-  ENDPOINT_CC(V)
-
-#undef V
-  return Nothing<ngtcp2_cc_algo>();
-}
-
-template <typename Opt, ngtcp2_cc_algo Opt::*member>
-bool SetOption(Environment* env,
-               Opt* options,
-               const Local<Object>& object,
-               const Local<String>& name) {
-  Local<Value> value;
-  if (!object->Get(env->context(), name).ToLocal(&value)) return false;
-  if (!value->IsUndefined()) {
-    ngtcp2_cc_algo algo;
-    if (value->IsString()) {
-      if (!getAlgoFromString(env, value.As<String>()).To(&algo)) {
-        THROW_ERR_INVALID_ARG_VALUE(env, "The cc_algorithm option is invalid");
-        return false;
-      }
-    } else {
-      if (!value->IsInt32()) {
-        THROW_ERR_INVALID_ARG_VALUE(
-            env, "The cc_algorithm option must be a string or an integer");
-        return false;
-      }
-      Local<Int32> num;
-      if (!value->ToInt32(env->context()).ToLocal(&num)) {
-        THROW_ERR_INVALID_ARG_VALUE(env, "The cc_algorithm option is invalid");
-        return false;
-      }
-      switch (num->Value()) {
-#define V(name, _)                                                             \
-  case NGTCP2_CC_ALGO_##name:                                                  \
-    break;
-        ENDPOINT_CC(V)
-#undef V
-        default:
-          THROW_ERR_INVALID_ARG_VALUE(env,
-                                      "The cc_algorithm option is invalid");
-          return false;
-      }
-      algo = static_cast<ngtcp2_cc_algo>(num->Value());
-    }
-    options->*member = algo;
-  }
-  return true;
-}
 
-#if DEBUG
 template <typename Opt, double Opt::*member>
 bool SetOption(Environment* env,
                Opt* options,
@@ -251,17 +193,13 @@ Maybe<Endpoint::Options> Endpoint::Options::From(Environment* env,
   if (!SET(retry_token_expiration) || !SET(token_expiration) ||
       !SET(max_connections_per_host) || !SET(max_connections_total) ||
       !SET(max_stateless_resets) || !SET(address_lru_size) ||
-      !SET(max_retries) || !SET(max_payload_size) ||
-      !SET(unacknowledged_packet_threshold) || !SET(validate_address) ||
+      !SET(max_retries) || !SET(validate_address) ||
       !SET(disable_stateless_reset) || !SET(ipv6_only) ||
-      !SET(handshake_timeout) || !SET(max_stream_window) || !SET(max_window) ||
-      !SET(no_udp_payload_size_shaping) ||
 #ifdef DEBUG
       !SET(rx_loss) || !SET(tx_loss) ||
 #endif
-      !SET(cc_algorithm) || !SET(udp_receive_buffer_size) ||
-      !SET(udp_send_buffer_size) || !SET(udp_ttl) || !SET(reset_token_secret) ||
-      !SET(token_secret)) {
+      !SET(udp_receive_buffer_size) || !SET(udp_send_buffer_size) ||
+      !SET(udp_ttl) || !SET(reset_token_secret) || !SET(token_secret)) {
     return Nothing<Options>();
   }
 
@@ -317,19 +255,6 @@ std::string Endpoint::Options::ToString() const {
       prefix + "max stateless resets: " + std::to_string(max_stateless_resets);
   res += prefix + "address lru size: " + std::to_string(address_lru_size);
   res += prefix + "max retries: " + std::to_string(max_retries);
-  res += prefix + "max payload size: " + std::to_string(max_payload_size);
-  res += prefix + "unacknowledged packet threshold: " +
-         std::to_string(unacknowledged_packet_threshold);
-  if (handshake_timeout == UINT64_MAX) {
-    res += prefix + "handshake timeout: <none>";
-  } else {
-    res += prefix + "handshake timeout: " + std::to_string(handshake_timeout) +
-           " nanoseconds";
-  }
-  res += prefix + "max stream window: " + std::to_string(max_stream_window);
-  res += prefix + "max window: " + std::to_string(max_window);
-  res += prefix + "no udp payload size shaping: " +
-         boolToString(no_udp_payload_size_shaping);
   res += prefix + "validate address: " + boolToString(validate_address);
   res += prefix +
          "disable stateless reset: " + boolToString(disable_stateless_reset);
@@ -337,18 +262,6 @@ std::string Endpoint::Options::ToString() const {
   res += prefix + "rx loss: " + std::to_string(rx_loss);
   res += prefix + "tx loss: " + std::to_string(tx_loss);
 #endif
-
-  auto ccalg = ([&] {
-    switch (cc_algorithm) {
-#define V(name, label)                                                         \
-  case NGTCP2_CC_ALGO_##name:                                                  \
-    return #label;
-      ENDPOINT_CC(V)
-#undef V
-    }
-    return "<unknown>";
-  })();
-  res += prefix + "cc algorithm: " + std::string(ccalg);
   res += prefix + "reset token secret: " + reset_token_secret.ToString();
   res += prefix + "token secret: " + token_secret.ToString();
   res += prefix + "ipv6 only: " + boolToString(ipv6_only);
@@ -453,6 +366,10 @@ class Endpoint::UDP::Impl final : public HandleWrap {
 
 Endpoint::UDP::UDP(Endpoint* endpoint) : impl_(Impl::Create(endpoint)) {
   DCHECK(impl_);
+  // The endpoint starts in an inactive, unref'd state. It will be ref'd when
+  // the endpoint is either configured to listen as a server or when then are
+  // active client sessions.
+  Unref();
 }
 
 Endpoint::UDP::~UDP() {
@@ -553,31 +470,33 @@ SocketAddress Endpoint::UDP::local_address() const {
   return SocketAddress::FromSockName(impl_->handle_);
 }
 
-int Endpoint::UDP::Send(Packet* packet) {
+int Endpoint::UDP::Send(const BaseObjectPtr<Packet>& packet) {
+  DCHECK(packet);
+  DCHECK(!packet->IsDispatched());
   if (is_closed_or_closing()) return UV_EBADF;
-  DCHECK_NOT_NULL(packet);
   uv_buf_t buf = *packet;
 
   // We don't use the default implementation of Dispatch because the packet
   // itself is going to be reset and added to a freelist to be reused. The
   // default implementation of Dispatch will cause the packet to be deleted,
-  // which we don't want. We call ClearWeak here just to be doubly sure.
+  // which we don't want.
   packet->ClearWeak();
   packet->Dispatched();
-  int err = uv_udp_send(
-      packet->req(),
-      &impl_->handle_,
-      &buf,
-      1,
-      packet->destination().data(),
-      uv_udp_send_cb{[](uv_udp_send_t* req, int status) {
-        auto ptr = static_cast<Packet*>(ReqWrap<uv_udp_send_t>::from_req(req));
-        ptr->env()->DecreaseWaitingRequestCounter();
-        ptr->Done(status);
-      }});
+  int err = uv_udp_send(packet->req(),
+                        &impl_->handle_,
+                        &buf,
+                        1,
+                        packet->destination().data(),
+                        uv_udp_send_cb{[](uv_udp_send_t* req, int status) {
+                          auto ptr = BaseObjectPtr<Packet>(static_cast<Packet*>(
+                              ReqWrap<uv_udp_send_t>::from_req(req)));
+                          ptr->env()->DecreaseWaitingRequestCounter();
+                          ptr->Done(status);
+                        }});
   if (err < 0) {
     // The packet failed.
     packet->Done(err);
+    packet->MakeWeak();
   } else {
     packet->env()->IncreaseWaitingRequestCounter();
   }
@@ -617,15 +536,10 @@ Local<FunctionTemplate> Endpoint::GetConstructorTemplate(Environment* env) {
 
 void Endpoint::InitPerIsolate(IsolateData* data, Local<ObjectTemplate> target) {
   // TODO(@jasnell): Implement the per-isolate state
+  Http3Application::InitPerIsolate(data, target);
 }
 
 void Endpoint::InitPerContext(Realm* realm, Local<Object> target) {
-#define V(name, str)                                                           \
-  NODE_DEFINE_CONSTANT(target, CC_ALGO_##name);                                \
-  NODE_DEFINE_STRING_CONSTANT(target, "CC_ALGO_" #name "_STR", #str);
-  ENDPOINT_CC(V)
-#undef V
-
 #define V(name, _) IDX_STATS_ENDPOINT_##name,
   enum IDX_STATS_ENDPOINT { ENDPOINT_STATS(V) IDX_STATS_ENDPOINT_COUNT };
   NODE_DEFINE_CONSTANT(target, IDX_STATS_ENDPOINT_COUNT);
@@ -678,6 +592,8 @@ void Endpoint::InitPerContext(Realm* realm, Local<Object> target) {
   NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_SEND_FAILURE);
   NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_START_FAILURE);
 
+  Http3Application::InitPerContext(realm, target);
+
   SetConstructorFunction(realm->context(),
                          target,
                          "Endpoint",
@@ -704,6 +620,7 @@ Endpoint::Endpoint(Environment* env,
       udp_(this),
       addrLRU_(options_.address_lru_size) {
   MakeWeak();
+  udp_.Unref();
   STAT_RECORD_TIMESTAMP(Stats, created_at);
   IF_QUIC_DEBUG(env) {
     Debug(this, "Endpoint created. Options %s", options.ToString());
@@ -733,64 +650,71 @@ void Endpoint::MarkAsBusy(bool on) {
 
 RegularToken Endpoint::GenerateNewToken(uint32_t version,
                                         const SocketAddress& remote_address) {
-  IF_QUIC_DEBUG(env()) {
-    Debug(this,
-          "Generating new regular token for version %u and remote address %s",
-          version,
-          remote_address);
-  }
+  Debug(this,
+        "Generating new regular token for version %u and remote address %s",
+        version,
+        remote_address);
   DCHECK(!is_closed() && !is_closing());
   return RegularToken(version, remote_address, options_.token_secret);
 }
 
 StatelessResetToken Endpoint::GenerateNewStatelessResetToken(
     uint8_t* token, const CID& cid) const {
-  IF_QUIC_DEBUG(env()) {
-    Debug(const_cast<Endpoint*>(this),
-          "Generating new stateless reset token for CID %s",
-          cid);
-  }
+  Debug(const_cast<Endpoint*>(this),
+        "Generating new stateless reset token for CID %s",
+        cid);
   DCHECK(!is_closed() && !is_closing());
   return StatelessResetToken(token, options_.reset_token_secret, cid);
 }
 
 void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> session) {
-  if (is_closed() || is_closing()) return;
+  DCHECK(!is_closed() && !is_closing());
   Debug(this, "Adding session for CID %s", cid);
-  sessions_[cid] = session;
   IncrementSocketAddressCounter(session->remote_address());
+  AssociateCID(session->config().dcid, session->config().scid);
+  sessions_[cid] = session;
   if (session->is_server()) {
     STAT_INCREMENT(Stats, server_sessions);
+    // We only emit the new session event for server sessions.
     EmitNewSession(session);
+    // It is important to note that the session may be closed/destroyed
+    // when it is emitted here.
   } else {
     STAT_INCREMENT(Stats, client_sessions);
   }
+  udp_.Ref();
 }
 
-void Endpoint::RemoveSession(const CID& cid) {
+void Endpoint::RemoveSession(const CID& cid,
+                             const SocketAddress& remote_address) {
   if (is_closed()) return;
   Debug(this, "Removing session for CID %s", cid);
-  auto session = FindSession(cid);
-  if (!session) return;
-  DecrementSocketAddressCounter(session->remote_address());
-  sessions_.erase(cid);
+  if (sessions_.erase(cid)) {
+    DecrementSocketAddressCounter(remote_address);
+  }
+  if (sessions_.empty()) {
+    udp_.Unref();
+  }
   if (state_->closing == 1) MaybeDestroy();
 }
 
 BaseObjectPtr<Session> Endpoint::FindSession(const CID& cid) {
-  BaseObjectPtr<Session> session;
   auto session_it = sessions_.find(cid);
   if (session_it == std::end(sessions_)) {
+    // If our given cid is not a match that doesn't mean we
+    // give up. A session might be identified by multiple
+    // CIDs. Let's see if our secondary map has a match!
     auto scid_it = dcid_to_scid_.find(cid);
     if (scid_it != std::end(dcid_to_scid_)) {
       session_it = sessions_.find(scid_it->second);
       CHECK_NE(session_it, std::end(sessions_));
-      session = session_it->second;
+      return session_it->second;
     }
-  } else {
-    session = session_it->second;
+    // No match found.
+    return {};
   }
-  return session;
+  // Match found!
+  return session_it->second;
 }
 
 void Endpoint::AssociateCID(const CID& cid, const CID& scid) {
@@ -823,8 +747,7 @@ void Endpoint::DisassociateStatelessResetToken(
   }
 }
 
-void Endpoint::Send(Packet* packet) {
-  CHECK_NOT_NULL(packet);
+void Endpoint::Send(const BaseObjectPtr<Packet>& packet) {
 #ifdef DEBUG
   // When diagnostic packet loss is enabled, the packet will be randomly
   // dropped. This can happen to any type of packet. We use this only in
@@ -836,11 +759,13 @@ void Endpoint::Send(Packet* packet) {
   }
 #endif  // DEBUG
 
-  if (is_closed() || is_closing() || packet->length() == 0) return;
+  if (is_closed() || is_closing() || packet->length() == 0) {
+    packet->Done(UV_ECANCELED);
+    return;
+  }
   Debug(this, "Sending %s", packet->ToString());
   state_->pending_callbacks++;
   int err = udp_.Send(packet);
-
   if (err != 0) {
     Debug(this, "Sending packet failed with error %d", err);
     packet->Done(err);
@@ -868,6 +793,7 @@ void Endpoint::SendRetry(const PathDescriptor& options) {
     if (packet) {
       STAT_INCREMENT(Stats, retry_count);
       Send(std::move(packet));
+      packet.reset();
     }
 
     // If creating the retry is unsuccessful, we just drop things on the floor.
@@ -889,6 +815,7 @@ void Endpoint::SendVersionNegotiation(const PathDescriptor& options) {
   if (packet) {
     STAT_INCREMENT(Stats, version_negotiation_count);
     Send(std::move(packet));
+    packet.reset();
   }
 
   // If creating the packet is unsuccessful, we just drop things on the floor.
@@ -924,6 +851,7 @@ bool Endpoint::SendStatelessReset(const PathDescriptor& options,
     addrLRU_.Upsert(options.remote_address)->reset_count++;
     STAT_INCREMENT(Stats, stateless_reset_count);
     Send(std::move(packet));
+    packet.reset();
     return true;
   }
   return false;
@@ -942,6 +870,7 @@ void Endpoint::SendImmediateConnectionClose(const PathDescriptor& options,
   if (packet) {
     STAT_INCREMENT(Stats, immediate_close_count);
     Send(std::move(packet));
+    packet.reset();
   }
 }
 
@@ -965,6 +894,7 @@ bool Endpoint::Start() {
   }
 
   err = udp_.Start();
+  udp_.Ref();
   if (err != 0) {
     // If we failed to start listening, destroy the endpoint. There's nothing we
     // can do.
@@ -1015,41 +945,42 @@ BaseObjectPtr<Session> Endpoint::Connect(
     const Session::Options& options,
     std::optional<SessionTicket> session_ticket) {
   // If starting fails, the endpoint will be destroyed.
-  if (!Start()) return BaseObjectPtr<Session>();
+  if (!Start()) return {};
 
-  Session::Config config(*this, options, local_address(), remote_address);
+  Session::Config config(env(), options, local_address(), remote_address);
 
-  IF_QUIC_DEBUG(env()) {
-    Debug(
-        this,
+  Debug(this,
         "Connecting to %s with options %s and config %s [has 0rtt ticket? %s]",
         remote_address,
         options,
         config,
         session_ticket.has_value() ? "yes" : "no");
-  }
 
   auto tls_context = TLSContext::CreateClient(options.tls_options);
   if (!*tls_context) {
     THROW_ERR_INVALID_STATE(env(),
                             "Failed to create TLS context: %s",
                             tls_context->validation_error());
-    return BaseObjectPtr<Session>();
+    return {};
   }
   auto session =
       Session::Create(this, config, tls_context.get(), session_ticket);
+  if (!session) {
+    THROW_ERR_INVALID_STATE(env(), "Failed to create session");
+    return {};
+  }
   if (!session->tls_session()) {
     THROW_ERR_INVALID_STATE(env(),
                             "Failed to create TLS session: %s",
                             session->tls_session().validation_error());
-    return BaseObjectPtr<Session>();
+    return {};
   }
-  if (!session) return BaseObjectPtr<Session>();
-  session->set_wrapped();
 
-  // Calling SendPendingData here triggers the session to send the initial
-  // handshake packets starting the connection.
-  session->application().SendPendingData();
+  // Marking a session as "wrapped" means that the reference has been
+  // (or will be) passed out to JavaScript.
+  Session::SendPendingDataScope send_scope(session);
+  session->set_wrapped();
+  AddSession(config.scid, session);
   return session;
 }
 
@@ -1139,8 +1070,8 @@ void Endpoint::Receive(const uv_buf_t& buf,
                            const CID& dcid,
                            const CID& scid) {
     DCHECK_NOT_NULL(session);
+    DCHECK(!session->is_destroyed());
     size_t len = store.length();
-    Debug(this, "Passing received packet to session for processing");
     if (session->Receive(std::move(store), local_address, remote_address)) {
       STAT_INCREMENT_N(Stats, bytes_received, len);
       STAT_INCREMENT(Stats, packets_received);
@@ -1157,21 +1088,31 @@ void Endpoint::Receive(const uv_buf_t& buf,
     std::optional<SessionTicket> no_ticket = std::nullopt;
     auto session = Session::Create(
         this, config, server_state_->tls_context.get(), no_ticket);
-    if (session) {
-      if (!session->tls_session()) {
-        Debug(this,
-              "Failed to create TLS session for %s: %s",
-              config.dcid,
-              session->tls_session().validation_error());
-        return;
-      }
-      receive(session.get(),
-              std::move(store),
-              config.local_address,
-              config.remote_address,
-              config.dcid,
-              config.scid);
+    if (!session) {
+      Debug(this, "Failed to create session for %s", config.dcid);
+      return;
+    }
+    if (!session->tls_session()) {
+      Debug(this,
+            "Failed to create TLS session for %s: %s",
+            config.dcid,
+            session->tls_session().validation_error());
+      return;
     }
+
+    AddSession(config.scid, session);
+    // It is possible that the session was created then immediately destroyed
+    // during the call to AddSession. If that's the case, we'll just return
+    // early.
+    if (session->is_destroyed()) [[unlikely]]
+      return;
+
+    receive(session.get(),
+            std::move(store),
+            config.local_address,
+            config.remote_address,
+            config.dcid,
+            config.scid);
   };
 
   const auto acceptInitialPacket = [&](const uint32_t version,
@@ -1180,26 +1121,19 @@ void Endpoint::Receive(const uv_buf_t& buf,
                                        Store&& store,
                                        const SocketAddress& local_address,
                                        const SocketAddress& remote_address) {
-    // Conditionally accept an initial packet to create a new session.
-    Debug(this,
-          "Trying to accept initial packet for %s from %s",
-          dcid,
-          remote_address);
-
     // If we're not listening as a server, do not accept an initial packet.
-    if (state_->listening == 0) return;
+    if (!is_listening()) return;
 
     ngtcp2_pkt_hd hd;
 
     // This is our first condition check... A minimal check to see if ngtcp2 can
-    // even recognize this packet as a quic packet with the correct version.
+    // even recognize this packet as a quic packet.
     ngtcp2_vec vec = store;
     if (ngtcp2_accept(&hd, vec.base, vec.len) != NGTCP2_SUCCESS) {
       // Per the ngtcp2 docs, ngtcp2_accept returns 0 if the check was
       // successful, or an error code if it was not. Currently there's only one
       // documented error code (NGTCP2_ERR_INVALID_ARGUMENT) but we'll handle
       // any error here the same -- by ignoring the packet entirely.
-      Debug(this, "Failed to accept initial packet from %s", remote_address);
       return;
     }
 
@@ -1208,10 +1142,13 @@ void Endpoint::Receive(const uv_buf_t& buf,
     // version negotiation packet in response.
     if (ngtcp2_is_supported_version(hd.version) == 0) {
       Debug(this,
-            "Packet was not accepted because the version (%d) is not supported",
+            "Packet not acceptable because the version (%d) is not supported. "
+            "Will attempt to send version negotiation",
             hd.version);
       SendVersionNegotiation(
           PathDescriptor{version, dcid, scid, local_address, remote_address});
+      // The packet was successfully processed, even if we did refuse the
+      // connection.
       STAT_INCREMENT(Stats, packets_received);
       return;
     }
@@ -1247,23 +1184,27 @@ void Endpoint::Receive(const uv_buf_t& buf,
       return;
     }
 
+    Debug(
+        this, "Accepting initial packet for %s from %s", dcid, remote_address);
+
     // At this point, we start to set up the configuration for our local
     // session. We pass the received scid here as the dcid argument value
     // because that is the value *this* session will use as the outbound dcid.
-    Session::Config config(Side::SERVER,
-                           *this,
+    Session::Config config(env(),
+                           Side::SERVER,
                            server_state_->options,
                            version,
                            local_address,
                            remote_address,
                            scid,
+                           dcid,
                            dcid);
 
-    Debug(this, "Using session config for initial packet %s", config);
+    Debug(this, "Using session config %s", config);
 
     // The this point, the config.scid and config.dcid represent *our* views of
     // the CIDs. Specifically, config.dcid identifies the peer and config.scid
-    // identifies us. config.dcid should equal scid. config.scid should *not*
+    // identifies us. config.dcid should equal scid, and config.scid should
     // equal dcid.
     DCHECK(config.dcid == scid);
     DCHECK(config.scid == dcid);
@@ -1292,6 +1233,19 @@ void Endpoint::Receive(const uv_buf_t& buf,
                     "Initial packet has no token. Sending retry to %s to start "
                     "validation",
                     remote_address);
+              // In this case we sent a retry to the remote peer and return
+              // without creating a session. What we expect to happen next is
+              // that the remote peer will try again with a new initial packet
+              // that includes the retry token we are sending them. It's
+              // possible, however, that they just give up and go away or send
+              // us another initial packet that does not have the token. In that
+              // case we'll end up right back here asking them to validate
+              // again.
+              //
+              // It is possible that the SendRetry(...) won't actually send a
+              // retry if the remote address has exceeded the maximum number of
+              // retry attempts it is allowed as tracked by the addressLRU
+              // cache. In that case, we'll just drop the packet on the floor.
               SendRetry(PathDescriptor{
                   version,
                   dcid,
@@ -1305,8 +1259,8 @@ void Endpoint::Receive(const uv_buf_t& buf,
               return;
             }
 
-            // We have two kinds of tokens, each prefixed with a different magic
-            // byte.
+            // We have two kinds of tokens, each prefixed with a different
+            // magic byte.
             switch (hd.token[0]) {
               case RetryToken::kTokenMagic: {
                 RetryToken token(hd.token, hd.tokenlen);
@@ -1387,7 +1341,10 @@ void Endpoint::Receive(const uv_buf_t& buf,
                 // If our prefix bit does not match anything we know about,
                 // let's send a retry to be lenient. There's a small risk that a
                 // malicious peer is trying to make us do some work but the risk
-                // is fairly low here.
+                // is fairly low here. The SendRetry will avoid sending a retry
+                // if the remote address has exceeded the maximum number of
+                // retry attempts it is allowed as tracked by the addressLRU
+                // cache.
                 SendRetry(PathDescriptor{
                     version,
                     dcid,
@@ -1484,12 +1441,16 @@ void Endpoint::Receive(const uv_buf_t& buf,
     // processed.
     auto it = token_map_.find(StatelessResetToken(vec.base));
     if (it != token_map_.end()) {
-      receive(it->second,
-              std::move(store),
-              local_address,
-              remote_address,
-              dcid,
-              scid);
+      // If the session happens to have been destroyed already, we'll
+      // just ignore the packet.
+      if (!it->second->is_destroyed()) [[likely]] {
+        receive(it->second,
+                std::move(store),
+                local_address,
+                remote_address,
+                dcid,
+                scid);
+      }
       return true;
     }
 
@@ -1512,10 +1473,7 @@ void Endpoint::Receive(const uv_buf_t& buf,
   //   return;
   // }
 
-  Debug(this,
-        "Received packet with length %" PRIu64 " from %s",
-        buf.len,
-        remote_address);
+  Debug(this, "Received %zu-byte packet from %s", buf.len, remote_address);
 
   // The managed buffer here contains the received packet. We do not yet know
   // at this point if it is a valid QUIC packet. We need to do some basic
@@ -1528,7 +1486,7 @@ void Endpoint::Receive(const uv_buf_t& buf,
     return Destroy(CloseContext::RECEIVE_FAILURE, UV_ENOMEM);
   }
 
-  Store store(backing, buf.len, 0);
+  Store store(std::move(backing), buf.len, 0);
 
   ngtcp2_vec vec = store;
   ngtcp2_version_cid pversion_cid;
@@ -1547,7 +1505,7 @@ void Endpoint::Receive(const uv_buf_t& buf,
   // QUIC currently requires CID lengths of max NGTCP2_MAX_CIDLEN. Ignore any
   // packet with a non-standard CID length.
   if (pversion_cid.dcidlen > NGTCP2_MAX_CIDLEN ||
-      pversion_cid.scidlen > NGTCP2_MAX_CIDLEN) [[unlikely]] {
+      pversion_cid.scidlen > NGTCP2_MAX_CIDLEN) {
     Debug(this, "Packet had incorrectly sized CIDs, ignoring");
     return;  // Ignore the packet!
   }
@@ -1582,7 +1540,6 @@ void Endpoint::Receive(const uv_buf_t& buf,
 
   auto session = FindSession(dcid);
   auto addr = local_address();
-
   HandleScope handle_scope(env()->isolate());
 
   // If a session is not found, there are four possible reasons:
@@ -1612,16 +1569,26 @@ void Endpoint::Receive(const uv_buf_t& buf,
                                remote_address);
   }
 
+  if (session->is_destroyed()) [[unlikely]] {
+    // The session has been destroyed. Well that's not good.
+    Debug(this, "Session for dcid %s has been destroyed", dcid);
+    return;
+  }
+
   // If we got here, the dcid matched the scid of a known local session. Yay!
   // The session will take over any further processing of the packet.
   Debug(this, "Dispatching packet to known session");
   receive(session.get(), std::move(store), addr, remote_address, dcid, scid);
+
+  // It is important to note that the session may have been destroyed during
+  // the call to receive(...). If that's the case, the session object still
+  // exists but it is in a destroyed state. Care should be taken accessing
+  // session after this point.
 }
 
 void Endpoint::PacketDone(int status) {
   if (is_closed()) return;
   // At this point we should be waiting on at least one packet.
-  Debug(this, "Packet was sent with status %d", status);
   DCHECK_GE(state_->pending_callbacks, 1);
   state_->pending_callbacks--;
   // Can we go ahead and close now?
@@ -1685,6 +1652,11 @@ void Endpoint::EmitNewSession(const BaseObjectPtr<Session>& session) {
 
   Debug(this, "Notifying JavaScript about new session");
   MakeCallback(BindingData::Get(env()).session_new_callback(), 1, &arg);
+
+  // It is important to note that the session may have been destroyed during
+  // the call to MakeCallback. If that's the case, the session object still
+  // exists but it is in a destroyed state. Care should be taken accessing
+  // session after this point.
 }
 
 void Endpoint::EmitClose(CloseContext context, int status) {
@@ -1735,7 +1707,7 @@ void Endpoint::DoConnect(const FunctionCallbackInfo<Value>& args) {
     return;
   }
 
-  BaseObjectPtr<Session> session;
+  BaseObjectWeakPtr<Session> session;
 
   if (!args[2]->IsUndefined()) {
     SessionTicket ticket;
diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h
index 194f7c3d84c33c..9cfd828c815f2b 100644
--- a/src/quic/endpoint.h
+++ b/src/quic/endpoint.h
@@ -19,11 +19,6 @@
 
 namespace node::quic {
 
-#define ENDPOINT_CC(V)                                                         \
-  V(RENO, reno)                                                                \
-  V(CUBIC, cubic)                                                              \
-  V(BBR, bbr)
-
 // An Endpoint encapsulates the UDP local port binding and is responsible for
 // sending and receiving QUIC packets. A single endpoint can act as both a QUIC
 // client and server simultaneously.
@@ -37,10 +32,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
   static constexpr uint64_t DEFAULT_MAX_STATELESS_RESETS = 10;
   static constexpr uint64_t DEFAULT_MAX_RETRY_LIMIT = 10;
 
-#define V(name, _) static constexpr auto CC_ALGO_##name = NGTCP2_CC_ALGO_##name;
-  ENDPOINT_CC(V)
-#undef V
-
   // Endpoint configuration options
   struct Options final : public MemoryRetainer {
     // The local socket address to which the UDP port will be bound. The port
@@ -95,30 +86,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
     // retries, so limiting them helps prevent a DOS vector.
     uint64_t max_retries = DEFAULT_MAX_RETRY_LIMIT;
 
-    // The max_payload_size is the maximum size of a serialized QUIC packet. It
-    // should always be set small enough to fit within a single MTU without
-    // fragmentation. The default is set by the QUIC specification at 1200. This
-    // value should not be changed unless you know for sure that the entire path
-    // supports a given MTU without fragmenting at any point in the path.
-    uint64_t max_payload_size = kDefaultMaxPacketLength;
-
-    // The unacknowledged_packet_threshold is the maximum number of
-    // unacknowledged packets that an ngtcp2 session will accumulate before
-    // sending an acknowledgement. Setting this to 0 uses the ngtcp2 defaults,
-    // which is what most will want. The value can be changed to fine tune some
-    // of the performance characteristics of the session. This should only be
-    // changed if you have a really good reason for doing so.
-    uint64_t unacknowledged_packet_threshold = 0;
-
-    // The amount of time (in milliseconds) that the endpoint will wait for the
-    // completion of the tls handshake.
-    uint64_t handshake_timeout = UINT64_MAX;
-
-    uint64_t max_stream_window = 0;
-    uint64_t max_window = 0;
-
-    bool no_udp_payload_size_shaping = true;
-
     // The validate_address parameter instructs the Endpoint to perform explicit
     // address validation using retry tokens. This is strongly recommended and
     // should only be disabled in trusted, closed environments as a performance
@@ -142,14 +109,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
     double tx_loss = 0.0;
 #endif  // DEBUG
 
-    // There are several common congestion control algorithms that ngtcp2 uses
-    // to determine how it manages the flow control window: RENO, CUBIC, and
-    // BBR. The details of how each works is not relevant here. The choice of
-    // which to use by default is arbitrary and we can choose whichever we'd
-    // like. Additional performance profiling will be needed to determine which
-    // is the better of the two for our needs.
-    ngtcp2_cc_algo cc_algorithm = CC_ALGO_CUBIC;
-
     // By default, when the endpoint is created, it will generate a
     // reset_token_secret at random. This is a secret used in generating
     // stateless reset tokens. In order for stateless reset to be effective,
@@ -197,6 +156,10 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
            v8::Local<v8::Object> object,
            const Endpoint::Options& options);
 
+  inline operator Packet::Listener*() {
+    return this;
+  }
+
   inline const Options& options() const {
     return options_;
   }
@@ -216,7 +179,7 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
                                                      const CID& cid) const;
 
   void AddSession(const CID& cid, BaseObjectPtr<Session> session);
-  void RemoveSession(const CID& cid);
+  void RemoveSession(const CID& cid, const SocketAddress& remote_address);
   BaseObjectPtr<Session> FindSession(const CID& cid);
 
   // A single session may be associated with multiple CIDs.
@@ -232,7 +195,7 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
                                     Session* session);
   void DisassociateStatelessResetToken(const StatelessResetToken& token);
 
-  void Send(Packet* packet);
+  void Send(const BaseObjectPtr<Packet>& packet);
 
   // Generates and sends a retry packet. This is terminal for the connection.
   // Retry packets are used to force explicit path validation by issuing a token
@@ -298,7 +261,7 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
     int Start();
     void Stop();
     void Close();
-    int Send(Packet* packet);
+    int Send(const BaseObjectPtr<Packet>& packet);
 
     // Returns the local UDP socket address to which we are bound,
     // or fail with an assert if we are not bound.
diff --git a/src/quic/http3.cc b/src/quic/http3.cc
index f6858521cd3283..6160596be1867b 100644
--- a/src/quic/http3.cc
+++ b/src/quic/http3.cc
@@ -17,16 +17,107 @@
 #include "session.h"
 #include "sessionticket.h"
 
-namespace node::quic {
-namespace {
+namespace node {
+
+using v8::FunctionCallbackInfo;
+using v8::FunctionTemplate;
+using v8::Local;
+using v8::Object;
+using v8::ObjectTemplate;
+using v8::Value;
+
+namespace quic {
+
+// ============================================================================
+
+bool Http3Application::HasInstance(Environment* env, Local<Value> value) {
+  return GetConstructorTemplate(env)->HasInstance(value);
+}
+
+Local<FunctionTemplate> Http3Application::GetConstructorTemplate(
+    Environment* env) {
+  auto& state = BindingData::Get(env);
+  auto tmpl = state.http3application_constructor_template();
+  if (tmpl.IsEmpty()) {
+    auto isolate = env->isolate();
+    tmpl = NewFunctionTemplate(isolate, New);
+    tmpl->SetClassName(state.http3application_string());
+    tmpl->InstanceTemplate()->SetInternalFieldCount(
+        Http3Application::kInternalFieldCount);
+    state.set_http3application_constructor_template(tmpl);
+  }
+  return tmpl;
+}
+
+void Http3Application::InitPerIsolate(IsolateData* isolate_data,
+                                      Local<ObjectTemplate> target) {
+  // TODO(@jasnell): Implement the per-isolate state
+}
+
+void Http3Application::InitPerContext(Realm* realm, Local<Object> target) {
+  SetConstructorFunction(realm->context(),
+                         target,
+                         "Http3Application",
+                         GetConstructorTemplate(realm->env()));
+}
+
+void Http3Application::RegisterExternalReferences(
+    ExternalReferenceRegistry* registry) {
+  registry->Register(New);
+}
+
+Http3Application::Http3Application(Environment* env,
+                                   Local<Object> object,
+                                   const Session::Application::Options& options)
+    : ApplicationProvider(env, object), options_(options) {
+  MakeWeak();
+}
+
+void Http3Application::New(const FunctionCallbackInfo<Value>& args) {
+  Environment* env = Environment::GetCurrent(args);
+  CHECK(args.IsConstructCall());
+
+  Local<Object> obj;
+  if (!GetConstructorTemplate(env)
+           ->InstanceTemplate()
+           ->NewInstance(env->context())
+           .ToLocal(&obj)) {
+    return;
+  }
+
+  Session::Application::Options options;
+  if (!args[0]->IsUndefined() &&
+      !Session::Application::Options::From(env, args[0]).To(&options)) {
+    return;
+  }
+
+  if (auto app = MakeBaseObject<Http3Application>(env, obj, options)) {
+    args.GetReturnValue().Set(app->object());
+  }
+}
+
+void Http3Application::MemoryInfo(MemoryTracker* tracker) const {
+  tracker->TrackField("options", options_);
+}
+
+std::string Http3Application::ToString() const {
+  DebugIndentScope indent;
+  auto prefix = indent.Prefix();
+  std::string res("{");
+  res += prefix + "options: " + options_.ToString();
+  res += indent.Close();
+  return res;
+}
+
+// ============================================================================
 
 struct Http3HeadersTraits {
-  typedef nghttp3_nv nv_t;
+  using nv_t = nghttp3_nv;
 };
 
 struct Http3RcBufferPointerTraits {
-  typedef nghttp3_rcbuf rcbuf_t;
-  typedef nghttp3_vec vector_t;
+  using rcbuf_t = nghttp3_rcbuf;
+  using vector_t = nghttp3_vec;
 
   static void inc(rcbuf_t* buf) {
     CHECK_NOT_NULL(buf);
@@ -76,10 +167,10 @@ struct Http3HeaderTraits {
 using Http3Header = NgHeader<Http3HeaderTraits>;
 
 // Implements the low-level HTTP/3 Application semantics.
-class Http3Application final : public Session::Application {
+class Http3ApplicationImpl final : public Session::Application {
  public:
-  Http3Application(Session* session,
-                   const Session::Application_Options& options)
+  Http3ApplicationImpl(Session* session,
+                       const Session::Application::Options& options)
       : Application(session, options),
         allocator_(BindingData::Get(env())),
         options_(options),
@@ -91,8 +182,9 @@ class Http3Application final : public Session::Application {
     CHECK(!started_);
     started_ = true;
     Debug(&session(), "Starting HTTP/3 application.");
+
     auto params = ngtcp2_conn_get_remote_transport_params(session());
-    if (params == nullptr) {
+    if (params == nullptr) [[unlikely]] {
       // The params are not available yet. Cannot start.
       Debug(&session(),
             "Cannot start HTTP/3 application yet. No remote transport params");
@@ -100,29 +192,67 @@ class Http3Application final : public Session::Application {
     }
 
     if (params->initial_max_streams_uni < 3) {
-      // If the initial max unidirectional stream limit is not at least three,
-      // we cannot actually use it since we need to create the control streams.
+      // HTTP3 requires 3 unidirectional control streams to be opened in each
+      // direction in additional to the bidirectional streams that are used to
+      // actually carry request and response payload back and forth.
+      // See:
+      // https://nghttp2.org/nghttp3/programmers-guide.html#binding-control-streams
       Debug(&session(),
             "Cannot start HTTP/3 application. Initial max "
-            "unidirectional streams is too low");
+            "unidirectional streams [%zu] is too low. Must be at least 3",
+            params->initial_max_streams_uni);
       return false;
     }
 
+    // If this is a server session, then set the maximum number of
+    // bidirectional streams that can be created. This determines the number
+    // of requests that the client can actually created.
     if (session().is_server()) {
       nghttp3_conn_set_max_client_streams_bidi(
           *this, params->initial_max_streams_bidi);
     }
 
-    return CreateAndBindControlStreams();
+    Debug(&session(), "Creating and binding HTTP/3 control streams");
+    bool ret =
+        ngtcp2_conn_open_uni_stream(session(), &control_stream_id_, nullptr) ==
+            0 &&
+        ngtcp2_conn_open_uni_stream(
+            session(), &qpack_enc_stream_id_, nullptr) == 0 &&
+        ngtcp2_conn_open_uni_stream(
+            session(), &qpack_dec_stream_id_, nullptr) == 0 &&
+        nghttp3_conn_bind_control_stream(*this, control_stream_id_) == 0 &&
+        nghttp3_conn_bind_qpack_streams(
+            *this, qpack_enc_stream_id_, qpack_dec_stream_id_) == 0;
+
+    if (env()->enabled_debug_list()->enabled(DebugCategory::QUIC) && ret) {
+      Debug(&session(),
+            "Created and bound control stream %" PRIi64,
+            control_stream_id_);
+      Debug(&session(),
+            "Created and bound qpack enc stream %" PRIi64,
+            qpack_enc_stream_id_);
+      Debug(&session(),
+            "Created and bound qpack dec streams %" PRIi64,
+            qpack_dec_stream_id_);
+    }
+
+    return ret;
   }
 
-  bool ReceiveStreamData(Stream* stream,
+  bool ReceiveStreamData(int64_t stream_id,
                          const uint8_t* data,
                          size_t datalen,
-                         Stream::ReceiveDataFlags flags) override {
-    Debug(&session(), "HTTP/3 application received %zu bytes of data", datalen);
+                         const Stream::ReceiveDataFlags& flags,
+                         void* unused) override {
+    Debug(&session(),
+          "HTTP/3 application received %zu bytes of data "
+          "on stream %" PRIi64 ". Is final? %d",
+          datalen,
+          stream_id,
+          flags.fin);
+
     ssize_t nread = nghttp3_conn_read_stream(
-        *this, stream->id(), data, datalen, flags.fin ? 1 : 0);
+        *this, stream_id, data, datalen, flags.fin ? 1 : 0);
 
     if (nread < 0) {
       Debug(&session(),
@@ -131,20 +261,24 @@ class Http3Application final : public Session::Application {
       return false;
     }
 
-    Debug(&session(),
-          "Extending stream and connection offset by %zd bytes",
-          nread);
-    session().ExtendStreamOffset(stream->id(), nread);
-    session().ExtendOffset(nread);
+    if (nread > 0) {
+      Debug(&session(),
+            "Extending stream and connection offset by %zd bytes",
+            nread);
+      session().ExtendStreamOffset(stream_id, nread);
+      session().ExtendOffset(nread);
+    }
 
     return true;
   }
 
-  void AcknowledgeStreamData(Stream* stream, size_t datalen) override {
+  bool AcknowledgeStreamData(int64_t stream_id, size_t datalen) override {
     Debug(&session(),
-          "HTTP/3 application received acknowledgement for %zu bytes of data",
-          datalen);
-    CHECK_EQ(nghttp3_conn_add_ack_offset(*this, stream->id(), datalen), 0);
+          "HTTP/3 application received acknowledgement for %zu bytes of data "
+          "on stream %" PRIi64,
+          datalen,
+          stream_id);
+    return nghttp3_conn_add_ack_offset(*this, stream_id, datalen) == 0;
   }
 
   bool CanAddHeader(size_t current_count,
@@ -153,17 +287,9 @@ class Http3Application final : public Session::Application {
     // We cannot add the header if we've either reached
     // * the max number of header pairs or
     // * the max number of header bytes
-    bool answer = (current_count < options_.max_header_pairs) &&
-                  (current_headers_length + this_header_length) <=
-                      options_.max_header_length;
-    IF_QUIC_DEBUG(env()) {
-      if (answer) {
-        Debug(&session(), "HTTP/3 application can add header");
-      } else {
-        Debug(&session(), "HTTP/3 application cannot add header");
-      }
-    }
-    return answer;
+    return (current_count < options_.max_header_pairs) &&
+           (current_headers_length + this_header_length) <=
+               options_.max_header_length;
   }
 
   void BlockStream(int64_t id) override {
@@ -186,7 +312,7 @@ class Http3Application final : public Session::Application {
         switch (direction) {
           case Direction::BIDIRECTIONAL: {
             Debug(&session(),
-                  "HTTP/3 application extending max bidi streams to %" PRIu64,
+                  "HTTP/3 application extending max bidi streams by %" PRIu64,
                   max_streams);
             ngtcp2_conn_extend_max_streams_bidi(
                 session(), static_cast<size_t>(max_streams));
@@ -194,7 +320,7 @@ class Http3Application final : public Session::Application {
           }
           case Direction::UNIDIRECTIONAL: {
             Debug(&session(),
-                  "HTTP/3 application extending max uni streams to %" PRIu64,
+                  "HTTP/3 application extending max uni streams by %" PRIu64,
                   max_streams);
             ngtcp2_conn_extend_max_streams_uni(
                 session(), static_cast<size_t>(max_streams));
@@ -227,7 +353,7 @@ class Http3Application final : public Session::Application {
                : SessionTicket::AppData::Status::TICKET_USE;
   }
 
-  void StreamClose(Stream* stream, QuicError error = QuicError()) override {
+  void StreamClose(Stream* stream, QuicError&& error = QuicError()) override {
     Debug(
         &session(), "HTTP/3 application closing stream %" PRIi64, stream->id());
     uint64_t code = NGHTTP3_H3_NO_ERROR;
@@ -254,14 +380,14 @@ class Http3Application final : public Session::Application {
 
   void StreamReset(Stream* stream,
                    uint64_t final_size,
-                   QuicError error) override {
+                   QuicError&& error = QuicError()) override {
     // We are shutting down the readable side of the local stream here.
     Debug(&session(),
           "HTTP/3 application resetting stream %" PRIi64,
           stream->id());
     int rv = nghttp3_conn_shutdown_stream_read(*this, stream->id());
     if (rv == 0) {
-      stream->ReceiveStreamReset(final_size, error);
+      stream->ReceiveStreamReset(final_size, std::move(error));
       return;
     }
 
@@ -270,8 +396,9 @@ class Http3Application final : public Session::Application {
     session().Close();
   }
 
-  void StreamStopSending(Stream* stream, QuicError error) override {
-    Application::StreamStopSending(stream, error);
+  void StreamStopSending(Stream* stream,
+                         QuicError&& error = QuicError()) override {
+    Application::StreamStopSending(stream, std::move(error));
   }
 
   bool SendHeaders(const Stream& stream,
@@ -288,7 +415,7 @@ class Http3Application final : public Session::Application {
           return false;
         }
         Debug(&session(),
-              "Submitting early hints for stream " PRIi64,
+              "Submitting %" PRIu64 " early hints for stream %" PRIu64,
               stream.id());
         return nghttp3_conn_submit_info(
                    *this, stream.id(), nva.data(), nva.length()) == 0;
@@ -301,19 +428,23 @@ class Http3Application final : public Session::Application {
         // If the terminal flag is set, that means that we know we're only
         // sending headers and no body and the stream writable side should be
         // closed immediately because there is no nghttp3_data_reader provided.
-        if (flags != HeadersFlags::TERMINAL) reader_ptr = &reader;
+        if (flags != HeadersFlags::TERMINAL) {
+          reader_ptr = &reader;
+        }
 
         if (session().is_server()) {
           // If this is a server, we're submitting a response...
           Debug(&session(),
-                "Submitting response headers for stream " PRIi64,
+                "Submitting %" PRIu64 " response headers for stream %" PRIu64,
+                nva.length(),
                 stream.id());
           return nghttp3_conn_submit_response(
               *this, stream.id(), nva.data(), nva.length(), reader_ptr);
         } else {
           // Otherwise we're submitting a request...
           Debug(&session(),
-                "Submitting request headers for stream " PRIi64,
+                "Submitting %" PRIu64 " request headers for stream %" PRIu64,
+                nva.length(),
                 stream.id());
           return nghttp3_conn_submit_request(*this,
                                              stream.id(),
@@ -325,6 +456,10 @@ class Http3Application final : public Session::Application {
         break;
       }
       case HeadersKind::TRAILING: {
+        Debug(&session(),
+              "Submitting %" PRIu64 " trailing headers for stream %" PRIu64,
+              nva.length(),
+              stream.id());
         return nghttp3_conn_submit_trailers(
                    *this, stream.id(), nva.data(), nva.length()) == 0;
         break;
@@ -351,22 +486,25 @@ class Http3Application final : public Session::Application {
   }
 
   int GetStreamData(StreamData* data) override {
+    data->count = kMaxVectorCount;
     ssize_t ret = 0;
     Debug(&session(), "HTTP/3 application getting stream data");
     if (conn_ && session().max_data_left()) {
-      nghttp3_vec vec = *data;
       ret = nghttp3_conn_writev_stream(
-          *this, &data->id, &data->fin, &vec, data->count);
+          *this, &data->id, &data->fin, *data, data->count);
+      // A negative return value indicates an error.
       if (ret < 0) {
         return static_cast<int>(ret);
-      } else {
-        data->remaining = data->count = static_cast<size_t>(ret);
-        if (data->id > 0) {
-          data->stream = session().FindStream(data->id);
-        }
+      }
+
+      data->count = static_cast<size_t>(ret);
+      if (data->id > 0 && data->id != control_stream_id_ &&
+          data->id != qpack_dec_stream_id_ &&
+          data->id != qpack_enc_stream_id_) {
+        data->stream = session().FindStream(data->id);
       }
     }
-    DCHECK_NOT_NULL(data->buf);
+
     return 0;
   }
 
@@ -389,8 +527,8 @@ class Http3Application final : public Session::Application {
   }
 
   SET_NO_MEMORY_INFO()
-  SET_MEMORY_INFO_NAME(Http3Application)
-  SET_SELF_SIZE(Http3Application)
+  SET_MEMORY_INFO_NAME(Http3ApplicationImpl)
+  SET_SELF_SIZE(Http3ApplicationImpl)
 
  private:
   inline operator nghttp3_conn*() const {
@@ -398,35 +536,11 @@ class Http3Application final : public Session::Application {
     return conn_.get();
   }
 
-  bool CreateAndBindControlStreams() {
-    Debug(&session(), "Creating and binding HTTP/3 control streams");
-    auto stream = session().OpenStream(Direction::UNIDIRECTIONAL);
-    if (!stream) return false;
-    if (nghttp3_conn_bind_control_stream(*this, stream->id()) != 0) {
-      return false;
-    }
-
-    auto enc_stream = session().OpenStream(Direction::UNIDIRECTIONAL);
-    if (!enc_stream) return false;
-
-    auto dec_stream = session().OpenStream(Direction::UNIDIRECTIONAL);
-    if (!dec_stream) return false;
-
-    bool bound = nghttp3_conn_bind_qpack_streams(
-                     *this, enc_stream->id(), dec_stream->id()) == 0;
-    control_stream_id_ = stream->id();
-    qpack_enc_stream_id_ = enc_stream->id();
-    qpack_dec_stream_id_ = dec_stream->id();
-    return bound;
-  }
-
   inline bool is_control_stream(int64_t id) const {
     return id == control_stream_id_ || id == qpack_dec_stream_id_ ||
            id == qpack_enc_stream_id_;
   }
 
-  bool is_destroyed() const { return session().is_destroyed(); }
-
   Http3ConnectionPointer InitializeConnection() {
     nghttp3_conn* conn = nullptr;
     nghttp3_settings settings = options_;
@@ -443,118 +557,141 @@ class Http3Application final : public Session::Application {
   }
 
   void OnStreamClose(Stream* stream, uint64_t app_error_code) {
-    if (stream->is_destroyed()) return;
-    Debug(&session(),
-          "HTTP/3 application received stream close for stream %" PRIi64,
-          stream->id());
+    if (app_error_code != NGHTTP3_H3_NO_ERROR) {
+      Debug(&session(),
+            "HTTP/3 application received stream close for stream %" PRIi64
+            " with code %" PRIu64,
+            stream->id(),
+            app_error_code);
+    }
     auto direction = stream->direction();
     stream->Destroy(QuicError::ForApplication(app_error_code));
     ExtendMaxStreams(EndpointLabel::REMOTE, direction, 1);
   }
 
-  void OnReceiveData(Stream* stream, const nghttp3_vec& vec) {
-    if (stream->is_destroyed()) return;
-    Debug(&session(), "HTTP/3 application received %zu bytes of data", vec.len);
-    stream->ReceiveData(vec.base, vec.len, Stream::ReceiveDataFlags{});
-  }
-
-  void OnDeferredConsume(Stream* stream, size_t consumed) {
-    auto& sess = session();
-    Debug(
-        &session(), "HTTP/3 application deferred consume %zu bytes", consumed);
-    if (!stream->is_destroyed()) {
-      sess.ExtendStreamOffset(stream->id(), consumed);
-    }
-    sess.ExtendOffset(consumed);
-  }
-
-  void OnBeginHeaders(Stream* stream) {
-    if (stream->is_destroyed()) return;
+  void OnBeginHeaders(int64_t stream_id) {
+    auto stream = session().FindStream(stream_id);
+    // If the stream does not exist or is destroyed, ignore!
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application beginning initial block of headers for stream "
           "%" PRIi64,
-          stream->id());
+          stream_id);
     stream->BeginHeaders(HeadersKind::INITIAL);
   }
 
-  void OnReceiveHeader(Stream* stream, Http3Header&& header) {
-    if (stream->is_destroyed()) return;
-    if (header.name() == ":status") {
-      if (header.value()[0] == '1') {
-        Debug(
-            &session(),
+  void OnReceiveHeader(int64_t stream_id, Http3Header&& header) {
+    auto stream = session().FindStream(stream_id);
+
+    if (!stream) [[unlikely]]
+      return;
+    if (header.name() == ":status" && header.value()[0] == '1') {
+      Debug(&session(),
             "HTTP/3 application switching to hints headers for stream %" PRIi64,
             stream->id());
-        stream->set_headers_kind(HeadersKind::HINTS);
-      }
+      stream->set_headers_kind(HeadersKind::HINTS);
+    }
+    IF_QUIC_DEBUG(env()) {
+      Debug(&session(),
+            "Received header \"%s: %s\"",
+            header.name(),
+            header.value());
     }
     stream->AddHeader(std::move(header));
   }
 
-  void OnEndHeaders(Stream* stream, int fin) {
+  void OnEndHeaders(int64_t stream_id, int fin) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application received end of headers for stream %" PRIi64,
-          stream->id());
+          stream_id);
     stream->EmitHeaders();
-    if (fin != 0) {
+    if (fin) {
       // The stream is done. There's no more data to receive!
-      Debug(&session(), "Headers are final for stream %" PRIi64, stream->id());
-      OnEndStream(stream);
+      Debug(&session(), "Headers are final for stream %" PRIi64, stream_id);
+      Stream::ReceiveDataFlags flags{
+          .fin = true,
+          .early = false,
+      };
+      stream->ReceiveData(nullptr, 0, flags);
     }
   }
 
-  void OnBeginTrailers(Stream* stream) {
-    if (stream->is_destroyed()) return;
+  void OnBeginTrailers(int64_t stream_id) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application beginning block of trailers for stream %" PRIi64,
-          stream->id());
+          stream_id);
     stream->BeginHeaders(HeadersKind::TRAILING);
   }
 
-  void OnReceiveTrailer(Stream* stream, Http3Header&& header) {
+  void OnReceiveTrailer(int64_t stream_id, Http3Header&& header) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
+    IF_QUIC_DEBUG(env()) {
+      Debug(&session(),
+            "Received header \"%s: %s\"",
+            header.name(),
+            header.value());
+    }
     stream->AddHeader(header);
   }
 
-  void OnEndTrailers(Stream* stream, int fin) {
-    if (stream->is_destroyed()) return;
+  void OnEndTrailers(int64_t stream_id, int fin) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application received end of trailers for stream %" PRIi64,
-          stream->id());
+          stream_id);
     stream->EmitHeaders();
-    if (fin != 0) {
-      Debug(&session(), "Trailers are final for stream %" PRIi64, stream->id());
-      // The stream is done. There's no more data to receive!
-      stream->ReceiveData(nullptr,
-                          0,
-                          Stream::ReceiveDataFlags{/* .fin = */ true,
-                                                   /* .early = */ false});
+    if (fin) {
+      Debug(&session(), "Trailers are final for stream %" PRIi64, stream_id);
+      Stream::ReceiveDataFlags flags{
+          .fin = true,
+          .early = false,
+      };
+      stream->ReceiveData(nullptr, 0, flags);
     }
   }
 
-  void OnEndStream(Stream* stream) {
-    if (stream->is_destroyed()) return;
+  void OnEndStream(int64_t stream_id) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application received end of stream for stream %" PRIi64,
-          stream->id());
-    stream->ReceiveData(nullptr,
-                        0,
-                        Stream::ReceiveDataFlags{/* .fin = */ true,
-                                                 /* .early = */ false});
+          stream_id);
+    Stream::ReceiveDataFlags flags{
+        .fin = true,
+        .early = false,
+    };
+    stream->ReceiveData(nullptr, 0, flags);
   }
 
-  void OnStopSending(Stream* stream, uint64_t app_error_code) {
-    if (stream->is_destroyed()) return;
+  void OnStopSending(int64_t stream_id, uint64_t app_error_code) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application received stop sending for stream %" PRIi64,
-          stream->id());
+          stream_id);
     stream->ReceiveStopSending(QuicError::ForApplication(app_error_code));
   }
 
-  void OnResetStream(Stream* stream, uint64_t app_error_code) {
-    if (stream->is_destroyed()) return;
+  void OnResetStream(int64_t stream_id, uint64_t app_error_code) {
+    auto stream = session().FindStream(stream_id);
+    if (!stream) [[unlikely]]
+      return;
     Debug(&session(),
           "HTTP/3 application received reset stream for stream %" PRIi64,
-          stream->id());
+          stream_id);
     stream->ReceiveStreamReset(0, QuicError::ForApplication(app_error_code));
   }
 
@@ -584,13 +721,14 @@ class Http3Application final : public Session::Application {
     options_.qpack_encoder_max_dtable_capacity =
         settings->qpack_encoder_max_dtable_capacity;
     options_.qpack_max_dtable_capacity = settings->qpack_max_dtable_capacity;
-    Debug(
-        &session(), "HTTP/3 application received updated settings ", options_);
+    Debug(&session(),
+          "HTTP/3 application received updated settings: %s",
+          options_);
   }
 
   bool started_ = false;
   nghttp3_mem allocator_;
-  Session::Application_Options options_;
+  Session::Application::Options options_;
   Http3ConnectionPointer conn_;
   int64_t control_stream_id_ = -1;
   int64_t qpack_dec_stream_id_ = -1;
@@ -599,26 +737,30 @@ class Http3Application final : public Session::Application {
   // ==========================================================================
   // Static callbacks
 
-  static Http3Application* From(nghttp3_conn* conn, void* user_data) {
+  static Http3ApplicationImpl* From(nghttp3_conn* conn, void* user_data) {
     DCHECK_NOT_NULL(user_data);
-    auto app = static_cast<Http3Application*>(user_data);
+    auto app = static_cast<Http3ApplicationImpl*>(user_data);
     DCHECK_EQ(conn, app->conn_.get());
     return app;
   }
 
-  static Stream* From(int64_t stream_id, void* stream_user_data) {
-    DCHECK_NOT_NULL(stream_user_data);
-    auto stream = static_cast<Stream*>(stream_user_data);
-    DCHECK_EQ(stream_id, stream->id());
-    return stream;
+  static BaseObjectWeakPtr<Stream> FindOrCreateStream(nghttp3_conn* conn,
+                                                      Session* session,
+                                                      int64_t stream_id) {
+    if (auto stream = session->FindStream(stream_id)) {
+      return stream;
+    }
+    if (auto stream = session->CreateStream(stream_id)) {
+      return stream;
+    }
+    return {};
   }
 
 #define NGHTTP3_CALLBACK_SCOPE(name)                                           \
-  auto name = From(conn, conn_user_data);                                      \
-  if (name->is_destroyed()) [[unlikely]] {                                     \
-    return NGHTTP3_ERR_CALLBACK_FAILURE;                                       \
-  }                                                                            \
-  NgHttp3CallbackScope scope(name->env());
+  auto ptr = From(conn, conn_user_data);                                       \
+  CHECK_NOT_NULL(ptr);                                                         \
+  auto& name = *ptr;                                                           \
+  NgHttp3CallbackScope scope(name.env());
 
   static nghttp3_ssize on_read_data_callback(nghttp3_conn* conn,
                                              int64_t stream_id,
@@ -627,7 +769,7 @@ class Http3Application final : public Session::Application {
                                              uint32_t* pflags,
                                              void* conn_user_data,
                                              void* stream_user_data) {
-    return 0;
+    return NGTCP2_SUCCESS;
   }
 
   static int on_acked_stream_data(nghttp3_conn* conn,
@@ -636,10 +778,9 @@ class Http3Application final : public Session::Application {
                                   void* conn_user_data,
                                   void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->AcknowledgeStreamData(stream, static_cast<size_t>(datalen));
-    return NGTCP2_SUCCESS;
+    return app.AcknowledgeStreamData(stream_id, static_cast<size_t>(datalen))
+               ? NGTCP2_SUCCESS
+               : NGHTTP3_ERR_CALLBACK_FAILURE;
   }
 
   static int on_stream_close(nghttp3_conn* conn,
@@ -648,9 +789,9 @@ class Http3Application final : public Session::Application {
                              void* conn_user_data,
                              void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnStreamClose(stream, app_error_code);
+    if (auto stream = app.session().FindStream(stream_id)) {
+      app.OnStreamClose(stream.get(), app_error_code);
+    }
     return NGTCP2_SUCCESS;
   }
 
@@ -661,11 +802,19 @@ class Http3Application final : public Session::Application {
                              void* conn_user_data,
                              void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnReceiveData(stream,
-                       nghttp3_vec{const_cast<uint8_t*>(data), datalen});
-    return NGTCP2_SUCCESS;
+    // The on_receive_data callback will never be called for control streams,
+    // so we know that if we get here, the data received is for a stream that
+    // we know is for an HTTP payload.
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    auto& session = app.session();
+    if (auto stream = FindOrCreateStream(conn, &session, stream_id))
+        [[likely]] {
+      stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{});
+      return NGTCP2_SUCCESS;
+    }
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
   }
 
   static int on_deferred_consume(nghttp3_conn* conn,
@@ -674,9 +823,10 @@ class Http3Application final : public Session::Application {
                                  void* conn_user_data,
                                  void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnDeferredConsume(stream, consumed);
+    auto& session = app.session();
+    Debug(&session, "HTTP/3 application deferred consume %zu bytes", consumed);
+    session.ExtendStreamOffset(stream_id, consumed);
+    session.ExtendOffset(consumed);
     return NGTCP2_SUCCESS;
   }
 
@@ -685,9 +835,10 @@ class Http3Application final : public Session::Application {
                               void* conn_user_data,
                               void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnBeginHeaders(stream);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnBeginHeaders(stream_id);
     return NGTCP2_SUCCESS;
   }
 
@@ -700,11 +851,12 @@ class Http3Application final : public Session::Application {
                                void* conn_user_data,
                                void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
     if (Http3Header::IsZeroLength(token, name, value)) return NGTCP2_SUCCESS;
-    app->OnReceiveHeader(stream,
-                         Http3Header(app->env(), token, name, value, flags));
+    app.OnReceiveHeader(stream_id,
+                        Http3Header(app.env(), token, name, value, flags));
     return NGTCP2_SUCCESS;
   }
 
@@ -714,9 +866,10 @@ class Http3Application final : public Session::Application {
                             void* conn_user_data,
                             void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnEndHeaders(stream, fin);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnEndHeaders(stream_id, fin);
     return NGTCP2_SUCCESS;
   }
 
@@ -725,9 +878,10 @@ class Http3Application final : public Session::Application {
                                void* conn_user_data,
                                void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnBeginTrailers(stream);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnBeginTrailers(stream_id);
     return NGTCP2_SUCCESS;
   }
 
@@ -740,11 +894,12 @@ class Http3Application final : public Session::Application {
                                 void* conn_user_data,
                                 void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
     if (Http3Header::IsZeroLength(token, name, value)) return NGTCP2_SUCCESS;
-    app->OnReceiveTrailer(stream,
-                          Http3Header(app->env(), token, name, value, flags));
+    app.OnReceiveTrailer(stream_id,
+                         Http3Header(app.env(), token, name, value, flags));
     return NGTCP2_SUCCESS;
   }
 
@@ -754,9 +909,10 @@ class Http3Application final : public Session::Application {
                              void* conn_user_data,
                              void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnEndTrailers(stream, fin);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnEndTrailers(stream_id, fin);
     return NGTCP2_SUCCESS;
   }
 
@@ -765,9 +921,10 @@ class Http3Application final : public Session::Application {
                            void* conn_user_data,
                            void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnEndStream(stream);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnEndStream(stream_id);
     return NGTCP2_SUCCESS;
   }
 
@@ -777,9 +934,10 @@ class Http3Application final : public Session::Application {
                              void* conn_user_data,
                              void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnStopSending(stream, app_error_code);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnStopSending(stream_id, app_error_code);
     return NGTCP2_SUCCESS;
   }
 
@@ -789,15 +947,16 @@ class Http3Application final : public Session::Application {
                              void* conn_user_data,
                              void* stream_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    auto stream = From(stream_id, stream_user_data);
-    if (stream == nullptr) return NGHTTP3_ERR_CALLBACK_FAILURE;
-    app->OnResetStream(stream, app_error_code);
+    if (app.is_control_stream(stream_id)) [[unlikely]] {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+    app.OnResetStream(stream_id, app_error_code);
     return NGTCP2_SUCCESS;
   }
 
   static int on_shutdown(nghttp3_conn* conn, int64_t id, void* conn_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    app->OnShutdown();
+    app.OnShutdown();
     return NGTCP2_SUCCESS;
   }
 
@@ -805,7 +964,7 @@ class Http3Application final : public Session::Application {
                                  const nghttp3_settings* settings,
                                  void* conn_user_data) {
     NGHTTP3_CALLBACK_SCOPE(app);
-    app->OnReceiveSettings(settings);
+    app.OnReceiveSettings(settings);
     return NGTCP2_SUCCESS;
   }
 
@@ -825,13 +984,14 @@ class Http3Application final : public Session::Application {
                                                    on_shutdown,
                                                    on_receive_settings};
 };
-}  // namespace
 
-std::unique_ptr<Session::Application> createHttp3Application(
-    Session* session, const Session::Application_Options& options) {
-  return std::make_unique<Http3Application>(session, options);
+std::unique_ptr<Session::Application> Http3Application::Create(
+    Session* session) {
+  Debug(session, "Selecting HTTP/3 application");
+  return std::make_unique<Http3ApplicationImpl>(session, options_);
 }
 
-}  // namespace node::quic
+}  // namespace quic
+}  // namespace node
 
 #endif  // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC
diff --git a/src/quic/http3.h b/src/quic/http3.h
index 94860c9b771830..01f682a4829a3c 100644
--- a/src/quic/http3.h
+++ b/src/quic/http3.h
@@ -3,11 +3,40 @@
 #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
 #if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC
 
+#include <base_object.h>
+#include <env.h>
+#include <memory_tracker.h>
 #include "session.h"
 
 namespace node::quic {
-std::unique_ptr<Session::Application> createHttp3Application(
-    Session* session, const Session::Application_Options& options);
+// Provides an implementation of the HTTP/3 Application implementation
+class Http3Application final : public Session::ApplicationProvider {
+ public:
+  static bool HasInstance(Environment* env, v8::Local<v8::Value> value);
+  static v8::Local<v8::FunctionTemplate> GetConstructorTemplate(
+      Environment* env);
+  static void InitPerIsolate(IsolateData* isolate_data,
+                             v8::Local<v8::ObjectTemplate> target);
+  static void InitPerContext(Realm* realm, v8::Local<v8::Object> target);
+  static void RegisterExternalReferences(ExternalReferenceRegistry* registry);
+
+  Http3Application(Environment* env,
+                   v8::Local<v8::Object> object,
+                   const Session::Application_Options& options);
+
+  std::unique_ptr<Session::Application> Create(Session* session) override;
+
+  void MemoryInfo(MemoryTracker* tracker) const override;
+  SET_SELF_SIZE(Http3Application)
+  SET_MEMORY_INFO_NAME(Http3Application)
+
+  std::string ToString() const;
+
+ private:
+  static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
+
+  Session::Application_Options options_;
+};
 
 }  // namespace node::quic
 
diff --git a/src/quic/logstream.cc b/src/quic/logstream.cc
index cf8fd5fef347a5..ed84cad15ec950 100644
--- a/src/quic/logstream.cc
+++ b/src/quic/logstream.cc
@@ -40,7 +40,7 @@ BaseObjectPtr<LogStream> LogStream::Create(Environment* env) {
            ->InstanceTemplate()
            ->NewInstance(env->context())
            .ToLocal(&obj)) {
-    return BaseObjectPtr<LogStream>();
+    return {};
   }
   return MakeDetachedBaseObject<LogStream>(env, obj);
 }
diff --git a/src/quic/packet.cc b/src/quic/packet.cc
index 9fee1f84bb2b93..3b03dc25fceac0 100644
--- a/src/quic/packet.cc
+++ b/src/quic/packet.cc
@@ -110,21 +110,21 @@ Local<FunctionTemplate> Packet::GetConstructorTemplate(Environment* env) {
   return tmpl;
 }
 
-Packet* Packet::Create(Environment* env,
-                       Listener* listener,
-                       const SocketAddress& destination,
-                       size_t length,
-                       const char* diagnostic_label) {
+BaseObjectPtr<Packet> Packet::Create(Environment* env,
+                                     Listener* listener,
+                                     const SocketAddress& destination,
+                                     size_t length,
+                                     const char* diagnostic_label) {
   if (BindingData::Get(env).packet_freelist.empty()) {
     Local<Object> obj;
     if (!GetConstructorTemplate(env)
              ->InstanceTemplate()
              ->NewInstance(env->context())
              .ToLocal(&obj)) [[unlikely]] {
-      return nullptr;
+      return {};
     }
 
-    return new Packet(
+    return MakeBaseObject<Packet>(
         env, listener, obj, destination, length, diagnostic_label);
   }
 
@@ -134,7 +134,7 @@ Packet* Packet::Create(Environment* env,
                       destination);
 }
 
-Packet* Packet::Clone() const {
+BaseObjectPtr<Packet> Packet::Clone() const {
   auto& binding = BindingData::Get(env());
   if (binding.packet_freelist.empty()) {
     Local<Object> obj;
@@ -142,26 +142,27 @@ Packet* Packet::Clone() const {
              ->InstanceTemplate()
              ->NewInstance(env()->context())
              .ToLocal(&obj)) [[unlikely]] {
-      return nullptr;
+      return {};
     }
 
-    return new Packet(env(), listener_, obj, destination_, data_);
+    return MakeBaseObject<Packet>(env(), listener_, obj, destination_, data_);
   }
 
   return FromFreeList(env(), data_, listener_, destination_);
 }
 
-Packet* Packet::FromFreeList(Environment* env,
-                             std::shared_ptr<Data> data,
-                             Listener* listener,
-                             const SocketAddress& destination) {
+BaseObjectPtr<Packet> Packet::FromFreeList(Environment* env,
+                                           std::shared_ptr<Data> data,
+                                           Listener* listener,
+                                           const SocketAddress& destination) {
   auto& binding = BindingData::Get(env);
-  if (binding.packet_freelist.empty()) return nullptr;
-  Packet* packet = binding.packet_freelist.back();
+  if (binding.packet_freelist.empty()) return {};
+  auto obj = binding.packet_freelist.back();
   binding.packet_freelist.pop_back();
-  CHECK_NOT_NULL(packet);
-  CHECK_EQ(env, packet->env());
-  Debug(packet, "Reusing packet from freelist");
+  CHECK(obj);
+  CHECK_EQ(env, obj->env());
+  auto packet = BaseObjectPtr<Packet>(static_cast<Packet*>(obj.get()));
+  Debug(packet.get(), "Reusing packet from freelist");
   packet->data_ = std::move(data);
   packet->destination_ = destination;
   packet->listener_ = listener;
@@ -195,23 +196,25 @@ Packet::Packet(Environment* env,
 
 void Packet::Done(int status) {
   Debug(this, "Packet is done with status %d", status);
-  if (listener_ != nullptr) {
+  BaseObjectPtr<Packet> self(this);
+  self->MakeWeak();
+
+  if (listener_ != nullptr && IsDispatched()) {
     listener_->PacketDone(status);
   }
-
   // As a performance optimization, we add this packet to a freelist
   // rather than deleting it but only if the freelist isn't too
   // big, we don't want to accumulate these things forever.
   auto& binding = BindingData::Get(env());
-  if (binding.packet_freelist.size() < kMaxFreeList) {
-    Debug(this, "Returning packet to freelist");
-    listener_ = nullptr;
-    data_.reset();
-    Reset();
-    binding.packet_freelist.push_back(this);
-  } else {
-    delete this;
+  if (binding.packet_freelist.size() >= kMaxFreeList) {
+    return;
   }
+
+  Debug(this, "Returning packet to freelist");
+  listener_ = nullptr;
+  data_.reset();
+  Reset();
+  binding.packet_freelist.push_back(std::move(self));
 }
 
 std::string Packet::ToString() const {
@@ -224,10 +227,11 @@ void Packet::MemoryInfo(MemoryTracker* tracker) const {
   tracker->TrackField("data", data_);
 }
 
-Packet* Packet::CreateRetryPacket(Environment* env,
-                                  Listener* listener,
-                                  const PathDescriptor& path_descriptor,
-                                  const TokenSecret& token_secret) {
+BaseObjectPtr<Packet> Packet::CreateRetryPacket(
+    Environment* env,
+    Listener* listener,
+    const PathDescriptor& path_descriptor,
+    const TokenSecret& token_secret) {
   auto& random = CID::Factory::random();
   CID cid = random.Generate();
   RetryToken token(path_descriptor.version,
@@ -235,7 +239,7 @@ Packet* Packet::CreateRetryPacket(Environment* env,
                    cid,
                    path_descriptor.dcid,
                    token_secret);
-  if (!token) return nullptr;
+  if (!token) return {};
 
   const ngtcp2_vec& vec = token;
 
@@ -244,7 +248,7 @@ Packet* Packet::CreateRetryPacket(Environment* env,
 
   auto packet =
       Create(env, listener, path_descriptor.remote_address, pktlen, "retry");
-  if (packet == nullptr) return nullptr;
+  if (!packet) return packet;
 
   ngtcp2_vec dest = *packet;
 
@@ -258,33 +262,34 @@ Packet* Packet::CreateRetryPacket(Environment* env,
                                              vec.len);
   if (nwrite <= 0) {
     packet->Done(UV_ECANCELED);
-    return nullptr;
+    return {};
   }
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
 }
 
-Packet* Packet::CreateConnectionClosePacket(Environment* env,
-                                            Listener* listener,
-                                            const SocketAddress& destination,
-                                            ngtcp2_conn* conn,
-                                            const QuicError& error) {
+BaseObjectPtr<Packet> Packet::CreateConnectionClosePacket(
+    Environment* env,
+    Listener* listener,
+    const SocketAddress& destination,
+    ngtcp2_conn* conn,
+    const QuicError& error) {
   auto packet = Create(
       env, listener, destination, kDefaultMaxPacketLength, "connection close");
-  if (packet == nullptr) return nullptr;
+  if (!packet) return packet;
   ngtcp2_vec vec = *packet;
 
   ssize_t nwrite = ngtcp2_conn_write_connection_close(
       conn, nullptr, nullptr, vec.base, vec.len, error, uv_hrtime());
   if (nwrite < 0) {
     packet->Done(UV_ECANCELED);
-    return nullptr;
+    return {};
   }
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
 }
 
-Packet* Packet::CreateImmediateConnectionClosePacket(
+BaseObjectPtr<Packet> Packet::CreateImmediateConnectionClosePacket(
     Environment* env,
     Listener* listener,
     const PathDescriptor& path_descriptor,
@@ -294,7 +299,7 @@ Packet* Packet::CreateImmediateConnectionClosePacket(
                        path_descriptor.remote_address,
                        kDefaultMaxPacketLength,
                        "immediate connection close (endpoint)");
-  if (packet == nullptr) return nullptr;
+  if (!packet) return packet;
   ngtcp2_vec vec = *packet;
   ssize_t nwrite = ngtcp2_crypto_write_connection_close(
       vec.base,
@@ -309,13 +314,13 @@ Packet* Packet::CreateImmediateConnectionClosePacket(
       0);
   if (nwrite <= 0) {
     packet->Done(UV_ECANCELED);
-    return nullptr;
+    return {};
   }
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
 }
 
-Packet* Packet::CreateStatelessResetPacket(
+BaseObjectPtr<Packet> Packet::CreateStatelessResetPacket(
     Environment* env,
     Listener* listener,
     const PathDescriptor& path_descriptor,
@@ -328,7 +333,7 @@ Packet* Packet::CreateStatelessResetPacket(
   // QUIC spec. The reason is that packets less than 41 bytes may allow an
   // observer to reliably determine that it's a stateless reset.
   size_t pktlen = source_len - 1;
-  if (pktlen < kMinStatelessResetLen) return nullptr;
+  if (pktlen < kMinStatelessResetLen) return {};
 
   StatelessResetToken token(token_secret, path_descriptor.dcid);
   uint8_t random[kRandlen];
@@ -339,21 +344,21 @@ Packet* Packet::CreateStatelessResetPacket(
                        path_descriptor.remote_address,
                        kDefaultMaxPacketLength,
                        "stateless reset");
-  if (packet == nullptr) return nullptr;
+  if (!packet) return packet;
   ngtcp2_vec vec = *packet;
 
   ssize_t nwrite = ngtcp2_pkt_write_stateless_reset(
       vec.base, pktlen, token, random, kRandlen);
   if (nwrite <= static_cast<ssize_t>(kMinStatelessResetLen)) {
     packet->Done(UV_ECANCELED);
-    return nullptr;
+    return {};
   }
 
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
 }
 
-Packet* Packet::CreateVersionNegotiationPacket(
+BaseObjectPtr<Packet> Packet::CreateVersionNegotiationPacket(
     Environment* env,
     Listener* listener,
     const PathDescriptor& path_descriptor) {
@@ -389,7 +394,7 @@ Packet* Packet::CreateVersionNegotiationPacket(
                        path_descriptor.remote_address,
                        kDefaultMaxPacketLength,
                        "version negotiation");
-  if (packet == nullptr) return nullptr;
+  if (!packet) return packet;
   ngtcp2_vec vec = *packet;
 
   ssize_t nwrite =
@@ -404,7 +409,7 @@ Packet* Packet::CreateVersionNegotiationPacket(
                                            arraysize(sv));
   if (nwrite <= 0) {
     packet->Done(UV_ECANCELED);
-    return nullptr;
+    return {};
   }
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
diff --git a/src/quic/packet.h b/src/quic/packet.h
index 58ab6f46fa8d21..ae6f76272e0156 100644
--- a/src/quic/packet.h
+++ b/src/quic/packet.h
@@ -89,13 +89,14 @@ class Packet final : public ReqWrap<uv_udp_send_t> {
   // tells us how many of the packets bytes were used.
   void Truncate(size_t len);
 
-  static Packet* Create(Environment* env,
-                        Listener* listener,
-                        const SocketAddress& destination,
-                        size_t length = kDefaultMaxPacketLength,
-                        const char* diagnostic_label = "<unknown>");
+  static BaseObjectPtr<Packet> Create(
+      Environment* env,
+      Listener* listener,
+      const SocketAddress& destination,
+      size_t length = kDefaultMaxPacketLength,
+      const char* diagnostic_label = "<unknown>");
 
-  Packet* Clone() const;
+  BaseObjectPtr<Packet> Clone() const;
 
   void MemoryInfo(MemoryTracker* tracker) const override;
   SET_MEMORY_INFO_NAME(Packet)
@@ -103,31 +104,33 @@ class Packet final : public ReqWrap<uv_udp_send_t> {
 
   std::string ToString() const;
 
-  static Packet* CreateRetryPacket(Environment* env,
-                                   Listener* listener,
-                                   const PathDescriptor& path_descriptor,
-                                   const TokenSecret& token_secret);
+  static BaseObjectPtr<Packet> CreateRetryPacket(
+      Environment* env,
+      Listener* listener,
+      const PathDescriptor& path_descriptor,
+      const TokenSecret& token_secret);
 
-  static Packet* CreateConnectionClosePacket(Environment* env,
-                                             Listener* listener,
-                                             const SocketAddress& destination,
-                                             ngtcp2_conn* conn,
-                                             const QuicError& error);
+  static BaseObjectPtr<Packet> CreateConnectionClosePacket(
+      Environment* env,
+      Listener* listener,
+      const SocketAddress& destination,
+      ngtcp2_conn* conn,
+      const QuicError& error);
 
-  static Packet* CreateImmediateConnectionClosePacket(
+  static BaseObjectPtr<Packet> CreateImmediateConnectionClosePacket(
       Environment* env,
       Listener* listener,
       const PathDescriptor& path_descriptor,
       const QuicError& reason);
 
-  static Packet* CreateStatelessResetPacket(
+  static BaseObjectPtr<Packet> CreateStatelessResetPacket(
       Environment* env,
       Listener* listener,
       const PathDescriptor& path_descriptor,
       const TokenSecret& token_secret,
       size_t source_len);
 
-  static Packet* CreateVersionNegotiationPacket(
+  static BaseObjectPtr<Packet> CreateVersionNegotiationPacket(
       Environment* env,
       Listener* listener,
       const PathDescriptor& path_descriptor);
@@ -136,10 +139,10 @@ class Packet final : public ReqWrap<uv_udp_send_t> {
   void Done(int status);
 
  private:
-  static Packet* FromFreeList(Environment* env,
-                              std::shared_ptr<Data> data,
-                              Listener* listener,
-                              const SocketAddress& destination);
+  static BaseObjectPtr<Packet> FromFreeList(Environment* env,
+                                            std::shared_ptr<Data> data,
+                                            Listener* listener,
+                                            const SocketAddress& destination);
 
   Listener* listener_;
   SocketAddress destination_;
diff --git a/src/quic/quic.cc b/src/quic/quic.cc
index 879e16e353d74d..f642a725263cef 100644
--- a/src/quic/quic.cc
+++ b/src/quic/quic.cc
@@ -26,6 +26,7 @@ void CreatePerIsolateProperties(IsolateData* isolate_data,
                                 Local<ObjectTemplate> target) {
   Endpoint::InitPerIsolate(isolate_data, target);
   Session::InitPerIsolate(isolate_data, target);
+  Stream::InitPerIsolate(isolate_data, target);
 }
 
 void CreatePerContextProperties(Local<Object> target,
@@ -36,12 +37,14 @@ void CreatePerContextProperties(Local<Object> target,
   BindingData::InitPerContext(realm, target);
   Endpoint::InitPerContext(realm, target);
   Session::InitPerContext(realm, target);
+  Stream::InitPerContext(realm, target);
 }
 
 void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
   BindingData::RegisterExternalReferences(registry);
   Endpoint::RegisterExternalReferences(registry);
   Session::RegisterExternalReferences(registry);
+  Stream::RegisterExternalReferences(registry);
 }
 
 }  // namespace quic
diff --git a/src/quic/session.cc b/src/quic/session.cc
index 4323c9268fdac2..d939edee18e01a 100644
--- a/src/quic/session.cc
+++ b/src/quic/session.cc
@@ -23,6 +23,7 @@
 #include "data.h"
 #include "defs.h"
 #include "endpoint.h"
+#include "http3.h"
 #include "logstream.h"
 #include "ncrypto.h"
 #include "packet.h"
@@ -37,17 +38,22 @@ namespace node {
 using v8::Array;
 using v8::ArrayBuffer;
 using v8::ArrayBufferView;
+using v8::BackingStoreInitializationMode;
 using v8::BigInt;
 using v8::Boolean;
 using v8::FunctionCallbackInfo;
 using v8::FunctionTemplate;
 using v8::HandleScope;
+using v8::Int32;
 using v8::Integer;
 using v8::Just;
 using v8::Local;
+using v8::LocalVector;
 using v8::Maybe;
+using v8::MaybeLocal;
 using v8::Nothing;
 using v8::Object;
+using v8::ObjectTemplate;
 using v8::PropertyAttribute;
 using v8::String;
 using v8::Uint32;
@@ -57,41 +63,32 @@ using v8::Value;
 namespace quic {
 
 #define SESSION_STATE(V)                                                       \
-  /* Set if the JavaScript wrapper has a path-validation event listener */     \
   V(PATH_VALIDATION, path_validation, uint8_t)                                 \
-  /* Set if the JavaScript wrapper has a version-negotiation event listener */ \
   V(VERSION_NEGOTIATION, version_negotiation, uint8_t)                         \
-  /* Set if the JavaScript wrapper has a datagram event listener */            \
   V(DATAGRAM, datagram, uint8_t)                                               \
-  /* Set if the JavaScript wrapper has a session-ticket event listener */      \
   V(SESSION_TICKET, session_ticket, uint8_t)                                   \
   V(CLOSING, closing, uint8_t)                                                 \
   V(GRACEFUL_CLOSE, graceful_close, uint8_t)                                   \
   V(SILENT_CLOSE, silent_close, uint8_t)                                       \
   V(STATELESS_RESET, stateless_reset, uint8_t)                                 \
-  V(DESTROYED, destroyed, uint8_t)                                             \
   V(HANDSHAKE_COMPLETED, handshake_completed, uint8_t)                         \
   V(HANDSHAKE_CONFIRMED, handshake_confirmed, uint8_t)                         \
   V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t)                         \
   V(PRIORITY_SUPPORTED, priority_supported, uint8_t)                           \
-  /* A Session is wrapped if it has been passed out to JS */                   \
   V(WRAPPED, wrapped, uint8_t)                                                 \
   V(LAST_DATAGRAM_ID, last_datagram_id, uint64_t)
 
 #define SESSION_STATS(V)                                                       \
   V(CREATED_AT, created_at)                                                    \
   V(CLOSING_AT, closing_at)                                                    \
-  V(DESTROYED_AT, destroyed_at)                                                \
   V(HANDSHAKE_COMPLETED_AT, handshake_completed_at)                            \
   V(HANDSHAKE_CONFIRMED_AT, handshake_confirmed_at)                            \
-  V(GRACEFUL_CLOSING_AT, graceful_closing_at)                                  \
   V(BYTES_RECEIVED, bytes_received)                                            \
   V(BYTES_SENT, bytes_sent)                                                    \
   V(BIDI_IN_STREAM_COUNT, bidi_in_stream_count)                                \
   V(BIDI_OUT_STREAM_COUNT, bidi_out_stream_count)                              \
   V(UNI_IN_STREAM_COUNT, uni_in_stream_count)                                  \
   V(UNI_OUT_STREAM_COUNT, uni_out_stream_count)                                \
-  V(LOSS_RETRANSMIT_COUNT, loss_retransmit_count)                              \
   V(MAX_BYTES_IN_FLIGHT, max_bytes_in_flight)                                  \
   V(BYTES_IN_FLIGHT, bytes_in_flight)                                          \
   V(BLOCK_COUNT, block_count)                                                  \
@@ -107,7 +104,7 @@ namespace quic {
   V(DATAGRAMS_LOST, datagrams_lost)
 
 #define SESSION_JS_METHODS(V)                                                  \
-  V(DoDestroy, destroy, false)                                                 \
+  V(Destroy, destroy, false)                                                   \
   V(GetRemoteAddress, getRemoteAddress, true)                                  \
   V(GetCertificate, getCertificate, true)                                      \
   V(GetEphemeralKeyInfo, getEphemeralKey, true)                                \
@@ -115,10 +112,10 @@ namespace quic {
   V(GracefulClose, gracefulClose, false)                                       \
   V(SilentClose, silentClose, false)                                           \
   V(UpdateKey, updateKey, false)                                               \
-  V(DoOpenStream, openStream, false)                                           \
-  V(DoSendDatagram, sendDatagram, false)
+  V(OpenStream, openStream, false)                                             \
+  V(SendDatagram, sendDatagram, false)
 
-struct Session::State {
+struct Session::State final {
 #define V(_, name, type) type name;
   SESSION_STATE(V)
 #undef V
@@ -127,61 +124,31 @@ struct Session::State {
 STAT_STRUCT(Session, SESSION)
 
 // ============================================================================
-// Used to conditionally trigger sending an explicit connection
-// close. If there are multiple MaybeCloseConnectionScope in the
-// stack, the determination of whether to send the close will be
-// done once the final scope is closed.
-struct Session::MaybeCloseConnectionScope final {
-  Session* session;
-  bool silent = false;
-  MaybeCloseConnectionScope(Session* session_, bool silent_)
-      : session(session_),
-        silent(silent_ || session->connection_close_depth_ > 0) {
-    Debug(session_,
-          "Entering maybe close connection scope. Silent? %s",
-          silent ? "yes" : "no");
-    session->connection_close_depth_++;
-  }
-  DISALLOW_COPY_AND_MOVE(MaybeCloseConnectionScope)
-  ~MaybeCloseConnectionScope() {
-    // We only want to trigger the sending the connection close if ...
-    // a) Silent is not explicitly true at this scope.
-    // b) We're not within the scope of an ngtcp2 callback, and
-    // c) We are not already in a closing or draining period.
-    if (--session->connection_close_depth_ == 0 && !silent &&
-        session->can_send_packets()) {
-      session->SendConnectionClose();
-    }
-  }
-};
 
-// ============================================================================
-// Used to conditionally trigger sending of any pending data the session may
-// be holding onto. If there are multiple SendPendingDataScope in the stack,
-// the determination of whether to send the data will be done once the final
-// scope is closed.
+class Http3Application;
 
-Session::SendPendingDataScope::SendPendingDataScope(Session* session)
-    : session(session) {
-  Debug(session, "Entering send pending data scope");
-  session->send_scope_depth_++;
+namespace {
+std::string to_string(PreferredAddress::Policy policy) {
+  switch (policy) {
+    case PreferredAddress::Policy::USE_PREFERRED:
+      return "use";
+    case PreferredAddress::Policy::IGNORE_PREFERRED:
+      return "ignore";
+  }
+  return "<unknown>";
 }
 
-Session::SendPendingDataScope::SendPendingDataScope(
-    const BaseObjectPtr<Session>& session)
-    : SendPendingDataScope(session.get()) {}
-
-Session::SendPendingDataScope::~SendPendingDataScope() {
-  if (--session->send_scope_depth_ == 0 && session->can_send_packets()) {
-    session->application().SendPendingData();
+std::string to_string(Side side) {
+  switch (side) {
+    case Side::CLIENT:
+      return "client";
+    case Side::SERVER:
+      return "server";
   }
+  return "<unknown>";
 }
 
-// ============================================================================
-
-namespace {
-
-inline std::string to_string(ngtcp2_encryption_level level) {
+std::string to_string(ngtcp2_encryption_level level) {
   switch (level) {
     case NGTCP2_ENCRYPTION_LEVEL_1RTT:
       return "1rtt";
@@ -195,6 +162,28 @@ inline std::string to_string(ngtcp2_encryption_level level) {
   return "<unknown>";
 }
 
+std::string to_string(ngtcp2_cc_algo cc_algorithm) {
+#define V(name, label)                                                         \
+  case NGTCP2_CC_ALGO_##name:                                                  \
+    return #label;
+  switch (cc_algorithm) { CC_ALGOS(V) }
+  return "<unknown>";
+#undef V
+}
+
+Maybe<ngtcp2_cc_algo> getAlgoFromString(Environment* env, Local<String> input) {
+  auto& state = BindingData::Get(env);
+#define V(name, str)                                                           \
+  if (input->StringEquals(state.str##_string())) {                             \
+    return Just(NGTCP2_CC_ALGO_##name);                                        \
+  }
+
+  CC_ALGOS(V)
+
+#undef V
+  return Nothing<ngtcp2_cc_algo>();
+}
+
 // Qlog is a JSON-based logging format that is being standardized for low-level
 // debug logging of QUIC connections and dataflows. The qlog output is generated
 // optionally by ngtcp2 for us. The on_qlog_write callback is registered with
@@ -224,8 +213,8 @@ void ngtcp2_debug_log(void* user_data, const char* fmt, ...) {
 template <typename Opt, PreferredAddress::Policy Opt::*member>
 bool SetOption(Environment* env,
                Opt* options,
-               const v8::Local<Object>& object,
-               const v8::Local<String>& name) {
+               const Local<Object>& object,
+               const Local<String>& name) {
   Local<Value> value;
   PreferredAddress::Policy policy = PreferredAddress::Policy::USE_PREFERRED;
   if (!object->Get(env->context(), name).ToLocal(&value) ||
@@ -239,8 +228,8 @@ bool SetOption(Environment* env,
 template <typename Opt, TLSContext::Options Opt::*member>
 bool SetOption(Environment* env,
                Opt* options,
-               const v8::Local<Object>& object,
-               const v8::Local<String>& name) {
+               const Local<Object>& object,
+               const Local<String>& name) {
   Local<Value> value;
   TLSContext::Options opts;
   if (!object->Get(env->context(), name).ToLocal(&value) ||
@@ -251,41 +240,96 @@ bool SetOption(Environment* env,
   return true;
 }
 
-template <typename Opt, Session::Application_Options Opt::*member>
+template <typename Opt, TransportParams::Options Opt::*member>
 bool SetOption(Environment* env,
                Opt* options,
-               const v8::Local<Object>& object,
-               const v8::Local<String>& name) {
+               const Local<Object>& object,
+               const Local<String>& name) {
   Local<Value> value;
-  Session::Application_Options opts;
+  TransportParams::Options opts;
   if (!object->Get(env->context(), name).ToLocal(&value) ||
-      !Session::Application_Options::From(env, value).To(&opts)) {
+      !TransportParams::Options::From(env, value).To(&opts)) {
     return false;
   }
   options->*member = opts;
   return true;
 }
 
-template <typename Opt, TransportParams::Options Opt::*member>
+template <typename Opt,
+          BaseObjectPtr<Session::ApplicationProvider> Opt::*member>
 bool SetOption(Environment* env,
                Opt* options,
-               const v8::Local<Object>& object,
-               const v8::Local<String>& name) {
+               const Local<Object>& object,
+               const Local<String>& name) {
   Local<Value> value;
-  TransportParams::Options opts;
-  if (!object->Get(env->context(), name).ToLocal(&value) ||
-      !TransportParams::Options::From(env, value).To(&opts)) {
+  if (!object->Get(env->context(), name).ToLocal(&value)) {
     return false;
   }
-  options->*member = opts;
+  if (!value->IsUndefined()) {
+    // We currently only support Http3Application for this option.
+    if (!Http3Application::HasInstance(env, value)) {
+      THROW_ERR_INVALID_ARG_TYPE(env,
+                                 "Application must be an Http3Application");
+      return false;
+    }
+    Http3Application* app;
+    ASSIGN_OR_RETURN_UNWRAP(&app, value.As<Object>(), false);
+    CHECK_NOT_NULL(app);
+    auto& assigned = options->*member =
+                         BaseObjectPtr<Session::ApplicationProvider>(app);
+    assigned->Detach();
+  }
+  return true;
+}
+
+template <typename Opt, ngtcp2_cc_algo Opt::*member>
+bool SetOption(Environment* env,
+               Opt* options,
+               const Local<Object>& object,
+               const Local<String>& name) {
+  Local<Value> value;
+  if (!object->Get(env->context(), name).ToLocal(&value)) return false;
+  if (!value->IsUndefined()) {
+    ngtcp2_cc_algo algo;
+    if (value->IsString()) {
+      if (!getAlgoFromString(env, value.As<String>()).To(&algo)) {
+        THROW_ERR_INVALID_ARG_VALUE(env, "The cc_algorithm option is invalid");
+        return false;
+      }
+    } else {
+      if (!value->IsInt32()) {
+        THROW_ERR_INVALID_ARG_VALUE(
+            env, "The cc_algorithm option must be a string or an integer");
+        return false;
+      }
+      Local<Int32> num;
+      if (!value->ToInt32(env->context()).ToLocal(&num)) {
+        THROW_ERR_INVALID_ARG_VALUE(env, "The cc_algorithm option is invalid");
+        return false;
+      }
+      switch (num->Value()) {
+#define V(name, _)                                                             \
+  case NGTCP2_CC_ALGO_##name:                                                  \
+    break;
+        CC_ALGOS(V)
+#undef V
+        default:
+          THROW_ERR_INVALID_ARG_VALUE(env,
+                                      "The cc_algorithm option is invalid");
+          return false;
+      }
+      algo = static_cast<ngtcp2_cc_algo>(num->Value());
+    }
+    options->*member = algo;
+  }
   return true;
 }
 
 }  // namespace
 
 // ============================================================================
-Session::Config::Config(Side side,
-                        const Endpoint& endpoint,
+Session::Config::Config(Environment* env,
+                        Side side,
                         const Options& options,
                         uint32_t version,
                         const SocketAddress& local_address,
@@ -306,6 +350,14 @@ Session::Config::Config(Side side,
 
   // We currently do not support Path MTU Discovery. Once we do, unset this.
   settings.no_pmtud = 1;
+  // Per the ngtcp2 documentation, when no_tx_udp_payload_size_shaping is set
+  // to a non-zero value, ngtcp2 not to limit the UDP payload size to
+  // NGTCP2_MAX_UDP_PAYLOAD_SIZE` and will instead "use the minimum size among
+  // the given buffer size, :member:`max_tx_udp_payload_size`, and the
+  // received max_udp_payload_size QUIC transport parameter." For now, this
+  // works for us, especially since we do not implement Path MTU discovery.
+  settings.no_tx_udp_payload_size_shaping = 1;
+  settings.max_tx_udp_payload_size = options.max_payload_size;
 
   settings.tokenlen = 0;
   settings.token = nullptr;
@@ -314,31 +366,24 @@ Session::Config::Config(Side side,
     settings.qlog_write = on_qlog_write;
   }
 
-  if (endpoint.env()->enabled_debug_list()->enabled(
-          DebugCategory::NGTCP2_DEBUG)) {
+  if (env->enabled_debug_list()->enabled(DebugCategory::NGTCP2_DEBUG)) {
     settings.log_printf = ngtcp2_debug_log;
   }
 
-  // We pull parts of the settings for the session from the endpoint options.
-  auto& config = endpoint.options();
-  settings.no_tx_udp_payload_size_shaping = config.no_udp_payload_size_shaping;
-  settings.handshake_timeout = config.handshake_timeout;
-  settings.max_stream_window = config.max_stream_window;
-  settings.max_window = config.max_window;
-  settings.cc_algo = config.cc_algorithm;
-  settings.max_tx_udp_payload_size = config.max_payload_size;
-  if (config.unacknowledged_packet_threshold > 0) {
-    settings.ack_thresh = config.unacknowledged_packet_threshold;
-  }
+  settings.handshake_timeout = options.handshake_timeout;
+  settings.max_stream_window = options.max_stream_window;
+  settings.max_window = options.max_window;
+  settings.ack_thresh = options.unacknowledged_packet_threshold;
+  settings.cc_algo = options.cc_algorithm;
 }
 
-Session::Config::Config(const Endpoint& endpoint,
+Session::Config::Config(Environment* env,
                         const Options& options,
                         const SocketAddress& local_address,
                         const SocketAddress& remote_address,
                         const CID& ocid)
-    : Config(Side::CLIENT,
-             endpoint,
+    : Config(env,
+             Side::CLIENT,
              options,
              options.version,
              local_address,
@@ -379,17 +424,7 @@ std::string Session::Config::ToString() const {
   DebugIndentScope indent;
   auto prefix = indent.Prefix();
   std::string res("{");
-
-  auto sidestr = ([&] {
-    switch (side) {
-      case Side::CLIENT:
-        return "client";
-      case Side::SERVER:
-        return "server";
-    }
-    return "<unknown>";
-  })();
-  res += prefix + "side: " + std::string(sidestr);
+  res += prefix + "side: " + to_string(side);
   res += prefix + "options: " + options.ToString();
   res += prefix + "version: " + std::to_string(version);
   res += prefix + "local address: " + local_address.ToString();
@@ -421,8 +456,10 @@ Maybe<Session::Options> Session::Options::From(Environment* env,
       env, &options, params, state.name##_string())
 
   if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) ||
-      !SET(transport_params) || !SET(tls_options) ||
-      !SET(application_options) || !SET(qlog)) {
+      !SET(transport_params) || !SET(tls_options) || !SET(qlog) ||
+      !SET(application_provider) || !SET(handshake_timeout) ||
+      !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) ||
+      !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm)) {
     return Nothing<Options>();
   }
 
@@ -437,7 +474,6 @@ Maybe<Session::Options> Session::Options::From(Environment* env,
 void Session::Options::MemoryInfo(MemoryTracker* tracker) const {
   tracker->TrackField("transport_params", transport_params);
   tracker->TrackField("crypto_options", tls_options);
-  tracker->TrackField("application_options", application_options);
   tracker->TrackField("cid_factory_ref", cid_factory_ref);
 }
 
@@ -447,1832 +483,2283 @@ std::string Session::Options::ToString() const {
   std::string res("{");
   res += prefix + "version: " + std::to_string(version);
   res += prefix + "min version: " + std::to_string(min_version);
-
-  auto policy = ([&] {
-    switch (preferred_address_strategy) {
-      case PreferredAddress::Policy::USE_PREFERRED:
-        return "use";
-      case PreferredAddress::Policy::IGNORE_PREFERRED:
-        return "ignore";
-    }
-    return "<unknown>";
-  })();
-  res += prefix + "preferred address policy: " + std::string(policy);
+  res += prefix +
+         "preferred address policy: " + to_string(preferred_address_strategy);
   res += prefix + "transport params: " + transport_params.ToString();
   res += prefix + "crypto options: " + tls_options.ToString();
-  res += prefix + "application options: " + application_options.ToString();
-  res += prefix + "qlog: " + (qlog ? std::string("yes") : std::string("no"));
+  if (qlog) {
+    res += prefix + "qlog: yes";
+  }
+  if (handshake_timeout == UINT64_MAX) {
+    res += prefix + "handshake timeout: <none>";
+  } else {
+    res += prefix + "handshake timeout: " + std::to_string(handshake_timeout) +
+           " nanoseconds";
+  }
+  res += prefix + "max stream window: " + std::to_string(max_stream_window);
+  res += prefix + "max window: " + std::to_string(max_window);
+  res += prefix + "max payload size: " + std::to_string(max_payload_size);
+  if (unacknowledged_packet_threshold != 0) {
+    res += prefix + "unacknowledged packet threshold: " +
+           std::to_string(unacknowledged_packet_threshold);
+  } else {
+    res += prefix + "unacknowledged packet threshold: <default>";
+  }
+  res += prefix + "cc algorithm: " + to_string(cc_algorithm);
   res += indent.Close();
   return res;
 }
 
 // ============================================================================
+// ngtcp2 static callback functions
 
-bool Session::HasInstance(Environment* env, Local<Value> value) {
-  return GetConstructorTemplate(env)->HasInstance(value);
-}
+// Utility used only within Session::Impl to reduce boilerplate
+#define NGTCP2_CALLBACK_SCOPE(name)                                            \
+  auto name = Impl::From(conn, user_data);                                     \
+  if (name == nullptr) return NGTCP2_ERR_CALLBACK_FAILURE;                     \
+  NgTcp2CallbackScope scope(name->env());
+
+// Session::Impl maintains most of the internal state of an active Session.
+struct Session::Impl final : public MemoryRetainer {
+  Session* session_;
+  AliasedStruct<Stats> stats_;
+  AliasedStruct<State> state_;
+  BaseObjectWeakPtr<Endpoint> endpoint_;
+  Config config_;
+  SocketAddress local_address_;
+  SocketAddress remote_address_;
+  std::unique_ptr<Application> application_;
+  StreamsMap streams_;
+  TimerWrapHandle timer_;
+  size_t send_scope_depth_ = 0;
+  QuicError last_error_;
+  PendingStream::PendingStreamQueue pending_bidi_stream_queue_;
+  PendingStream::PendingStreamQueue pending_uni_stream_queue_;
+
+  Impl(Session* session, Endpoint* endpoint, const Config& config)
+      : session_(session),
+        stats_(env()->isolate()),
+        state_(env()->isolate()),
+        endpoint_(endpoint),
+        config_(config),
+        local_address_(config.local_address),
+        remote_address_(config.remote_address),
+        application_(SelectApplication(session, config_)),
+        timer_(session_->env(), [this] { session_->OnTimeout(); }) {
+    timer_.Unref();
+  }
+
+  inline bool is_closing() const { return state_->closing; }
+
+  /**
+   * @returns {boolean} Returns true if the Session can be destroyed
+   * immediately.
+   */
+  bool Close() {
+    if (state_->closing) return true;
+    state_->closing = 1;
+    STAT_RECORD_TIMESTAMP(Stats, closing_at);
+
+    // Iterate through all of the known streams and close them. The streams
+    // will remove themselves from the Session as soon as they are closed.
+    // Note: we create a copy because the streams will remove themselves
+    // while they are cleaning up which will invalidate the iterator.
+    StreamsMap streams = streams_;
+    for (auto& stream : streams) stream.second->Destroy(last_error_);
+    DCHECK(streams.empty());
+
+    // Clear the pending streams.
+    while (!pending_bidi_stream_queue_.IsEmpty()) {
+      pending_bidi_stream_queue_.PopFront()->reject(last_error_);
+    }
+    while (!pending_uni_stream_queue_.IsEmpty()) {
+      pending_uni_stream_queue_.PopFront()->reject(last_error_);
+    }
 
-BaseObjectPtr<Session> Session::Create(
-    Endpoint* endpoint,
-    const Config& config,
-    TLSContext* tls_context,
-    const std::optional<SessionTicket>& ticket) {
-  Local<Object> obj;
-  if (!GetConstructorTemplate(endpoint->env())
-           ->InstanceTemplate()
-           ->NewInstance(endpoint->env()->context())
-           .ToLocal(&obj)) {
-    return BaseObjectPtr<Session>();
-  }
+    // If we are able to send packets, we should try sending a connection
+    // close packet to the remote peer.
+    if (!state_->silent_close) {
+      session_->SendConnectionClose();
+    }
 
-  return MakeDetachedBaseObject<Session>(
-      endpoint, obj, config, tls_context, ticket);
-}
+    timer_.Close();
 
-Session::Session(Endpoint* endpoint,
-                 v8::Local<v8::Object> object,
-                 const Config& config,
-                 TLSContext* tls_context,
-                 const std::optional<SessionTicket>& session_ticket)
-    : AsyncWrap(endpoint->env(), object, AsyncWrap::PROVIDER_QUIC_SESSION),
-      stats_(env()->isolate()),
-      state_(env()->isolate()),
-      allocator_(BindingData::Get(env())),
-      endpoint_(BaseObjectWeakPtr<Endpoint>(endpoint)),
-      config_(config),
-      local_address_(config.local_address),
-      remote_address_(config.remote_address),
-      connection_(InitConnection()),
-      tls_session_(tls_context->NewSession(this, session_ticket)),
-      application_(select_application()),
-      timer_(env(),
-             [this, self = BaseObjectPtr<Session>(this)] { OnTimeout(); }) {
-  MakeWeak();
+    return !state_->wrapped;
+  }
 
-  Debug(this, "Session created.");
+  ~Impl() {
+    // Ensure that Close() was called before dropping
+    DCHECK(is_closing());
+    DCHECK(endpoint_);
 
-  timer_.Unref();
+    // Removing the session from the endpoint may cause the endpoint to be
+    // destroyed if it is waiting on the last session to be destroyed. Let's
+    // grab a reference just to be safe for the rest of the function.
+    BaseObjectPtr<Endpoint> endpoint = endpoint_;
+    endpoint_.reset();
 
-  application().ExtendMaxStreams(EndpointLabel::LOCAL,
-                                 Direction::BIDIRECTIONAL,
-                                 TransportParams::DEFAULT_MAX_STREAMS_BIDI);
-  application().ExtendMaxStreams(EndpointLabel::LOCAL,
-                                 Direction::UNIDIRECTIONAL,
-                                 TransportParams::DEFAULT_MAX_STREAMS_UNI);
+    MaybeStackBuffer<ngtcp2_cid, 10> cids(
+        ngtcp2_conn_get_scid(*session_, nullptr));
+    ngtcp2_conn_get_scid(*session_, cids.out());
 
-  const auto defineProperty = [&](auto name, auto value) {
-    object
-        ->DefineOwnProperty(
-            env()->context(), name, value, PropertyAttribute::ReadOnly)
-        .Check();
-  };
+    MaybeStackBuffer<ngtcp2_cid_token, 10> tokens(
+        ngtcp2_conn_get_active_dcid(*session_, nullptr));
+    ngtcp2_conn_get_active_dcid(*session_, tokens.out());
 
-  defineProperty(env()->state_string(), state_.GetArrayBuffer());
-  defineProperty(env()->stats_string(), stats_.GetArrayBuffer());
+    endpoint->DisassociateCID(config_.dcid);
+    endpoint->DisassociateCID(config_.preferred_address_cid);
 
-  auto& state = BindingData::Get(env());
+    for (size_t n = 0; n < cids.length(); n++) {
+      endpoint->DisassociateCID(CID(cids[n]));
+    }
 
-  if (config_.options.qlog) [[unlikely]] {
-    qlog_stream_ = LogStream::Create(env());
-    if (qlog_stream_)
-      defineProperty(state.qlog_string(), qlog_stream_->object());
-  }
+    for (size_t n = 0; n < tokens.length(); n++) {
+      if (tokens[n].token_present) {
+        endpoint->DisassociateStatelessResetToken(
+            StatelessResetToken(tokens[n].token));
+      }
+    }
 
-  if (config_.options.tls_options.keylog) [[unlikely]] {
-    keylog_stream_ = LogStream::Create(env());
-    if (keylog_stream_)
-      defineProperty(state.keylog_string(), keylog_stream_->object());
+    endpoint->RemoveSession(config_.scid, remote_address_);
   }
 
-  // We index the Session by our local CID (the scid) and dcid (the peer's cid)
-  endpoint_->AddSession(config_.scid, BaseObjectPtr<Session>(this));
-  endpoint_->AssociateCID(config_.dcid, config_.scid);
+  void MemoryInfo(MemoryTracker* tracker) const override {
+    tracker->TrackField("config", config_);
+    tracker->TrackField("endpoint", endpoint_);
+    tracker->TrackField("streams", streams_);
+    tracker->TrackField("local_address", local_address_);
+    tracker->TrackField("remote_address", remote_address_);
+    tracker->TrackField("application", application_);
+    tracker->TrackField("timer", timer_);
+  }
+  SET_SELF_SIZE(Impl)
+  SET_MEMORY_INFO_NAME(Session::Impl)
 
-  UpdateDataStats();
-}
+  Environment* env() const { return session_->env(); }
 
-Session::~Session() {
-  Debug(this, "Session destroyed.");
-  if (conn_closebuf_) {
-    conn_closebuf_->Done(0);
+  // Gets the Session pointer from the user_data void pointer
+  // provided by ngtcp2.
+  static Session* From(ngtcp2_conn* conn, void* user_data) {
+    if (user_data == nullptr) [[unlikely]] {
+      return nullptr;
+    }
+    auto session = static_cast<Session*>(user_data);
+    if (session->is_destroyed()) [[unlikely]] {
+      return nullptr;
+    }
+    return session;
   }
-  if (qlog_stream_) {
-    Debug(this, "Closing the qlog stream for this session");
-    env()->SetImmediate(
-        [ptr = std::move(qlog_stream_)](Environment*) { ptr->End(); });
+
+  // JavaScript APIs
+
+  static void Destroy(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
+    session->Destroy();
   }
-  if (keylog_stream_) {
-    Debug(this, "Closing the keylog stream for this session");
-    env()->SetImmediate(
-        [ptr = std::move(keylog_stream_)](Environment*) { ptr->End(); });
+
+  static void GetRemoteAddress(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
+
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
+
+    auto address = session->remote_address();
+    args.GetReturnValue().Set(
+        SocketAddressBase::Create(env, std::make_shared<SocketAddress>(address))
+            ->object());
   }
-  DCHECK(streams_.empty());
-}
 
-size_t Session::max_packet_size() const {
-  return ngtcp2_conn_get_max_tx_udp_payload_size(*this);
-}
+  static void GetCertificate(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-Session::operator ngtcp2_conn*() const {
-  return connection_.get();
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-uint32_t Session::version() const {
-  return config_.version;
-}
+    Local<Value> ret;
+    if (session->tls_session().cert(env).ToLocal(&ret))
+      args.GetReturnValue().Set(ret);
+  }
 
-Endpoint& Session::endpoint() const {
-  return *endpoint_;
-}
+  static void GetEphemeralKeyInfo(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-TLSSession& Session::tls_session() {
-  return *tls_session_;
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-Session::Application& Session::application() {
-  return *application_;
-}
+    Local<Object> ret;
+    if (!session->is_server() &&
+        session->tls_session().ephemeral_key(env).ToLocal(&ret))
+      args.GetReturnValue().Set(ret);
+  }
 
-const SocketAddress& Session::remote_address() const {
-  return remote_address_;
-}
+  static void GetPeerCertificate(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-const SocketAddress& Session::local_address() const {
-  return local_address_;
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-bool Session::is_closing() const {
-  return state_->closing;
-}
+    Local<Value> ret;
+    if (session->tls_session().peer_cert(env).ToLocal(&ret))
+      args.GetReturnValue().Set(ret);
+  }
 
-bool Session::is_graceful_closing() const {
-  return state_->graceful_close;
-}
+  static void GracefulClose(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-bool Session::is_silent_closing() const {
-  return state_->silent_close;
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-bool Session::is_destroyed() const {
-  return state_->destroyed;
-}
+    session->Close(Session::CloseMethod::GRACEFUL);
+  }
 
-bool Session::is_server() const {
-  return config_.side == Side::SERVER;
-}
+  static void SilentClose(const FunctionCallbackInfo<Value>& args) {
+    // This is exposed for testing purposes only!
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-std::string Session::diagnostic_name() const {
-  const auto get_type = [&] { return is_server() ? "server" : "client"; };
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-  return std::string("Session (") + get_type() + "," +
-         std::to_string(env()->thread_id()) + ":" +
-         std::to_string(static_cast<int64_t>(get_async_id())) + ")";
-}
+    session->Close(Session::CloseMethod::SILENT);
+  }
 
-const Session::Config& Session::config() const {
-  return config_;
-}
+  static void UpdateKey(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-const Session::Options& Session::options() const {
-  return config_.options;
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-void Session::HandleQlog(uint32_t flags, const void* data, size_t len) {
-  if (qlog_stream_) {
-    // Fun fact... ngtcp2 does not emit the final qlog statement until the
-    // ngtcp2_conn object is destroyed. Ideally, destroying is explicit, but
-    // sometimes the Session object can be garbage collected without being
-    // explicitly destroyed. During those times, we cannot call out to
-    // JavaScript. Because we don't know for sure if we're in in a GC when this
-    // is called, it is safer to just defer writes to immediate, and to keep it
-    // consistent, let's just always defer (this is not performance sensitive so
-    // the deferring is fine).
-    std::vector<uint8_t> buffer(len);
-    memcpy(buffer.data(), data, len);
-    Debug(this, "Emitting qlog data to the qlog stream");
-    env()->SetImmediate(
-        [ptr = qlog_stream_, buffer = std::move(buffer), flags](Environment*) {
-          ptr->Emit(buffer.data(),
-                    buffer.size(),
-                    flags & NGTCP2_QLOG_WRITE_FLAG_FIN
-                        ? LogStream::EmitOption::FIN
-                        : LogStream::EmitOption::NONE);
-        });
+    // Initiating a key update may fail if it is done too early (either
+    // before the TLS handshake has been confirmed or while a previous
+    // key update is being processed). When it fails, InitiateKeyUpdate()
+    // will return false.
+    SendPendingDataScope send_scope(session);
+    args.GetReturnValue().Set(session->tls_session().InitiateKeyUpdate());
   }
-}
 
-TransportParams Session::GetLocalTransportParams() const {
-  DCHECK(!is_destroyed());
-  return TransportParams(ngtcp2_conn_get_local_transport_params(*this));
-}
+  static void OpenStream(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-TransportParams Session::GetRemoteTransportParams() const {
-  DCHECK(!is_destroyed());
-  return TransportParams(ngtcp2_conn_get_remote_transport_params(*this));
-}
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-void Session::SetLastError(QuicError&& error) {
-  Debug(this, "Setting last error to %s", error);
-  last_error_ = std::move(error);
-}
+    DCHECK(args[0]->IsUint32());
 
-void Session::Close(Session::CloseMethod method) {
-  if (is_destroyed()) return;
-  switch (method) {
-    case CloseMethod::DEFAULT: {
-      Debug(this, "Closing session");
-      DoClose(false);
-      break;
+    // GetDataQueueFromSource handles type validation.
+    std::shared_ptr<DataQueue> data_source =
+        Stream::GetDataQueueFromSource(env, args[1]).ToChecked();
+    if (data_source == nullptr) {
+      THROW_ERR_INVALID_ARG_VALUE(env, "Invalid data source");
     }
-    case CloseMethod::SILENT: {
-      Debug(this, "Closing session silently");
-      DoClose(true);
-      break;
-    }
-    case CloseMethod::GRACEFUL: {
-      if (is_graceful_closing()) return;
-      Debug(this, "Closing session gracefully");
-      // If there are no open streams, then we can close just immediately and
-      // not worry about waiting around for the right moment.
-      if (streams_.empty()) {
-        DoClose(false);
-      } else {
-        state_->graceful_close = 1;
-        STAT_RECORD_TIMESTAMP(Stats, graceful_closing_at);
-      }
-      break;
+
+    SendPendingDataScope send_scope(session);
+    auto direction = static_cast<Direction>(args[0].As<Uint32>()->Value());
+    Local<Object> stream;
+    if (session->OpenStream(direction, std::move(data_source)).ToLocal(&stream))
+        [[likely]] {
+      args.GetReturnValue().Set(stream);
     }
   }
-}
 
-void Session::Destroy() {
-  if (is_destroyed()) return;
-  Debug(this, "Session destroyed");
+  static void SendDatagram(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-  // The DoClose() method should have already been called.
-  DCHECK(state_->closing);
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-  // We create a copy of the streams because they will remove themselves
-  // from streams_ as they are cleaning up, causing the iterator to be
-  // invalidated.
-  auto streams = streams_;
-  for (auto& stream : streams) stream.second->Destroy(last_error_);
-  DCHECK(streams_.empty());
+    DCHECK(args[0]->IsArrayBufferView());
+    SendPendingDataScope send_scope(session);
+    args.GetReturnValue().Set(BigInt::New(
+        env->isolate(),
+        session->SendDatagram(Store(args[0].As<ArrayBufferView>()))));
+  }
 
-  STAT_RECORD_TIMESTAMP(Stats, destroyed_at);
-  state_->closing = 0;
-  state_->graceful_close = 0;
+  // Internal ngtcp2 callbacks
 
-  timer_.Stop();
+  static int on_acknowledge_stream_data_offset(ngtcp2_conn* conn,
+                                               int64_t stream_id,
+                                               uint64_t offset,
+                                               uint64_t datalen,
+                                               void* user_data,
+                                               void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    // The callback will be invoked with datalen 0 if a zero-length
+    // stream frame with fin flag set is received. In that case, let's
+    // just ignore it.
+    // Per ngtcp2, the range of bytes that are being acknowledged here
+    // are `[offset, offset + datalen]` but we only really care about
+    // the datalen as our accounting does not track the offset and
+    // acknowledges should never come out of order here.
+    if (datalen == 0) return NGTCP2_SUCCESS;
+    return session->application().AcknowledgeStreamData(stream_id, datalen)
+               ? NGTCP2_SUCCESS
+               : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-  // The Session instances are kept alive using a in the Endpoint. Removing the
-  // Session from the Endpoint will free that pointer, allowing the Session to
-  // be deconstructed once the stack unwinds and any remaining
-  // BaseObjectPtr<Session> instances fall out of scope.
+  static int on_acknowledge_datagram(ngtcp2_conn* conn,
+                                     uint64_t dgram_id,
+                                     void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->DatagramStatus(dgram_id, quic::DatagramStatus::ACKNOWLEDGED);
+    return NGTCP2_SUCCESS;
+  }
 
-  MaybeStackBuffer<ngtcp2_cid, 10> cids(ngtcp2_conn_get_scid(*this, nullptr));
-  ngtcp2_conn_get_scid(*this, cids.out());
+  static int on_cid_status(ngtcp2_conn* conn,
+                           ngtcp2_connection_id_status_type type,
+                           uint64_t seq,
+                           const ngtcp2_cid* cid,
+                           const uint8_t* token,
+                           void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    std::optional<StatelessResetToken> maybe_reset_token;
+    if (token != nullptr) maybe_reset_token.emplace(token);
+    auto& endpoint = session->endpoint();
+    switch (type) {
+      case NGTCP2_CONNECTION_ID_STATUS_TYPE_ACTIVATE: {
+        endpoint.AssociateCID(session->config().scid, CID(cid));
+        if (token != nullptr) {
+          endpoint.AssociateStatelessResetToken(StatelessResetToken(token),
+                                                session);
+        }
+        break;
+      }
+      case NGTCP2_CONNECTION_ID_STATUS_TYPE_DEACTIVATE: {
+        endpoint.DisassociateCID(CID(cid));
+        if (token != nullptr) {
+          endpoint.DisassociateStatelessResetToken(StatelessResetToken(token));
+        }
+        break;
+      }
+    }
+    return NGTCP2_SUCCESS;
+  }
 
-  MaybeStackBuffer<ngtcp2_cid_token, 10> tokens(
-      ngtcp2_conn_get_active_dcid(*this, nullptr));
-  ngtcp2_conn_get_active_dcid(*this, tokens.out());
+  static int on_extend_max_remote_streams_bidi(ngtcp2_conn* conn,
+                                               uint64_t max_streams,
+                                               void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    // TODO(@jasnell): Do anything here?
+    return NGTCP2_SUCCESS;
+  }
 
-  endpoint_->DisassociateCID(config_.dcid);
-  endpoint_->DisassociateCID(config_.preferred_address_cid);
+  static int on_extend_max_remote_streams_uni(ngtcp2_conn* conn,
+                                              uint64_t max_streams,
+                                              void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    // TODO(@jasnell): Do anything here?
+    return NGTCP2_SUCCESS;
+  }
 
-  for (size_t n = 0; n < cids.length(); n++) {
-    endpoint_->DisassociateCID(CID(cids[n]));
+  static int on_extend_max_streams_bidi(ngtcp2_conn* conn,
+                                        uint64_t max_streams,
+                                        void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->ProcessPendingBidiStreams();
+    return NGTCP2_SUCCESS;
   }
 
-  for (size_t n = 0; n < tokens.length(); n++) {
-    if (tokens[n].token_present) {
-      endpoint_->DisassociateStatelessResetToken(
-          StatelessResetToken(tokens[n].token));
-    }
+  static int on_extend_max_streams_uni(ngtcp2_conn* conn,
+                                       uint64_t max_streams,
+                                       void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->ProcessPendingUniStreams();
+    return NGTCP2_SUCCESS;
   }
 
-  state_->destroyed = 1;
+  static int on_extend_max_stream_data(ngtcp2_conn* conn,
+                                       int64_t stream_id,
+                                       uint64_t max_data,
+                                       void* user_data,
+                                       void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->application().ExtendMaxStreamData(Stream::From(stream_user_data),
+                                               max_data);
+    return NGTCP2_SUCCESS;
+  }
 
-  // Removing the session from the endpoint may cause the endpoint to be
-  // destroyed if it is waiting on the last session to be destroyed. Let's grab
-  // a reference just to be safe for the rest of the function.
-  BaseObjectPtr<Endpoint> endpoint = std::move(endpoint_);
-  endpoint->RemoveSession(config_.scid);
-}
+  static int on_get_new_cid(ngtcp2_conn* conn,
+                            ngtcp2_cid* cid,
+                            uint8_t* token,
+                            size_t cidlen,
+                            void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->GenerateNewConnectionId(cid, cidlen, token);
+    return NGTCP2_SUCCESS;
+  }
 
-bool Session::Receive(Store&& store,
-                      const SocketAddress& local_address,
-                      const SocketAddress& remote_address) {
-  if (is_destroyed()) return false;
+  static int on_handshake_completed(ngtcp2_conn* conn, void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    return session->HandshakeCompleted() ? NGTCP2_SUCCESS
+                                         : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-  const auto receivePacket = [&](ngtcp2_path* path, ngtcp2_vec vec) {
-    DCHECK(!is_destroyed());
+  static int on_handshake_confirmed(ngtcp2_conn* conn, void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->HandshakeConfirmed();
+    return NGTCP2_SUCCESS;
+  }
 
-    uint64_t now = uv_hrtime();
-    ngtcp2_pkt_info pi{};  // Not used but required.
-    int err = ngtcp2_conn_read_pkt(*this, path, &pi, vec.base, vec.len, now);
-    switch (err) {
-      case 0: {
-        // Return true so we send after receiving.
-        Debug(this, "Session successfully received packet");
-        return true;
-      }
-      case NGTCP2_ERR_DRAINING: {
-        // Connection has entered the draining state, no further data should be
-        // sent. This happens when the remote peer has sent a CONNECTION_CLOSE.
-        Debug(this, "Session is draining");
-        return false;
-      }
-      case NGTCP2_ERR_CLOSING: {
-        // Connection has entered the closing state, no further data should be
-        // sent. This happens when the local peer has called
-        // ngtcp2_conn_write_connection_close.
-        Debug(this, "Session is closing");
-        return false;
-      }
-      case NGTCP2_ERR_CRYPTO: {
-        // Crypto error happened! Set the last error to the tls alert
-        last_error_ = QuicError::ForTlsAlert(ngtcp2_conn_get_tls_alert(*this));
-        Debug(this, "Crypto error while receiving packet: %s", last_error_);
-        Close();
-        return false;
-      }
-      case NGTCP2_ERR_RETRY: {
-        // This should only ever happen on the server. We have to send a path
-        // validation challenge in the form of a RETRY packet to the peer and
-        // drop the connection.
-        DCHECK(is_server());
-        Debug(this, "Server must send a retry packet");
-        endpoint_->SendRetry(PathDescriptor{
-            version(),
-            config_.dcid,
-            config_.scid,
-            local_address_,
-            remote_address_,
-        });
-        Close(CloseMethod::SILENT);
-        return false;
-      }
-      case NGTCP2_ERR_DROP_CONN: {
-        // There's nothing else to do but drop the connection state.
-        Debug(this, "Session must drop the connection");
-        Close(CloseMethod::SILENT);
-        return false;
-      }
+  static int on_lost_datagram(ngtcp2_conn* conn,
+                              uint64_t dgram_id,
+                              void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->DatagramStatus(dgram_id, quic::DatagramStatus::LOST);
+    return NGTCP2_SUCCESS;
+  }
+
+  static int on_path_validation(ngtcp2_conn* conn,
+                                uint32_t flags,
+                                const ngtcp2_path* path,
+                                const ngtcp2_path* old_path,
+                                ngtcp2_path_validation_result res,
+                                void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    bool flag_preferred_address =
+        flags & NGTCP2_PATH_VALIDATION_FLAG_PREFERRED_ADDR;
+    ValidatedPath newValidatedPath{
+        std::make_shared<SocketAddress>(path->local.addr),
+        std::make_shared<SocketAddress>(path->remote.addr)};
+    std::optional<ValidatedPath> oldValidatedPath = std::nullopt;
+    if (old_path != nullptr) {
+      oldValidatedPath =
+          ValidatedPath{std::make_shared<SocketAddress>(old_path->local.addr),
+                        std::make_shared<SocketAddress>(old_path->remote.addr)};
     }
-    // Shouldn't happen but just in case.
-    last_error_ = QuicError::ForNgtcp2Error(err);
-    Debug(this, "Error while receiving packet: %s (%d)", last_error_, err);
-    Close();
-    return false;
-  };
+    session->EmitPathValidation(static_cast<PathValidationResult>(res),
+                                PathValidationFlags{flag_preferred_address},
+                                newValidatedPath,
+                                oldValidatedPath);
+    return NGTCP2_SUCCESS;
+  }
 
-  auto update_stats = OnScopeLeave([&] { UpdateDataStats(); });
-  remote_address_ = remote_address;
-  Path path(local_address, remote_address_);
-  Debug(this, "Session is receiving packet received along path %s", path);
-  STAT_INCREMENT_N(Stats, bytes_received, store.length());
-  if (receivePacket(&path, store)) application().SendPendingData();
+  static int on_receive_datagram(ngtcp2_conn* conn,
+                                 uint32_t flags,
+                                 const uint8_t* data,
+                                 size_t datalen,
+                                 void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->DatagramReceived(
+        data,
+        datalen,
+        DatagramReceivedFlags{
+            .early = (flags & NGTCP2_DATAGRAM_FLAG_0RTT) ==
+                     NGTCP2_DATAGRAM_FLAG_0RTT,
+        });
+    return NGTCP2_SUCCESS;
+  }
 
-  if (!is_destroyed()) UpdateTimer();
+  static int on_receive_new_token(ngtcp2_conn* conn,
+                                  const uint8_t* token,
+                                  size_t tokenlen,
+                                  void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    // We currently do nothing with this callback.
+    return NGTCP2_SUCCESS;
+  }
 
-  return true;
-}
+  static int on_receive_rx_key(ngtcp2_conn* conn,
+                               ngtcp2_encryption_level level,
+                               void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    CHECK(!session->is_server());
 
-void Session::Send(Packet* packet) {
-  // Sending a Packet is generally best effort. If we're not in a state
-  // where we can send a packet, it's ok to drop it on the floor. The
-  // packet loss mechanisms will cause the packet data to be resent later
-  // if appropriate (and possible).
-  DCHECK(!is_destroyed());
-  DCHECK(!is_in_draining_period());
+    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
 
-  if (can_send_packets() && packet->length() > 0) {
-    Debug(this, "Session is sending %s", packet->ToString());
-    STAT_INCREMENT_N(Stats, bytes_sent, packet->length());
-    endpoint_->Send(packet);
-    return;
-  }
+    Debug(session,
+          "Receiving RX key for level %s for dcid %s",
+          to_string(level),
+          session->config().dcid);
 
-  Debug(this, "Session could not send %s", packet->ToString());
-  packet->Done(packet->length() > 0 ? UV_ECANCELED : 0);
-}
+    return session->application().Start() ? NGTCP2_SUCCESS
+                                          : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-void Session::Send(Packet* packet, const PathStorage& path) {
-  UpdatePath(path);
-  Send(packet);
-}
+  static int on_receive_stateless_reset(ngtcp2_conn* conn,
+                                        const ngtcp2_pkt_stateless_reset* sr,
+                                        void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->impl_->state_->stateless_reset = 1;
+    return NGTCP2_SUCCESS;
+  }
 
-void Session::UpdatePacketTxTime() {
-  ngtcp2_conn_update_pkt_tx_time(*this, uv_hrtime());
-}
+  static int on_receive_stream_data(ngtcp2_conn* conn,
+                                    uint32_t flags,
+                                    int64_t stream_id,
+                                    uint64_t offset,
+                                    const uint8_t* data,
+                                    size_t datalen,
+                                    void* user_data,
+                                    void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    Stream::ReceiveDataFlags data_flags{
+        // The fin flag indicates that this is the last chunk of data we will
+        // receive on this stream.
+        .fin = (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ==
+               NGTCP2_STREAM_DATA_FLAG_FIN,
+        // Stream data is early if it is received before the TLS handshake is
+        // complete.
+        .early = (flags & NGTCP2_STREAM_DATA_FLAG_0RTT) ==
+                 NGTCP2_STREAM_DATA_FLAG_0RTT,
+    };
+
+    // We received data for a stream! What we don't know yet at this point
+    // is whether the application wants us to treat this as a control stream
+    // data (something the application will handle on its own) or a user stream
+    // data (something that we should create a Stream handle for that is passed
+    // out to JavaScript). HTTP3, for instance, will generally create three
+    // control stream in either direction and we want to make sure those are
+    // never exposed to users and that we don't waste time creating Stream
+    // handles for them. So, what we do here is pass the stream data on to the
+    // application for processing. If it ends up being a user stream, the
+    // application will handle creating the Stream handle and passing that off
+    // to the JavaScript side.
+    if (!session->application().ReceiveStreamData(
+            stream_id, data, datalen, data_flags, stream_user_data)) {
+      return NGTCP2_ERR_CALLBACK_FAILURE;
+    }
 
-uint64_t Session::SendDatagram(Store&& data) {
-  auto tp = ngtcp2_conn_get_remote_transport_params(*this);
-  uint64_t max_datagram_size = tp->max_datagram_frame_size;
-  if (max_datagram_size == 0 || data.length() > max_datagram_size) {
-    // Datagram is too large.
-    Debug(this, "Data is too large to send as a datagram");
-    return 0;
+    return NGTCP2_SUCCESS;
   }
 
-  Debug(this, "Session is sending datagram");
-  Packet* packet = nullptr;
-  uint8_t* pos = nullptr;
-  int accepted = 0;
-  ngtcp2_vec vec = data;
-  PathStorage path;
-  int flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE;
-  uint64_t did = state_->last_datagram_id + 1;
+  static int on_receive_tx_key(ngtcp2_conn* conn,
+                               ngtcp2_encryption_level level,
+                               void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session);
+    CHECK(session->is_server());
 
-  // Let's give it a max number of attempts to send the datagram
-  static const int kMaxAttempts = 16;
-  int attempts = 0;
+    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
 
-  for (;;) {
-    // We may have to make several attempts at encoding and sending the
-    // datagram packet. On each iteration here we'll try to encode the
-    // datagram. It's entirely up to ngtcp2 whether to include the datagram
-    // in the packet on each call to ngtcp2_conn_writev_datagram.
-    if (packet == nullptr) {
-      packet = Packet::Create(env(),
-                              endpoint_.get(),
-                              remote_address_,
-                              ngtcp2_conn_get_max_tx_udp_payload_size(*this),
-                              "datagram");
-      // Typically sending datagrams is best effort, but if we cannot create
-      // the packet, then we handle it as a fatal error.
-      if (packet == nullptr) {
-        last_error_ = QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL);
-        Close(CloseMethod::SILENT);
-        return 0;
-      }
-      pos = ngtcp2_vec(*packet).base;
-    }
+    Debug(session,
+          "Receiving TX key for level %s for dcid %s",
+          to_string(level),
+          session->config().dcid);
+    return session->application().Start() ? NGTCP2_SUCCESS
+                                          : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-    ssize_t nwrite = ngtcp2_conn_writev_datagram(*this,
-                                                 &path.path,
-                                                 nullptr,
-                                                 pos,
-                                                 packet->length(),
-                                                 &accepted,
-                                                 flags,
-                                                 did,
-                                                 &vec,
-                                                 1,
-                                                 uv_hrtime());
-    ngtcp2_conn_update_pkt_tx_time(*this, uv_hrtime());
+  static int on_receive_version_negotiation(ngtcp2_conn* conn,
+                                            const ngtcp2_pkt_hd* hd,
+                                            const uint32_t* sv,
+                                            size_t nsv,
+                                            void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->EmitVersionNegotiation(*hd, sv, nsv);
+    return NGTCP2_SUCCESS;
+  }
 
-    if (nwrite <= 0) {
-      // Nothing was written to the packet.
-      switch (nwrite) {
-        case 0: {
-          // We cannot send data because of congestion control or the data will
-          // not fit. Since datagrams are best effort, we are going to abandon
-          // the attempt and just return.
-          CHECK_EQ(accepted, 0);
-          packet->Done(UV_ECANCELED);
-          return 0;
-        }
-        case NGTCP2_ERR_WRITE_MORE: {
-          // We keep on looping! Keep on sending!
-          continue;
-        }
-        case NGTCP2_ERR_INVALID_STATE: {
-          // The remote endpoint does not want to accept datagrams. That's ok,
-          // just return 0.
-          packet->Done(UV_ECANCELED);
-          return 0;
-        }
-        case NGTCP2_ERR_INVALID_ARGUMENT: {
-          // The datagram is too large. That should have been caught above but
-          // that's ok. We'll just abandon the attempt and return.
-          packet->Done(UV_ECANCELED);
-          return 0;
-        }
-        case NGTCP2_ERR_PKT_NUM_EXHAUSTED: {
-          // We've exhausted the packet number space. Sadly we have to treat it
-          // as a fatal condition.
-          break;
-        }
-        case NGTCP2_ERR_CALLBACK_FAILURE: {
-          // There was an internal failure. Sadly we have to treat it as a fatal
-          // condition.
-          break;
-        }
-      }
-      packet->Done(UV_ECANCELED);
-      last_error_ = QuicError::ForNgtcp2Error(nwrite);
-      Close(CloseMethod::SILENT);
-      return 0;
-    }
+  static int on_remove_connection_id(ngtcp2_conn* conn,
+                                     const ngtcp2_cid* cid,
+                                     void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->endpoint().DisassociateCID(CID(cid));
+    return NGTCP2_SUCCESS;
+  }
 
-    // In this case, a complete packet was written and we need to send it along.
-    // Note that this doesn't mean that the packet actually contains the
-    // datagram! We'll check that next by checking the accepted value.
-    packet->Truncate(nwrite);
-    Send(std::move(packet));
+  static int on_select_preferred_address(ngtcp2_conn* conn,
+                                         ngtcp2_path* dest,
+                                         const ngtcp2_preferred_addr* paddr,
+                                         void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    PreferredAddress preferred_address(dest, paddr);
+    session->SelectPreferredAddress(&preferred_address);
+    return NGTCP2_SUCCESS;
+  }
 
-    if (accepted != 0) {
-      // Yay! The datagram was accepted into the packet we just sent and we can
-      // return the datagram ID.
-      Debug(this, "Session successfully encoded datagram");
-      STAT_INCREMENT(Stats, datagrams_sent);
-      STAT_INCREMENT_N(Stats, bytes_sent, vec.len);
-      state_->last_datagram_id = did;
-      return did;
+  static int on_stream_close(ngtcp2_conn* conn,
+                             uint32_t flags,
+                             int64_t stream_id,
+                             uint64_t app_error_code,
+                             void* user_data,
+                             void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) {
+      session->application().StreamClose(
+          Stream::From(stream_user_data),
+          QuicError::ForApplication(app_error_code));
+    } else {
+      session->application().StreamClose(Stream::From(stream_user_data));
     }
+    return NGTCP2_SUCCESS;
+  }
 
-    // We sent a packet, but it wasn't the datagram packet. That can happen.
-    // Let's loop around and try again.
-    if (++attempts == kMaxAttempts) {
-      Debug(this, "Too many attempts to send the datagram");
-      // Too many attempts to send the datagram.
-      break;
-    }
+  static int on_stream_reset(ngtcp2_conn* conn,
+                             int64_t stream_id,
+                             uint64_t final_size,
+                             uint64_t app_error_code,
+                             void* user_data,
+                             void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->application().StreamReset(
+        Stream::From(stream_user_data),
+        final_size,
+        QuicError::ForApplication(app_error_code));
+    return NGTCP2_SUCCESS;
   }
 
-  return 0;
+  static int on_stream_stop_sending(ngtcp2_conn* conn,
+                                    int64_t stream_id,
+                                    uint64_t app_error_code,
+                                    void* user_data,
+                                    void* stream_user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->application().StreamStopSending(
+        Stream::From(stream_user_data),
+        QuicError::ForApplication(app_error_code));
+    return NGTCP2_SUCCESS;
+  }
+
+  static void on_rand(uint8_t* dest,
+                      size_t destlen,
+                      const ngtcp2_rand_ctx* rand_ctx) {
+    CHECK(ncrypto::CSPRNG(dest, destlen));
+  }
+
+  static int on_early_data_rejected(ngtcp2_conn* conn, void* user_data) {
+    // TODO(@jasnell): Called when early data was rejected by server during the
+    // TLS handshake or client decided not to attempt early data.
+    return NGTCP2_SUCCESS;
+  }
+
+  static constexpr ngtcp2_callbacks CLIENT = {
+      ngtcp2_crypto_client_initial_cb,
+      nullptr,
+      ngtcp2_crypto_recv_crypto_data_cb,
+      on_handshake_completed,
+      on_receive_version_negotiation,
+      ngtcp2_crypto_encrypt_cb,
+      ngtcp2_crypto_decrypt_cb,
+      ngtcp2_crypto_hp_mask_cb,
+      on_receive_stream_data,
+      on_acknowledge_stream_data_offset,
+      nullptr,
+      on_stream_close,
+      on_receive_stateless_reset,
+      ngtcp2_crypto_recv_retry_cb,
+      on_extend_max_streams_bidi,
+      on_extend_max_streams_uni,
+      on_rand,
+      on_get_new_cid,
+      on_remove_connection_id,
+      ngtcp2_crypto_update_key_cb,
+      on_path_validation,
+      on_select_preferred_address,
+      on_stream_reset,
+      on_extend_max_remote_streams_bidi,
+      on_extend_max_remote_streams_uni,
+      on_extend_max_stream_data,
+      on_cid_status,
+      on_handshake_confirmed,
+      on_receive_new_token,
+      ngtcp2_crypto_delete_crypto_aead_ctx_cb,
+      ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
+      on_receive_datagram,
+      on_acknowledge_datagram,
+      on_lost_datagram,
+      ngtcp2_crypto_get_path_challenge_data_cb,
+      on_stream_stop_sending,
+      ngtcp2_crypto_version_negotiation_cb,
+      on_receive_rx_key,
+      nullptr,
+      on_early_data_rejected};
+
+  static constexpr ngtcp2_callbacks SERVER = {
+      nullptr,
+      ngtcp2_crypto_recv_client_initial_cb,
+      ngtcp2_crypto_recv_crypto_data_cb,
+      on_handshake_completed,
+      nullptr,
+      ngtcp2_crypto_encrypt_cb,
+      ngtcp2_crypto_decrypt_cb,
+      ngtcp2_crypto_hp_mask_cb,
+      on_receive_stream_data,
+      on_acknowledge_stream_data_offset,
+      nullptr,
+      on_stream_close,
+      on_receive_stateless_reset,
+      nullptr,
+      on_extend_max_streams_bidi,
+      on_extend_max_streams_uni,
+      on_rand,
+      on_get_new_cid,
+      on_remove_connection_id,
+      ngtcp2_crypto_update_key_cb,
+      on_path_validation,
+      nullptr,
+      on_stream_reset,
+      on_extend_max_remote_streams_bidi,
+      on_extend_max_remote_streams_uni,
+      on_extend_max_stream_data,
+      on_cid_status,
+      nullptr,
+      nullptr,
+      ngtcp2_crypto_delete_crypto_aead_ctx_cb,
+      ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
+      on_receive_datagram,
+      on_acknowledge_datagram,
+      on_lost_datagram,
+      ngtcp2_crypto_get_path_challenge_data_cb,
+      on_stream_stop_sending,
+      ngtcp2_crypto_version_negotiation_cb,
+      nullptr,
+      on_receive_tx_key,
+      on_early_data_rejected};
+};
+
+#undef NGTCP2_CALLBACK_SCOPE
+
+// ============================================================================
+Session::SendPendingDataScope::SendPendingDataScope(Session* session)
+    : session(session) {
+  CHECK_NOT_NULL(session);
+  CHECK(!session->is_destroyed());
+  ++session->impl_->send_scope_depth_;
 }
 
-void Session::UpdatePath(const PathStorage& storage) {
-  remote_address_.Update(storage.path.remote.addr, storage.path.remote.addrlen);
-  local_address_.Update(storage.path.local.addr, storage.path.local.addrlen);
-  Debug(this,
-        "path updated. local %s, remote %s",
-        local_address_,
-        remote_address_);
+Session::SendPendingDataScope::SendPendingDataScope(
+    const BaseObjectPtr<Session>& session)
+    : SendPendingDataScope(session.get()) {}
+
+Session::SendPendingDataScope::~SendPendingDataScope() {
+  if (session->is_destroyed()) return;
+  DCHECK_GE(session->impl_->send_scope_depth_, 1);
+  if (--session->impl_->send_scope_depth_ == 0) {
+    session->application().SendPendingData();
+  }
 }
 
-BaseObjectPtr<Stream> Session::FindStream(int64_t id) const {
-  auto it = streams_.find(id);
-  return it == std::end(streams_) ? BaseObjectPtr<Stream>() : it->second;
+// ============================================================================
+bool Session::HasInstance(Environment* env, Local<Value> value) {
+  return GetConstructorTemplate(env)->HasInstance(value);
 }
 
-BaseObjectPtr<Stream> Session::CreateStream(int64_t id) {
-  if (!can_create_streams()) return BaseObjectPtr<Stream>();
-  auto stream = Stream::Create(this, id);
-  if (stream) AddStream(stream);
-  return stream;
+BaseObjectPtr<Session> Session::Create(
+    Endpoint* endpoint,
+    const Config& config,
+    TLSContext* tls_context,
+    const std::optional<SessionTicket>& ticket) {
+  Local<Object> obj;
+  if (!GetConstructorTemplate(endpoint->env())
+           ->InstanceTemplate()
+           ->NewInstance(endpoint->env()->context())
+           .ToLocal(&obj)) {
+    return {};
+  }
+
+  return MakeDetachedBaseObject<Session>(
+      endpoint, obj, config, tls_context, ticket);
 }
 
-BaseObjectPtr<Stream> Session::OpenStream(Direction direction) {
-  if (!can_create_streams()) return BaseObjectPtr<Stream>();
-  int64_t id;
-  switch (direction) {
-    case Direction::BIDIRECTIONAL: {
-      Debug(this, "Opening bidirectional stream");
-      if (ngtcp2_conn_open_bidi_stream(*this, &id, nullptr) == 0)
-        return CreateStream(id);
-      break;
-    }
-    case Direction::UNIDIRECTIONAL: {
-      Debug(this, "Opening uni-directional stream");
-      if (ngtcp2_conn_open_uni_stream(*this, &id, nullptr) == 0)
-        return CreateStream(id);
-      break;
-    }
+Session::Session(Endpoint* endpoint,
+                 Local<Object> object,
+                 const Config& config,
+                 TLSContext* tls_context,
+                 const std::optional<SessionTicket>& session_ticket)
+    : AsyncWrap(endpoint->env(), object, AsyncWrap::PROVIDER_QUIC_SESSION),
+      side_(config.side),
+      allocator_(BindingData::Get(env())),
+      impl_(std::make_unique<Impl>(this, endpoint, config)),
+      connection_(InitConnection()),
+      tls_session_(tls_context->NewSession(this, session_ticket)) {
+  DCHECK(impl_);
+  MakeWeak();
+  Debug(this, "Session created.");
+
+  const auto defineProperty = [&](auto name, auto value) {
+    object
+        ->DefineOwnProperty(
+            env()->context(), name, value, PropertyAttribute::ReadOnly)
+        .Check();
+  };
+
+  defineProperty(env()->state_string(), impl_->state_.GetArrayBuffer());
+  defineProperty(env()->stats_string(), impl_->stats_.GetArrayBuffer());
+
+  auto& binding = BindingData::Get(env());
+
+  if (config.options.qlog) [[unlikely]] {
+    qlog_stream_ = LogStream::Create(env());
+    defineProperty(binding.qlog_string(), qlog_stream_->object());
   }
-  return BaseObjectPtr<Stream>();
+
+  if (config.options.tls_options.keylog) [[unlikely]] {
+    keylog_stream_ = LogStream::Create(env());
+    defineProperty(binding.keylog_string(), keylog_stream_->object());
+  }
+
+  UpdateDataStats();
 }
 
-void Session::AddStream(const BaseObjectPtr<Stream>& stream) {
-  Debug(this, "Adding stream %" PRIi64 " to session", stream->id());
-  ngtcp2_conn_set_stream_user_data(*this, stream->id(), stream.get());
-  streams_[stream->id()] = stream;
+Session::~Session() {
+  // Double check that Destroy() was called first.
+  CHECK(is_destroyed());
+}
 
-  // Update tracking statistics for the number of streams associated with this
-  // session.
-  switch (stream->origin()) {
-    case Side::CLIENT: {
-      if (is_server()) {
-        switch (stream->direction()) {
-          case Direction::BIDIRECTIONAL:
-            STAT_INCREMENT(Stats, bidi_in_stream_count);
-            break;
-          case Direction::UNIDIRECTIONAL:
-            STAT_INCREMENT(Stats, uni_in_stream_count);
-            break;
-        }
-      } else {
-        switch (stream->direction()) {
-          case Direction::BIDIRECTIONAL:
-            STAT_INCREMENT(Stats, bidi_out_stream_count);
-            break;
-          case Direction::UNIDIRECTIONAL:
-            STAT_INCREMENT(Stats, uni_out_stream_count);
-            break;
-        }
-      }
+Session::QuicConnectionPointer Session::InitConnection() {
+  ngtcp2_conn* conn;
+  Path path(config().local_address, config().remote_address);
+  TransportParams::Config tp_config(side_, config().ocid, config().retry_scid);
+  TransportParams transport_params(tp_config,
+                                   config().options.transport_params);
+  transport_params.GenerateSessionTokens(this);
+
+  switch (side_) {
+    case Side::SERVER: {
+      // On the server side there are certain transport parameters that are
+      // required to be sent. Let's make sure they are set.
+      const ngtcp2_transport_params& params = transport_params;
+      CHECK_EQ(params.original_dcid_present, 1);
+      CHECK_EQ(ngtcp2_conn_server_new(&conn,
+                                      config().dcid,
+                                      config().scid,
+                                      path,
+                                      config().version,
+                                      &Impl::SERVER,
+                                      &config().settings,
+                                      transport_params,
+                                      &allocator_,
+                                      this),
+               0);
       break;
     }
-    case Side::SERVER: {
-      if (is_server()) {
-        switch (stream->direction()) {
-          case Direction::BIDIRECTIONAL:
-            STAT_INCREMENT(Stats, bidi_out_stream_count);
-            break;
-          case Direction::UNIDIRECTIONAL:
-            STAT_INCREMENT(Stats, uni_out_stream_count);
-            break;
-        }
-      } else {
-        switch (stream->direction()) {
-          case Direction::BIDIRECTIONAL:
-            STAT_INCREMENT(Stats, bidi_in_stream_count);
-            break;
-          case Direction::UNIDIRECTIONAL:
-            STAT_INCREMENT(Stats, uni_in_stream_count);
-            break;
-        }
-      }
+    case Side::CLIENT: {
+      CHECK_EQ(ngtcp2_conn_client_new(&conn,
+                                      config().dcid,
+                                      config().scid,
+                                      path,
+                                      config().version,
+                                      &Impl::CLIENT,
+                                      &config().settings,
+                                      transport_params,
+                                      &allocator_,
+                                      this),
+               0);
       break;
     }
   }
+  return QuicConnectionPointer(conn);
 }
 
-void Session::RemoveStream(int64_t id) {
-  // ngtcp2 does not extend the max streams count automatically except in very
-  // specific conditions, none of which apply once we've gotten this far. We
-  // need to manually extend when a remote peer initiated stream is removed.
-  Debug(this, "Removing stream %" PRIi64 " from session", id);
-  if (!is_in_draining_period() && !is_in_closing_period() &&
-      !state_->silent_close &&
-      !ngtcp2_conn_is_local_stream(connection_.get(), id)) {
-    if (ngtcp2_is_bidi_stream(id))
-      ngtcp2_conn_extend_max_streams_bidi(connection_.get(), 1);
-    else
-      ngtcp2_conn_extend_max_streams_uni(connection_.get(), 1);
-  }
-
-  // Frees the persistent reference to the Stream object, allowing it to be gc'd
-  // any time after the JS side releases it's own reference.
-  streams_.erase(id);
-  ngtcp2_conn_set_stream_user_data(*this, id, nullptr);
+Session::operator ngtcp2_conn*() const {
+  return connection_.get();
 }
 
-void Session::ResumeStream(int64_t id) {
-  Debug(this, "Resuming stream %" PRIi64, id);
-  SendPendingDataScope send_scope(this);
-  application_->ResumeStream(id);
+bool Session::is_server() const {
+  return side_ == Side::SERVER;
 }
 
-void Session::ShutdownStream(int64_t id, QuicError error) {
-  Debug(this, "Shutting down stream %" PRIi64 " with error %s", id, error);
-  SendPendingDataScope send_scope(this);
-  ngtcp2_conn_shutdown_stream(*this,
-                              0,
-                              id,
-                              error.type() == QuicError::Type::APPLICATION
-                                  ? error.code()
-                                  : NGTCP2_APP_NOERROR);
+bool Session::is_destroyed() const {
+  return !impl_;
 }
 
-void Session::StreamDataBlocked(int64_t id) {
-  Debug(this, "Stream %" PRIi64 " is blocked", id);
-  STAT_INCREMENT(Stats, block_count);
-  application_->BlockStream(id);
+bool Session::is_destroyed_or_closing() const {
+  return !impl_ || impl_->state_->closing;
 }
 
-void Session::ShutdownStreamWrite(int64_t id, QuicError code) {
-  Debug(this, "Shutting down stream %" PRIi64 " write with error %s", id, code);
-  SendPendingDataScope send_scope(this);
-  ngtcp2_conn_shutdown_stream_write(*this,
-                                    0,
-                                    id,
-                                    code.type() == QuicError::Type::APPLICATION
-                                        ? code.code()
-                                        : NGTCP2_APP_NOERROR);
-}
+void Session::Close(Session::CloseMethod method) {
+  if (is_destroyed()) return;
+  auto& stats_ = impl_->stats_;
 
-void Session::CollectSessionTicketAppData(
-    SessionTicket::AppData* app_data) const {
-  application_->CollectSessionTicketAppData(app_data);
-}
+  if (impl_->last_error_) {
+    Debug(this, "Closing with error: %s", impl_->last_error_);
+  }
 
-SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
-    const SessionTicket::AppData& app_data,
-    SessionTicket::AppData::Source::Flag flag) {
-  return application_->ExtractSessionTicketAppData(app_data, flag);
-}
+  STAT_RECORD_TIMESTAMP(Stats, closing_at);
+  impl_->state_->closing = 1;
+
+  // With both the DEFAULT and SILENT options, we will proceed to closing
+  // the session immediately. All open streams will be immediately destroyed
+  // with whatever is set as the last error. The session will then be destroyed
+  // with a possible roundtrip to JavaScript to emit a close event and clean up
+  // any JavaScript side state. Importantly, these operations are all done
+  // synchronously, so the session will be destroyed once FinishClose returns.
+  //
+  // With the graceful option, we will wait for all streams to close on their
+  // own before proceeding with the FinishClose operation. New streams will
+  // be rejected, however.
 
-void Session::MemoryInfo(MemoryTracker* tracker) const {
-  tracker->TrackField("config", config_);
-  tracker->TrackField("endpoint", endpoint_);
-  tracker->TrackField("streams", streams_);
-  tracker->TrackField("local_address", local_address_);
-  tracker->TrackField("remote_address", remote_address_);
-  tracker->TrackField("application", application_);
-  tracker->TrackField("tls_session", tls_session_);
-  tracker->TrackField("timer", timer_);
-  tracker->TrackField("conn_closebuf", conn_closebuf_);
-  tracker->TrackField("qlog_stream", qlog_stream_);
-  tracker->TrackField("keylog_stream", keylog_stream_);
+  switch (method) {
+    case CloseMethod::DEFAULT: {
+      Debug(this, "Immediately closing session");
+      impl_->state_->silent_close = 0;
+      return FinishClose();
+    }
+    case CloseMethod::SILENT: {
+      Debug(this, "Immediately closing session silently");
+      impl_->state_->silent_close = 1;
+      return FinishClose();
+    }
+    case CloseMethod::GRACEFUL: {
+      // If there are no open streams, then we can close just immediately and
+      // not worry about waiting around.
+      if (impl_->streams_.empty()) {
+        impl_->state_->silent_close = 0;
+        impl_->state_->graceful_close = 0;
+        return FinishClose();
+      }
+
+      // If we are already closing gracefully, do nothing.
+      if (impl_->state_->graceful_close) [[unlikely]] {
+        return;
+      }
+      impl_->state_->graceful_close = 1;
+      Debug(this,
+            "Gracefully closing session (waiting on %zu streams)",
+            impl_->streams_.size());
+      return;
+    }
+  }
+  UNREACHABLE();
 }
 
-bool Session::is_in_closing_period() const {
-  return ngtcp2_conn_in_closing_period(*this) != 0;
+void Session::FinishClose() {
+  // FinishClose() should be called only after, and as a result of, Close()
+  // being called first.
+  DCHECK(!is_destroyed());
+  DCHECK(impl_->state_->closing);
+
+  // If impl_->Close() returns true, then the session can be destroyed
+  // immediately without round-tripping through JavaScript.
+  if (impl_->Close()) {
+    return Destroy();
+  }
+
+  // Otherwise, we emit a close callback so that the JavaScript side can
+  // clean up anything it needs to clean up before destroying.
+  EmitClose();
 }
 
-bool Session::is_in_draining_period() const {
-  return ngtcp2_conn_in_draining_period(*this) != 0;
+void Session::Destroy() {
+  // Destroy() should be called only after, and as a result of, Close()
+  // being called first.
+  DCHECK(impl_);
+  DCHECK(impl_->state_->closing);
+  Debug(this, "Session destroyed");
+  impl_.reset();
+  if (qlog_stream_ || keylog_stream_) {
+    env()->SetImmediate(
+        [qlog = qlog_stream_, keylog = keylog_stream_](Environment*) {
+          if (qlog) qlog->End();
+          if (keylog) keylog->End();
+        });
+  }
+  qlog_stream_.reset();
+  keylog_stream_.reset();
 }
 
-bool Session::wants_session_ticket() const {
-  return state_->session_ticket == 1;
+PendingStream::PendingStreamQueue& Session::pending_bidi_stream_queue() const {
+  CHECK(!is_destroyed());
+  return impl_->pending_bidi_stream_queue_;
 }
 
-void Session::SetStreamOpenAllowed() {
-  state_->stream_open_allowed = 1;
+PendingStream::PendingStreamQueue& Session::pending_uni_stream_queue() const {
+  CHECK(!is_destroyed());
+  return impl_->pending_uni_stream_queue_;
 }
 
-bool Session::can_send_packets() const {
-  // We can send packets if we're not in the middle of a ngtcp2 callback,
-  // we're not destroyed, we're not in a draining or closing period, and
-  // endpoint is set.
-  return !NgTcp2CallbackScope::in_ngtcp2_callback(env()) && !is_destroyed() &&
-         !is_in_draining_period() && !is_in_closing_period() && endpoint_;
+size_t Session::max_packet_size() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_max_tx_udp_payload_size(*this);
 }
 
-bool Session::can_create_streams() const {
-  return !state_->destroyed && !state_->graceful_close && !state_->closing &&
-         !is_in_closing_period() && !is_in_draining_period();
+uint32_t Session::version() const {
+  CHECK(!is_destroyed());
+  return impl_->config_.version;
 }
 
-uint64_t Session::max_data_left() const {
-  return ngtcp2_conn_get_max_data_left(*this);
+Endpoint& Session::endpoint() const {
+  CHECK(!is_destroyed());
+  return *impl_->endpoint_;
 }
 
-uint64_t Session::max_local_streams_uni() const {
-  return ngtcp2_conn_get_streams_uni_left(*this);
+TLSSession& Session::tls_session() const {
+  CHECK(!is_destroyed());
+  return *tls_session_;
 }
 
-uint64_t Session::max_local_streams_bidi() const {
-  return ngtcp2_conn_get_local_transport_params(*this)
-      ->initial_max_streams_bidi;
+Session::Application& Session::application() const {
+  CHECK(!is_destroyed());
+  return *impl_->application_;
 }
 
-void Session::set_wrapped() {
-  state_->wrapped = 1;
+const SocketAddress& Session::remote_address() const {
+  CHECK(!is_destroyed());
+  return impl_->remote_address_;
 }
 
-void Session::set_priority_supported(bool on) {
-  state_->priority_supported = on ? 1 : 0;
+const SocketAddress& Session::local_address() const {
+  CHECK(!is_destroyed());
+  return impl_->local_address_;
 }
 
-void Session::DoClose(bool silent) {
-  DCHECK(!is_destroyed());
-  Debug(this, "Session is closing. Silently %s", silent ? "yes" : "no");
-  // Once Close has been called, we cannot re-enter
-  if (state_->closing == 1) return;
-  state_->closing = 1;
-  state_->silent_close = silent ? 1 : 0;
-  STAT_RECORD_TIMESTAMP(Stats, closing_at);
+std::string Session::diagnostic_name() const {
+  const auto get_type = [&] { return is_server() ? "server" : "client"; };
 
-  // Iterate through all of the known streams and close them. The streams
-  // will remove themselves from the Session as soon as they are closed.
-  // Note: we create a copy because the streams will remove themselves
-  // while they are cleaning up which will invalidate the iterator.
-  auto streams = streams_;
-  for (auto& stream : streams) stream.second->Destroy(last_error_);
-  DCHECK(streams.empty());
-
-  // If the state has not been passed out to JavaScript yet, we can skip closing
-  // entirely and drop directly out to Destroy.
-  if (!state_->wrapped) return Destroy();
-
-  // If we're not running within a ngtcp2 callback scope, schedule a
-  // CONNECTION_CLOSE to be sent. If we are within a ngtcp2 callback scope,
-  // sending the CONNECTION_CLOSE will be deferred.
-  { MaybeCloseConnectionScope close_scope(this, silent); }
-
-  // We emit a close callback so that the JavaScript side can clean up anything
-  // it needs to clean up before destroying. It's the JavaScript side's
-  // responsibility to call destroy() when ready.
-  EmitClose();
+  return std::string("Session (") + get_type() + "," +
+         std::to_string(env()->thread_id()) + ":" +
+         std::to_string(static_cast<int64_t>(get_async_id())) + ")";
 }
 
-void Session::ExtendStreamOffset(int64_t id, size_t amount) {
-  Debug(this, "Extending stream %" PRIi64 " offset by %zu", id, amount);
-  ngtcp2_conn_extend_max_stream_offset(*this, id, amount);
+const Session::Config& Session::config() const {
+  CHECK(!is_destroyed());
+  return impl_->config_;
 }
 
-void Session::ExtendOffset(size_t amount) {
-  Debug(this, "Extending offset by %zu", amount);
-  ngtcp2_conn_extend_max_offset(*this, amount);
+Session::Config& Session::config() {
+  CHECK(!is_destroyed());
+  return impl_->config_;
 }
 
-void Session::UpdateDataStats() {
-  if (state_->destroyed) return;
-  Debug(this, "Updating data stats");
-  ngtcp2_conn_info info;
-  ngtcp2_conn_get_conn_info(*this, &info);
-  STAT_SET(Stats, bytes_in_flight, info.bytes_in_flight);
-  STAT_SET(Stats, cwnd, info.cwnd);
-  STAT_SET(Stats, latest_rtt, info.latest_rtt);
-  STAT_SET(Stats, min_rtt, info.min_rtt);
-  STAT_SET(Stats, rttvar, info.rttvar);
-  STAT_SET(Stats, smoothed_rtt, info.smoothed_rtt);
-  STAT_SET(Stats, ssthresh, info.ssthresh);
-  STAT_SET(
-      Stats,
-      max_bytes_in_flight,
-      std::max(STAT_GET(Stats, max_bytes_in_flight), info.bytes_in_flight));
+const Session::Options& Session::options() const {
+  CHECK(!is_destroyed());
+  return impl_->config_.options;
+}
+
+void Session::HandleQlog(uint32_t flags, const void* data, size_t len) {
+  DCHECK(qlog_stream_);
+  // Fun fact... ngtcp2 does not emit the final qlog statement until the
+  // ngtcp2_conn object is destroyed.
+  std::vector<uint8_t> buffer(len);
+  memcpy(buffer.data(), data, len);
+  Debug(this, "Emitting qlog data to the qlog stream");
+  env()->SetImmediate([ptr = qlog_stream_, buffer = std::move(buffer), flags](
+                          Environment*) {
+    ptr->Emit(buffer.data(),
+              buffer.size(),
+              flags & NGTCP2_QLOG_WRITE_FLAG_FIN ? LogStream::EmitOption::FIN
+                                                 : LogStream::EmitOption::NONE);
+  });
 }
 
-void Session::SendConnectionClose() {
-  DCHECK(!NgTcp2CallbackScope::in_ngtcp2_callback(env()));
-  if (is_destroyed() || is_in_draining_period() || state_->silent_close) return;
+const TransportParams Session::local_transport_params() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_local_transport_params(*this);
+}
 
-  Debug(this, "Sending connection close");
-  auto on_exit = OnScopeLeave([this] { UpdateTimer(); });
+const TransportParams Session::remote_transport_params() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_remote_transport_params(*this);
+}
 
-  switch (config_.side) {
-    case Side::SERVER: {
-      if (!is_in_closing_period() && !StartClosingPeriod()) {
+void Session::SetLastError(QuicError&& error) {
+  CHECK(!is_destroyed());
+  Debug(this, "Setting last error to %s", error);
+  impl_->last_error_ = std::move(error);
+}
+
+bool Session::Receive(Store&& store,
+                      const SocketAddress& local_address,
+                      const SocketAddress& remote_address) {
+  CHECK(!is_destroyed());
+  impl_->remote_address_ = remote_address;
+
+  // When we are done processing thins packet, we arrange to send any
+  // pending data for this session.
+  SendPendingDataScope send_scope(this);
+
+  ngtcp2_vec vec = store;
+  Path path(local_address, remote_address);
+
+  Debug(this,
+        "Session is receiving %zu-byte packet received along path %s",
+        vec.len,
+        path);
+
+  // It is important to understand that reading the packet will cause
+  // callback functions to be invoked, any one of which could lead to
+  // the Session being closed/destroyed synchronously. After calling
+  // ngtcp2_conn_read_pkt here, we will need to double check that the
+  // session is not destroyed before we try doing anything with it
+  // (like updating stats, sending pending data, etc).
+  int err = ngtcp2_conn_read_pkt(
+      *this, path, nullptr, vec.base, vec.len, uv_hrtime());
+
+  switch (err) {
+    case 0: {
+      Debug(this, "Session successfully received %zu-byte packet", vec.len);
+      if (!is_destroyed()) [[unlikely]] {
+        auto& stats_ = impl_->stats_;
+        STAT_INCREMENT_N(Stats, bytes_received, vec.len);
+      }
+      return true;
+    }
+    case NGTCP2_ERR_REQUIRED_TRANSPORT_PARAM: {
+      Debug(this,
+            "Receiving packet failed: "
+            "Remote peer failed to send a required transport parameter");
+      if (!is_destroyed()) [[likely]] {
+        SetLastError(QuicError::ForTransport(err));
+        Close();
+      }
+      return false;
+    }
+    case NGTCP2_ERR_DRAINING: {
+      // Connection has entered the draining state, no further data should be
+      // sent. This happens when the remote peer has already sent a
+      // CONNECTION_CLOSE.
+      Debug(this, "Receiving packet failed: Session is draining");
+      return false;
+    }
+    case NGTCP2_ERR_CLOSING: {
+      // Connection has entered the closing state, no further data should be
+      // sent. This happens when the local peer has called
+      // ngtcp2_conn_write_connection_close.
+      Debug(this, "Receiving packet failed: Session is closing");
+      return false;
+    }
+    case NGTCP2_ERR_CRYPTO: {
+      Debug(this, "Receiving packet failed: Crypto error");
+      // Crypto error happened! Set the last error to the tls alert
+      if (!is_destroyed()) [[likely]] {
+        SetLastError(QuicError::ForTlsAlert(ngtcp2_conn_get_tls_alert(*this)));
+        Close();
+      }
+      return false;
+    }
+    case NGTCP2_ERR_RETRY: {
+      // This should only ever happen on the server. We have to send a path
+      // validation challenge in the form of a RETRY packet to the peer and
+      // drop the connection.
+      DCHECK(is_server());
+      Debug(this, "Receiving packet failed: Server must send a retry packet");
+      if (!is_destroyed()) {
+        endpoint().SendRetry(PathDescriptor{
+            version(),
+            config().dcid,
+            config().scid,
+            impl_->local_address_,
+            impl_->remote_address_,
+        });
         Close(CloseMethod::SILENT);
-      } else {
-        DCHECK(conn_closebuf_);
-        Send(conn_closebuf_->Clone());
       }
-      return;
+      return false;
     }
-    case Side::CLIENT: {
-      Path path(local_address_, remote_address_);
-      auto packet = Packet::Create(env(),
-                                   endpoint_.get(),
-                                   remote_address_,
-                                   kDefaultMaxPacketLength,
-                                   "immediate connection close (client)");
-      ngtcp2_vec vec = *packet;
-      ssize_t nwrite = ngtcp2_conn_write_connection_close(
-          *this, &path, nullptr, vec.base, vec.len, last_error_, uv_hrtime());
-
-      if (nwrite < 0) [[unlikely]] {
-        packet->Done(UV_ECANCELED);
-        last_error_ = QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR);
+    case NGTCP2_ERR_DROP_CONN: {
+      // There's nothing else to do but drop the connection state.
+      Debug(this, "Receiving packet failed: Session must drop the connection");
+      if (!is_destroyed()) {
         Close(CloseMethod::SILENT);
-      } else {
-        packet->Truncate(nwrite);
-        Send(std::move(packet));
       }
-      return;
+      return false;
     }
   }
-  UNREACHABLE();
-}
-
-void Session::OnTimeout() {
-  HandleScope scope(env()->isolate());
-  if (is_destroyed()) return;
 
-  int ret = ngtcp2_conn_handle_expiry(*this, uv_hrtime());
-  if (NGTCP2_OK(ret) && !is_in_closing_period() && !is_in_draining_period()) {
-    Debug(this, "Sending pending data after timr expiry");
-    SendPendingDataScope send_scope(this);
-    return;
+  // Shouldn't happen but just in case... handle other unknown errors
+  Debug(this,
+        "Receiving packet failed: "
+        "Unexpected error %d while receiving packet",
+        err);
+  if (!is_destroyed()) {
+    SetLastError(QuicError::ForNgtcp2Error(err));
+    Close();
   }
-
-  Debug(this, "Session timed out");
-  last_error_ = QuicError::ForNgtcp2Error(ret);
-  Close(CloseMethod::SILENT);
+  return false;
 }
 
-void Session::UpdateTimer() {
-  // Both uv_hrtime and ngtcp2_conn_get_expiry return nanosecond units.
-  uint64_t expiry = ngtcp2_conn_get_expiry(*this);
-  uint64_t now = uv_hrtime();
-  Debug(
-      this, "Updating timer. Expiry: %" PRIu64 ", now: %" PRIu64, expiry, now);
+void Session::Send(const BaseObjectPtr<Packet>& packet) {
+  // Sending a Packet is generally best effort. If we're not in a state
+  // where we can send a packet, it's ok to drop it on the floor. The
+  // packet loss mechanisms will cause the packet data to be resent later
+  // if appropriate (and possible).
 
-  if (expiry <= now) {
-    // The timer has already expired.
-    return OnTimeout();
-  }
+  // That said, we should never be trying to send a packet when we're in
+  // a draining period.
+  CHECK(!is_destroyed());
+  DCHECK(!is_in_draining_period());
 
-  auto timeout = (expiry - now) / NGTCP2_MILLISECONDS;
-  Debug(this, "Updating timeout to %zu milliseconds", timeout);
+  if (!can_send_packets()) [[unlikely]] {
+    return packet->Done(UV_ECANCELED);
+  }
 
-  // If timeout is zero here, it means our timer is less than a millisecond
-  // off from expiry. Let's bump the timer to 1.
-  timer_.Update(timeout == 0 ? 1 : timeout);
+  Debug(this, "Session is sending %s", packet->ToString());
+  auto& stats_ = impl_->stats_;
+  STAT_INCREMENT_N(Stats, bytes_sent, packet->length());
+  endpoint().Send(packet);
 }
 
-bool Session::StartClosingPeriod() {
-  if (is_in_closing_period()) return true;
-  if (is_destroyed()) return false;
+void Session::Send(const BaseObjectPtr<Packet>& packet,
+                   const PathStorage& path) {
+  UpdatePath(path);
+  Send(packet);
+}
 
-  Debug(this, "Session is entering closing period");
+uint64_t Session::SendDatagram(Store&& data) {
+  CHECK(!is_destroyed());
+  if (!can_send_packets()) {
+    Debug(this, "Unable to send datagram");
+    return 0;
+  }
 
-  conn_closebuf_ = Packet::CreateConnectionClosePacket(
-      env(), endpoint_.get(), remote_address_, *this, last_error_);
+  const ngtcp2_transport_params* tp = remote_transport_params();
+  uint64_t max_datagram_size = tp->max_datagram_frame_size;
 
-  // If we were unable to create a connection close packet, we're in trouble.
-  // Set the internal error and return false so that the session will be
-  // silently closed.
-  if (!conn_closebuf_) {
-    last_error_ = QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR);
-    return false;
+  if (max_datagram_size == 0) {
+    Debug(this, "Datagrams are disabled");
+    return 0;
   }
 
-  return true;
-}
+  if (data.length() > max_datagram_size) {
+    Debug(this, "Ignoring oversized datagram");
+    return 0;
+  }
 
-void Session::DatagramStatus(uint64_t datagramId, quic::DatagramStatus status) {
-  switch (status) {
-    case quic::DatagramStatus::ACKNOWLEDGED: {
-      Debug(this, "Datagram %" PRIu64 " was acknowledged", datagramId);
-      STAT_INCREMENT(Stats, datagrams_acknowledged);
-      break;
-    }
-    case quic::DatagramStatus::LOST: {
-      Debug(this, "Datagram %" PRIu64 " was lost", datagramId);
-      STAT_INCREMENT(Stats, datagrams_lost);
-      break;
-    }
+  if (data.length() == 0) {
+    Debug(this, "Ignoring empty datagram");
+    return 0;
   }
-  EmitDatagramStatus(datagramId, status);
-}
 
-void Session::DatagramReceived(const uint8_t* data,
-                               size_t datalen,
-                               DatagramReceivedFlags flag) {
-  // If there is nothing watching for the datagram on the JavaScript side,
-  // we just drop it on the floor.
-  if (state_->datagram == 0 || datalen == 0) return;
+  BaseObjectPtr<Packet> packet;
+  uint8_t* pos = nullptr;
+  int accepted = 0;
+  ngtcp2_vec vec = data;
+  PathStorage path;
+  int flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE;
+  uint64_t did = impl_->state_->last_datagram_id + 1;
 
-  auto backing = ArrayBuffer::NewBackingStore(env()->isolate(), datalen);
-  Debug(this, "Session is receiving datagram of size %zu", datalen);
-  memcpy(backing->Data(), data, datalen);
-  STAT_INCREMENT(Stats, datagrams_received);
-  STAT_INCREMENT_N(Stats, bytes_received, datalen);
-  EmitDatagram(Store(std::move(backing), datalen), flag);
-}
+  Debug(this, "Sending %zu-byte datagram %" PRIu64, data.length(), did);
 
-bool Session::GenerateNewConnectionId(ngtcp2_cid* cid,
-                                      size_t len,
-                                      uint8_t* token) {
-  CID cid_ = config_.options.cid_factory->GenerateInto(cid, len);
-  Debug(this, "Generated new connection id %s", cid_);
-  StatelessResetToken new_token(
-      token, endpoint_->options().reset_token_secret, cid_);
-  endpoint_->AssociateCID(cid_, config_.scid);
-  endpoint_->AssociateStatelessResetToken(new_token, this);
-  return true;
-}
+  // Let's give it a max number of attempts to send the datagram.
+  static const int kMaxAttempts = 16;
+  int attempts = 0;
 
-bool Session::HandshakeCompleted() {
-  Debug(this, "Session handshake completed");
+  auto on_exit = OnScopeLeave([&] {
+    UpdatePacketTxTime();
+    UpdateTimer();
+    UpdateDataStats();
+  });
+
+  for (;;) {
+    // We may have to make several attempts at encoding and sending the
+    // datagram packet. On each iteration here we'll try to encode the
+    // datagram. It's entirely up to ngtcp2 whether to include the datagram
+    // in the packet on each call to ngtcp2_conn_writev_datagram.
+    if (!packet) {
+      packet = Packet::Create(env(),
+                              endpoint(),
+                              impl_->remote_address_,
+                              ngtcp2_conn_get_max_tx_udp_payload_size(*this),
+                              "datagram");
+      // Typically sending datagrams is best effort, but if we cannot create
+      // the packet, then we handle it as a fatal error.
+      if (!packet) {
+        SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
+        Close(CloseMethod::SILENT);
+        return 0;
+      }
+      pos = ngtcp2_vec(*packet).base;
+    }
 
-  if (state_->handshake_completed) return false;
-  state_->handshake_completed = 1;
+    ssize_t nwrite = ngtcp2_conn_writev_datagram(*this,
+                                                 &path.path,
+                                                 nullptr,
+                                                 pos,
+                                                 packet->length(),
+                                                 &accepted,
+                                                 flags,
+                                                 did,
+                                                 &vec,
+                                                 1,
+                                                 uv_hrtime());
 
-  STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
+    if (nwrite <= 0) {
+      // Nothing was written to the packet.
+      switch (nwrite) {
+        case 0: {
+          // We cannot send data because of congestion control or the data will
+          // not fit. Since datagrams are best effort, we are going to abandon
+          // the attempt and just return.
+          CHECK_EQ(accepted, 0);
+          packet->Done(UV_ECANCELED);
+          return 0;
+        }
+        case NGTCP2_ERR_WRITE_MORE: {
+          // We keep on looping! Keep on sending!
+          continue;
+        }
+        case NGTCP2_ERR_INVALID_STATE: {
+          // The remote endpoint does not want to accept datagrams. That's ok,
+          // just return 0.
+          packet->Done(UV_ECANCELED);
+          return 0;
+        }
+        case NGTCP2_ERR_INVALID_ARGUMENT: {
+          // The datagram is too large. That should have been caught above but
+          // that's ok. We'll just abandon the attempt and return.
+          packet->Done(UV_ECANCELED);
+          return 0;
+        }
+        case NGTCP2_ERR_PKT_NUM_EXHAUSTED: {
+          // We've exhausted the packet number space. Sadly we have to treat it
+          // as a fatal condition (which we will do after the switch)
+          break;
+        }
+        case NGTCP2_ERR_CALLBACK_FAILURE: {
+          // There was an internal failure. Sadly we have to treat it as a fatal
+          // condition. (which we will do after the switch)
+          break;
+        }
+      }
+      packet->Done(UV_ECANCELED);
+      SetLastError(QuicError::ForTransport(nwrite));
+      Close(CloseMethod::SILENT);
+      return 0;
+    }
 
-  if (!tls_session().early_data_was_accepted())
-    ngtcp2_conn_tls_early_data_rejected(*this);
+    // In this case, a complete packet was written and we need to send it along.
+    // Note that this doesn't mean that the packet actually contains the
+    // datagram! We'll check that next by checking the accepted value.
+    packet->Truncate(nwrite);
+    Send(packet);
+    packet.reset();
 
-  // When in a server session, handshake completed == handshake confirmed.
-  if (is_server()) {
-    HandshakeConfirmed();
+    if (accepted) {
+      // Yay! The datagram was accepted into the packet we just sent and we can
+      // return the datagram ID.
+      Debug(this, "Datagram %" PRIu64 " sent", did);
+      auto& stats_ = impl_->stats_;
+      STAT_INCREMENT(Stats, datagrams_sent);
+      STAT_INCREMENT_N(Stats, bytes_sent, vec.len);
+      impl_->state_->last_datagram_id = did;
+      return did;
+    }
 
-    if (!endpoint().is_closed() && !endpoint().is_closing()) {
-      auto token = endpoint().GenerateNewToken(version(), remote_address_);
-      ngtcp2_vec vec = token;
-      if (NGTCP2_ERR(ngtcp2_conn_submit_new_token(*this, vec.base, vec.len))) {
-        // Submitting the new token failed... In this case we're going to
-        // fail because submitting the new token should only fail if we
-        // ran out of memory or some other unrecoverable state.
-        return false;
-      }
+    // We sent a packet, but it wasn't the datagram packet. That can happen.
+    // Let's loop around and try again.
+    if (++attempts == kMaxAttempts) [[unlikely]] {
+      Debug(this, "Too many attempts to send datagram. Canceling.");
+      // Too many attempts to send the datagram.
+      break;
     }
-  }
 
-  EmitHandshakeComplete();
+    // If we get here that means the datagram has not yet been sent.
+    // We're going to loop around to try again.
+  }
 
-  return true;
+  return 0;
 }
 
-void Session::HandshakeConfirmed() {
-  if (state_->handshake_confirmed) return;
-
-  Debug(this, "Session handshake confirmed");
+void Session::UpdatePacketTxTime() {
+  CHECK(!is_destroyed());
+  ngtcp2_conn_update_pkt_tx_time(*this, uv_hrtime());
+}
 
-  state_->handshake_confirmed = true;
-  STAT_RECORD_TIMESTAMP(Stats, handshake_confirmed_at);
+void Session::UpdatePath(const PathStorage& storage) {
+  CHECK(!is_destroyed());
+  impl_->remote_address_.Update(storage.path.remote.addr,
+                                storage.path.remote.addrlen);
+  impl_->local_address_.Update(storage.path.local.addr,
+                               storage.path.local.addrlen);
+  Debug(this,
+        "path updated. local %s, remote %s",
+        impl_->local_address_,
+        impl_->remote_address_);
 }
 
-void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) {
-  if (config_.options.preferred_address_strategy ==
-      PreferredAddress::Policy::IGNORE_PREFERRED) {
-    Debug(this, "Ignoring preferred address");
-    return;
+BaseObjectPtr<Stream> Session::FindStream(int64_t id) const {
+  if (is_destroyed()) return {};
+  auto it = impl_->streams_.find(id);
+  if (it == std::end(impl_->streams_)) return {};
+  return it->second;
+}
+
+BaseObjectPtr<Stream> Session::CreateStream(
+    int64_t id,
+    CreateStreamOption option,
+    std::shared_ptr<DataQueue> data_source) {
+  if (!can_create_streams()) [[unlikely]]
+    return {};
+  if (auto stream = Stream::Create(this, id, std::move(data_source)))
+      [[likely]] {
+    AddStream(stream, option);
+    return stream;
+  }
+  return {};
+}
+
+MaybeLocal<Object> Session::OpenStream(Direction direction,
+                                       std::shared_ptr<DataQueue> data_source) {
+  // If can_create_streams() returns false, we are not able to open a stream
+  // at all now, even in a pending state. The implication is that that session
+  // is destroyed or closing.
+  if (!can_create_streams()) [[unlikely]]
+    return {};
+
+  // If can_open_streams() returns false, we are able to create streams but
+  // they will remain in a pending state. The implication is that the session
+  // TLS handshake is still progressing. Note that when a pending stream is
+  // created, it will not be listed in the streams list.
+  if (!can_open_streams()) {
+    if (auto stream = Stream::Create(this, direction, std::move(data_source)))
+        [[likely]] {
+      return stream->object();
+    }
+    return {};
   }
 
-  auto local_address = endpoint_->local_address();
-  int family = local_address.family();
-
-  switch (family) {
-    case AF_INET: {
-      Debug(this, "Selecting preferred address for AF_INET");
-      auto ipv4 = preferredAddress->ipv4();
-      if (ipv4.has_value()) {
-        if (ipv4->address.empty() || ipv4->port == 0) return;
-        CHECK(SocketAddress::New(AF_INET,
-                                 std::string(ipv4->address).c_str(),
-                                 ipv4->port,
-                                 &remote_address_));
-        preferredAddress->Use(ipv4.value());
+  int64_t id = -1;
+  auto open = [&] {
+    switch (direction) {
+      case Direction::BIDIRECTIONAL: {
+        Debug(this, "Opening bidirectional stream");
+        return ngtcp2_conn_open_bidi_stream(*this, &id, nullptr);
+      }
+      case Direction::UNIDIRECTIONAL: {
+        Debug(this, "Opening uni-directional stream");
+        return ngtcp2_conn_open_uni_stream(*this, &id, nullptr);
       }
-      break;
     }
-    case AF_INET6: {
-      Debug(this, "Selecting preferred address for AF_INET6");
-      auto ipv6 = preferredAddress->ipv6();
-      if (ipv6.has_value()) {
-        if (ipv6->address.empty() || ipv6->port == 0) return;
-        CHECK(SocketAddress::New(AF_INET,
-                                 std::string(ipv6->address).c_str(),
-                                 ipv6->port,
-                                 &remote_address_));
-        preferredAddress->Use(ipv6.value());
+    UNREACHABLE();
+  };
+
+  switch (open()) {
+    case 0: {
+      // Woo! Our stream was created.
+      CHECK_GE(id, 0);
+      if (auto stream = CreateStream(
+              id, CreateStreamOption::DO_NOT_NOTIFY, std::move(data_source)))
+          [[likely]] {
+        return stream->object();
+      }
+      break;
+    }
+    case NGTCP2_ERR_STREAM_ID_BLOCKED: {
+      // The stream cannot yet be opened.
+      // This is typically caused by the application exceeding the allowed max
+      // number of concurrent streams. We will allow the stream to be created
+      // in a pending state.
+      if (auto stream = Stream::Create(this, direction, std::move(data_source)))
+          [[likely]] {
+        return stream->object();
       }
       break;
     }
   }
+  return {};
 }
 
-CID Session::new_cid(size_t len) const {
-  return config_.options.cid_factory->Generate(len);
-}
+void Session::AddStream(BaseObjectPtr<Stream> stream,
+                        CreateStreamOption option) {
+  CHECK(!is_destroyed());
+  CHECK(stream);
 
-// JavaScript callouts
+  auto id = stream->id();
+  auto direction = stream->direction();
 
-void Session::EmitClose(const QuicError& error) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return Destroy();
+  // Let's double check that a stream with the given id does not already
+  // exist. If it does, that means we've got a bug somewhere.
+  DCHECK_EQ(impl_->streams_.find(id), impl_->streams_.end());
 
-  CallbackScope<Session> cb_scope(this);
-  Local<Value> argv[] = {
-      Integer::New(env()->isolate(), static_cast<int>(error.type())),
-      BigInt::NewFromUnsigned(env()->isolate(), error.code()),
-      Undefined(env()->isolate()),
-  };
-  if (error.reason().length() > 0 &&
-      !ToV8Value(env()->context(), error.reason()).ToLocal(&argv[2])) {
-    return;
-  }
-  Debug(this, "Notifying JavaScript of session close");
-  MakeCallback(
-      BindingData::Get(env()).session_close_callback(), arraysize(argv), argv);
-}
+  Debug(this, "Adding stream %" PRIi64 " to session", id);
 
-void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+  // The streams_ map becomes the sole owner of the Stream instance.
+  // We mark the stream detached so that when it is removed from
+  // the session, or is the session is destroyed, the stream will
+  // also be destroyed.
+  impl_->streams_[id] = stream;
+  stream->Detach();
 
-  CallbackScope<Session> cbv_scope(this);
+  ngtcp2_conn_set_stream_user_data(*this, id, stream.get());
 
-  Local<Value> argv[] = {datagram.ToUint8Array(env()),
-                         v8::Boolean::New(env()->isolate(), flag.early)};
+  if (option == CreateStreamOption::NOTIFY) {
+    EmitStream(stream);
+  }
 
-  Debug(this, "Notifying JavaScript of datagram");
-  MakeCallback(BindingData::Get(env()).session_datagram_callback(),
-               arraysize(argv),
-               argv);
+  // Update tracking statistics for the number of streams associated with this
+  // session.
+  auto& stats_ = impl_->stats_;
+  if (ngtcp2_conn_is_local_stream(*this, id)) {
+    switch (direction) {
+      case Direction::BIDIRECTIONAL: {
+        STAT_INCREMENT(Stats, bidi_out_stream_count);
+        break;
+      }
+      case Direction::UNIDIRECTIONAL: {
+        STAT_INCREMENT(Stats, uni_out_stream_count);
+        break;
+      }
+    }
+  } else {
+    switch (direction) {
+      case Direction::BIDIRECTIONAL: {
+        STAT_INCREMENT(Stats, bidi_in_stream_count);
+        break;
+      }
+      case Direction::UNIDIRECTIONAL: {
+        STAT_INCREMENT(Stats, uni_in_stream_count);
+        break;
+      }
+    }
+  }
 }
 
-void Session::EmitDatagramStatus(uint64_t id, quic::DatagramStatus status) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+void Session::RemoveStream(int64_t id) {
+  CHECK(!is_destroyed());
+  Debug(this, "Removing stream %" PRIi64 " from session", id);
+  if (!is_in_draining_period() && !is_in_closing_period() &&
+      !ngtcp2_conn_is_local_stream(*this, id)) {
+    if (ngtcp2_is_bidi_stream(id)) {
+      ngtcp2_conn_extend_max_streams_bidi(*this, 1);
+    } else {
+      ngtcp2_conn_extend_max_streams_uni(*this, 1);
+    }
+  }
 
-  CallbackScope<Session> cb_scope(this);
-  auto& state = BindingData::Get(env());
+  ngtcp2_conn_set_stream_user_data(*this, id, nullptr);
 
-  const auto status_to_string = ([&] {
-    switch (status) {
-      case quic::DatagramStatus::ACKNOWLEDGED:
-        return state.acknowledged_string();
-      case quic::DatagramStatus::LOST:
-        return state.lost_string();
-    }
-    UNREACHABLE();
-  })();
+  // Note that removing the stream from the streams map likely releases
+  // the last BaseObjectPtr holding onto the Stream instance, at which
+  // point it will be freed. If there are other BaseObjectPtr instances
+  // or other references to the Stream, however, freeing will be deferred.
+  // In either case, we cannot assume that the stream still exists after
+  // this call.
+  impl_->streams_.erase(id);
 
-  Local<Value> argv[] = {BigInt::NewFromUnsigned(env()->isolate(), id),
-                         status_to_string};
-  Debug(this, "Notifying JavaScript of datagram status");
-  MakeCallback(state.session_datagram_status_callback(), arraysize(argv), argv);
+  // If we are gracefully closing and there are no more streams,
+  // then we can proceed to finishing the close now. Note that the
+  // expectation is that the session will be destroyed once FinishClose
+  // returns.
+  if (impl_->state_->closing && impl_->state_->graceful_close) {
+    FinishClose();
+    CHECK(is_destroyed());
+  }
 }
 
-void Session::EmitHandshakeComplete() {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+void Session::ResumeStream(int64_t id) {
+  CHECK(!is_destroyed());
+  SendPendingDataScope send_scope(this);
+  application().ResumeStream(id);
+}
 
-  CallbackScope<Session> cb_scope(this);
+void Session::ShutdownStream(int64_t id, QuicError error) {
+  CHECK(!is_destroyed());
+  Debug(this, "Shutting down stream %" PRIi64 " with error %s", id, error);
+  SendPendingDataScope send_scope(this);
+  ngtcp2_conn_shutdown_stream(*this,
+                              0,
+                              id,
+                              error.type() == QuicError::Type::APPLICATION
+                                  ? error.code()
+                                  : NGTCP2_APP_NOERROR);
+}
 
-  auto isolate = env()->isolate();
+void Session::ShutdownStreamWrite(int64_t id, QuicError code) {
+  CHECK(!is_destroyed());
+  Debug(this, "Shutting down stream %" PRIi64 " write with error %s", id, code);
+  SendPendingDataScope send_scope(this);
+  ngtcp2_conn_shutdown_stream_write(*this,
+                                    0,
+                                    id,
+                                    code.type() == QuicError::Type::APPLICATION
+                                        ? code.code()
+                                        : NGTCP2_APP_NOERROR);
+}
 
-  static constexpr auto kServerName = 0;
-  static constexpr auto kSelectedAlpn = 1;
-  static constexpr auto kCipherName = 2;
-  static constexpr auto kCipherVersion = 3;
-  static constexpr auto kValidationErrorReason = 4;
-  static constexpr auto kValidationErrorCode = 5;
+void Session::StreamDataBlocked(int64_t id) {
+  CHECK(!is_destroyed());
+  auto& stats_ = impl_->stats_;
+  STAT_INCREMENT(Stats, block_count);
+  application().BlockStream(id);
+}
 
-  Local<Value> argv[] = {
-      Undefined(isolate),  // The negotiated server name
-      Undefined(isolate),  // The selected alpn
-      Undefined(isolate),  // Cipher name
-      Undefined(isolate),  // Cipher version
-      Undefined(isolate),  // Validation error reason
-      Undefined(isolate),  // Validation error code
-      v8::Boolean::New(isolate, tls_session().early_data_was_accepted())};
+void Session::CollectSessionTicketAppData(
+    SessionTicket::AppData* app_data) const {
+  CHECK(!is_destroyed());
+  application().CollectSessionTicketAppData(app_data);
+}
 
-  auto& tls = tls_session();
-  auto peerVerifyError = tls.VerifyPeerIdentity(env());
-  if (peerVerifyError.has_value() &&
-      (!peerVerifyError->reason.ToLocal(&argv[kValidationErrorReason]) ||
-       !peerVerifyError->code.ToLocal(&argv[kValidationErrorCode]))) {
-    return;
-  }
+SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
+    const SessionTicket::AppData& app_data,
+    SessionTicket::AppData::Source::Flag flag) {
+  CHECK(!is_destroyed());
+  return application().ExtractSessionTicketAppData(app_data, flag);
+}
 
-  if (!ToV8Value(env()->context(), tls.servername())
-           .ToLocal(&argv[kServerName]) ||
-      !ToV8Value(env()->context(), tls.alpn()).ToLocal(&argv[kSelectedAlpn]) ||
-      !tls.cipher_name(env()).ToLocal(&argv[kCipherName]) ||
-      !tls.cipher_version(env()).ToLocal(&argv[kCipherVersion])) {
-    return;
+void Session::MemoryInfo(MemoryTracker* tracker) const {
+  if (impl_) {
+    tracker->TrackField("impl", impl_);
+  }
+  tracker->TrackField("tls_session", tls_session_);
+  if (qlog_stream_) {
+    tracker->TrackField("qlog_stream", qlog_stream_);
+  }
+  if (keylog_stream_) {
+    tracker->TrackField("keylog_stream", keylog_stream_);
   }
+}
 
-  Debug(this, "Notifying JavaScript of handshake complete");
-  MakeCallback(BindingData::Get(env()).session_handshake_callback(),
-               arraysize(argv),
-               argv);
+bool Session::is_in_closing_period() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_in_closing_period(*this) != 0;
 }
 
-void Session::EmitPathValidation(PathValidationResult result,
-                                 PathValidationFlags flags,
-                                 const ValidatedPath& newPath,
-                                 const std::optional<ValidatedPath>& oldPath) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
-  if (state_->path_validation == 0) [[likely]] {
-    return;
-  }
+bool Session::is_in_draining_period() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_in_draining_period(*this) != 0;
+}
 
-  auto isolate = env()->isolate();
-  CallbackScope<Session> cb_scope(this);
-  auto& state = BindingData::Get(env());
+bool Session::wants_session_ticket() const {
+  return !is_destroyed() && impl_->state_->session_ticket == 1;
+}
 
-  const auto resultToString = ([&] {
-    switch (result) {
-      case PathValidationResult::ABORTED:
-        return state.aborted_string();
-      case PathValidationResult::FAILURE:
-        return state.failure_string();
-      case PathValidationResult::SUCCESS:
-        return state.success_string();
-    }
-    UNREACHABLE();
-  })();
+void Session::SetStreamOpenAllowed() {
+  CHECK(!is_destroyed());
+  impl_->state_->stream_open_allowed = 1;
+}
 
-  Local<Value> argv[] = {
-      resultToString,
-      SocketAddressBase::Create(env(), newPath.local)->object(),
-      SocketAddressBase::Create(env(), newPath.remote)->object(),
-      Undefined(isolate),
-      Undefined(isolate),
-      Boolean::New(isolate, flags.preferredAddress)};
+bool Session::can_send_packets() const {
+  // We can send packets if we're not in the middle of a ngtcp2 callback,
+  // we're not destroyed, we're not in a draining or closing period, and
+  // endpoint is set.
+  return !is_destroyed() && !NgTcp2CallbackScope::in_ngtcp2_callback(env()) &&
+         !is_in_draining_period() && !is_in_closing_period();
+}
 
-  if (oldPath.has_value()) {
-    argv[3] = SocketAddressBase::Create(env(), oldPath->local)->object();
-    argv[4] = SocketAddressBase::Create(env(), oldPath->remote)->object();
-  }
+bool Session::can_create_streams() const {
+  return !is_destroyed_or_closing() && !is_in_closing_period() &&
+         !is_in_draining_period();
+}
 
-  Debug(this, "Notifying JavaScript of path validation");
-  MakeCallback(state.session_path_validation_callback(), arraysize(argv), argv);
+bool Session::can_open_streams() const {
+  return !is_destroyed() && impl_->state_->stream_open_allowed;
 }
 
-void Session::EmitSessionTicket(Store&& ticket) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+uint64_t Session::max_data_left() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_max_data_left(*this);
+}
 
-  // If there is nothing listening for the session ticket, don't bother
-  // emitting.
-  if (!wants_session_ticket()) [[likely]] {
-    Debug(this, "Session ticket was discarded");
-    return;
-  }
+uint64_t Session::max_local_streams_uni() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_streams_uni_left(*this);
+}
 
-  CallbackScope<Session> cb_scope(this);
+uint64_t Session::max_local_streams_bidi() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_local_transport_params(*this)
+      ->initial_max_streams_bidi;
+}
 
-  auto remote_transport_params = GetRemoteTransportParams();
-  Store transport_params;
-  if (remote_transport_params)
-    transport_params = remote_transport_params.Encode(env());
+void Session::set_wrapped() {
+  CHECK(!is_destroyed());
+  impl_->state_->wrapped = 1;
+}
 
-  SessionTicket session_ticket(std::move(ticket), std::move(transport_params));
-  Local<Value> argv;
-  if (session_ticket.encode(env()).ToLocal(&argv)) {
-    Debug(this, "Notifying JavaScript of session ticket");
-    MakeCallback(BindingData::Get(env()).session_ticket_callback(), 1, &argv);
-  }
+void Session::set_priority_supported(bool on) {
+  CHECK(!is_destroyed());
+  impl_->state_->priority_supported = on ? 1 : 0;
 }
 
-void Session::EmitStream(BaseObjectPtr<Stream> stream) {
-  if (is_destroyed()) return;
-  if (!env()->can_call_into_js()) return;
-  CallbackScope<Session> cb_scope(this);
-  auto isolate = env()->isolate();
-  Local<Value> argv[] = {
-      stream->object(),
-      Integer::NewFromUnsigned(isolate,
-                               static_cast<uint32_t>(stream->direction())),
-  };
+void Session::ExtendStreamOffset(int64_t id, size_t amount) {
+  CHECK(!is_destroyed());
+  Debug(this, "Extending stream %" PRIi64 " offset by %zu bytes", id, amount);
+  ngtcp2_conn_extend_max_stream_offset(*this, id, amount);
+}
 
-  Debug(this, "Notifying JavaScript of stream created");
-  MakeCallback(
-      BindingData::Get(env()).stream_created_callback(), arraysize(argv), argv);
+void Session::ExtendOffset(size_t amount) {
+  CHECK(!is_destroyed());
+  Debug(this, "Extending offset by %zu bytes", amount);
+  ngtcp2_conn_extend_max_offset(*this, amount);
 }
 
-void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
-                                     const uint32_t* sv,
-                                     size_t nsv) {
-  DCHECK(!is_destroyed());
-  DCHECK(!is_server());
-  if (!env()->can_call_into_js()) return;
+void Session::UpdateDataStats() {
+  Debug(this, "Updating data stats");
+  auto& stats_ = impl_->stats_;
+  ngtcp2_conn_info info;
+  ngtcp2_conn_get_conn_info(*this, &info);
+  STAT_SET(Stats, bytes_in_flight, info.bytes_in_flight);
+  STAT_SET(Stats, cwnd, info.cwnd);
+  STAT_SET(Stats, latest_rtt, info.latest_rtt);
+  STAT_SET(Stats, min_rtt, info.min_rtt);
+  STAT_SET(Stats, rttvar, info.rttvar);
+  STAT_SET(Stats, smoothed_rtt, info.smoothed_rtt);
+  STAT_SET(Stats, ssthresh, info.ssthresh);
+  STAT_SET(
+      Stats,
+      max_bytes_in_flight,
+      std::max(STAT_GET(Stats, max_bytes_in_flight), info.bytes_in_flight));
+}
+
+void Session::SendConnectionClose() {
+  // Method is a non-op if the session is in a state where packets cannot
+  // be transmitted to the remote peer.
+  if (!can_send_packets()) return;
+
+  Debug(this, "Sending connection close packet to peer");
 
-  auto isolate = env()->isolate();
-  const auto to_integer = [&](uint32_t version) {
-    return Integer::NewFromUnsigned(isolate, version);
+  auto ErrorAndSilentClose = [&] {
+    Debug(this, "Failed to create connection close packet");
+    SetLastError(QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR));
+    Close(CloseMethod::SILENT);
   };
 
-  CallbackScope<Session> cb_scope(this);
+  if (is_server()) {
+    if (auto packet = Packet::CreateConnectionClosePacket(
+            env(),
+            endpoint(),
+            impl_->remote_address_,
+            *this,
+            impl_->last_error_)) [[likely]] {
+      return Send(packet);
+    }
 
-  // version() is the version that was actually configured for this session.
+    // If we are unable to create a connection close packet then
+    // we are in a bad state. An internal error will be set and
+    // the session will be silently closed. This is not ideal
+    // because the remote peer will not know immediately that
+    // the connection has terminated but there's not much else
+    // we can do.
+    return ErrorAndSilentClose();
+  }
 
-  // versions are the versions requested by the peer.
-  MaybeStackBuffer<Local<Value>, 5> versions;
-  versions.AllocateSufficientStorage(nsv);
-  for (size_t n = 0; n < nsv; n++) versions[n] = to_integer(sv[n]);
+  auto packet = Packet::Create(env(),
+                               endpoint(),
+                               impl_->remote_address_,
+                               kDefaultMaxPacketLength,
+                               "immediate connection close (client)");
+  if (!packet) [[unlikely]] {
+    return ErrorAndSilentClose();
+  }
 
-  // supported are the versions we acutually support expressed as a range.
-  // The first value is the minimum version, the second is the maximum.
-  Local<Value> supported[] = {to_integer(config_.options.min_version),
-                              to_integer(config_.options.version)};
+  ngtcp2_vec vec = *packet;
+  Path path(impl_->local_address_, impl_->remote_address_);
+  ssize_t nwrite = ngtcp2_conn_write_connection_close(*this,
+                                                      &path,
+                                                      nullptr,
+                                                      vec.base,
+                                                      vec.len,
+                                                      impl_->last_error_,
+                                                      uv_hrtime());
 
-  Local<Value> argv[] = {// The version configured for this session.
-                         to_integer(version()),
-                         // The versions requested.
-                         Array::New(isolate, versions.out(), nsv),
-                         // The versions we actually support.
-                         Array::New(isolate, supported, arraysize(supported))};
+  if (nwrite < 0) [[unlikely]] {
+    packet->Done(UV_ECANCELED);
+    return ErrorAndSilentClose();
+  }
 
-  Debug(this, "Notifying JavaScript of version negotiation");
-  MakeCallback(BindingData::Get(env()).session_version_negotiation_callback(),
-               arraysize(argv),
-               argv);
+  packet->Truncate(nwrite);
+  return Send(packet);
 }
 
-void Session::EmitKeylog(const char* line) {
-  if (!env()->can_call_into_js()) return;
-  if (keylog_stream_) {
-    Debug(this, "Emitting keylog line");
-    env()->SetImmediate([ptr = keylog_stream_, data = std::string(line) + "\n"](
-                            Environment* env) { ptr->Emit(data); });
+void Session::OnTimeout() {
+  CHECK(!is_destroyed());
+  HandleScope scope(env()->isolate());
+  int ret = ngtcp2_conn_handle_expiry(*this, uv_hrtime());
+  if (NGTCP2_OK(ret) && !is_in_closing_period() && !is_in_draining_period()) {
+    return application().SendPendingData();
   }
-}
 
-// ============================================================================
-// ngtcp2 static callback functions
+  Debug(this, "Session timed out");
+  SetLastError(QuicError::ForNgtcp2Error(ret));
+  Close(CloseMethod::SILENT);
+}
 
-#define NGTCP2_CALLBACK_SCOPE(name)                                            \
-  auto name = Impl::From(conn, user_data);                                     \
-  if (name->is_destroyed()) [[unlikely]] {                                     \
-    return NGTCP2_ERR_CALLBACK_FAILURE;                                        \
-  }                                                                            \
-  NgTcp2CallbackScope scope(session->env());
+void Session::UpdateTimer() {
+  CHECK(!is_destroyed());
+  // Both uv_hrtime and ngtcp2_conn_get_expiry return nanosecond units.
+  uint64_t expiry = ngtcp2_conn_get_expiry(*this);
+  uint64_t now = uv_hrtime();
+  Debug(
+      this, "Updating timer. Expiry: %" PRIu64 ", now: %" PRIu64, expiry, now);
 
-struct Session::Impl {
-  static Session* From(ngtcp2_conn* conn, void* user_data) {
-    DCHECK_NOT_NULL(user_data);
-    auto session = static_cast<Session*>(user_data);
-    DCHECK_EQ(conn, session->connection_.get());
-    return session;
+  if (expiry <= now) {
+    // The timer has already expired.
+    return OnTimeout();
   }
 
-  static void DoDestroy(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    session->Destroy();
-  }
+  auto timeout = (expiry - now) / NGTCP2_MILLISECONDS;
+  Debug(this, "Updating timeout to %zu milliseconds", timeout);
 
-  static void GetRemoteAddress(const FunctionCallbackInfo<Value>& args) {
-    auto env = Environment::GetCurrent(args);
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    auto address = session->remote_address();
-    args.GetReturnValue().Set(
-        SocketAddressBase::Create(env, std::make_shared<SocketAddress>(address))
-            ->object());
-  }
+  // If timeout is zero here, it means our timer is less than a millisecond
+  // off from expiry. Let's bump the timer to 1.
+  impl_->timer_.Update(timeout == 0 ? 1 : timeout);
+}
 
-  static void GetCertificate(const FunctionCallbackInfo<Value>& args) {
-    auto env = Environment::GetCurrent(args);
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    Local<Value> ret;
-    if (session->tls_session().cert(env).ToLocal(&ret))
-      args.GetReturnValue().Set(ret);
+void Session::DatagramStatus(uint64_t datagramId, quic::DatagramStatus status) {
+  DCHECK(!is_destroyed());
+  auto& stats_ = impl_->stats_;
+  switch (status) {
+    case quic::DatagramStatus::ACKNOWLEDGED: {
+      Debug(this, "Datagram %" PRIu64 " was acknowledged", datagramId);
+      STAT_INCREMENT(Stats, datagrams_acknowledged);
+      break;
+    }
+    case quic::DatagramStatus::LOST: {
+      Debug(this, "Datagram %" PRIu64 " was lost", datagramId);
+      STAT_INCREMENT(Stats, datagrams_lost);
+      break;
+    }
   }
+  EmitDatagramStatus(datagramId, status);
+}
 
-  static void GetEphemeralKeyInfo(const FunctionCallbackInfo<Value>& args) {
-    auto env = Environment::GetCurrent(args);
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    Local<Object> ret;
-    if (!session->is_server() &&
-        session->tls_session().ephemeral_key(env).ToLocal(&ret))
-      args.GetReturnValue().Set(ret);
-  }
+void Session::DatagramReceived(const uint8_t* data,
+                               size_t datalen,
+                               DatagramReceivedFlags flag) {
+  DCHECK(!is_destroyed());
+  // If there is nothing watching for the datagram on the JavaScript side,
+  // or if the datagram is zero-length, we just drop it on the floor.
+  if (impl_->state_->datagram == 0 || datalen == 0) return;
 
-  static void GetPeerCertificate(const FunctionCallbackInfo<Value>& args) {
-    auto env = Environment::GetCurrent(args);
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    Local<Value> ret;
-    if (session->tls_session().peer_cert(env).ToLocal(&ret))
-      args.GetReturnValue().Set(ret);
-  }
+  Debug(this, "Session is receiving datagram of size %zu", datalen);
+  auto& stats_ = impl_->stats_;
+  STAT_INCREMENT(Stats, datagrams_received);
+  auto backing = ArrayBuffer::NewBackingStore(
+      env()->isolate(),
+      datalen,
+      BackingStoreInitializationMode::kUninitialized);
+  memcpy(backing->Data(), data, datalen);
+  EmitDatagram(Store(std::move(backing), datalen), flag);
+}
 
-  static void GracefulClose(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    session->Close(Session::CloseMethod::GRACEFUL);
-  }
+void Session::GenerateNewConnectionId(ngtcp2_cid* cid,
+                                      size_t len,
+                                      uint8_t* token) {
+  DCHECK(!is_destroyed());
+  CID cid_ = impl_->config_.options.cid_factory->GenerateInto(cid, len);
+  Debug(this, "Generated new connection id %s", cid_);
+  StatelessResetToken new_token(
+      token, endpoint().options().reset_token_secret, cid_);
+  endpoint().AssociateCID(cid_, impl_->config_.scid);
+  endpoint().AssociateStatelessResetToken(new_token, this);
+}
 
-  static void SilentClose(const FunctionCallbackInfo<Value>& args) {
-    // This is exposed for testing purposes only!
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    session->Close(Session::CloseMethod::SILENT);
-  }
+bool Session::HandshakeCompleted() {
+  DCHECK(!is_destroyed());
+  DCHECK(!impl_->state_->handshake_completed);
 
-  static void UpdateKey(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    // Initiating a key update may fail if it is done too early (either
-    // before the TLS handshake has been confirmed or while a previous
-    // key update is being processed). When it fails, InitiateKeyUpdate()
-    // will return false.
-    Debug(session, "Initiating key update");
-    args.GetReturnValue().Set(session->tls_session().InitiateKeyUpdate());
-  }
+  Debug(this, "Session handshake completed");
+  impl_->state_->handshake_completed = 1;
+  auto& stats_ = impl_->stats_;
+  STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
+  SetStreamOpenAllowed();
 
-  static void DoOpenStream(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    DCHECK(args[0]->IsUint32());
-    auto direction = static_cast<Direction>(args[0].As<Uint32>()->Value());
-    BaseObjectPtr<Stream> stream = session->OpenStream(direction);
+  // TODO(@jasnel): Not yet supporting early data...
+  // if (!tls_session().early_data_was_accepted())
+  //   ngtcp2_conn_tls_early_data_rejected(*this);
+
+  // When in a server session, handshake completed == handshake confirmed.
+  if (is_server()) {
+    HandshakeConfirmed();
+
+    auto& ep = endpoint();
 
-    if (stream) args.GetReturnValue().Set(stream->object());
+    if (!ep.is_closed() && !ep.is_closing()) {
+      auto token = ep.GenerateNewToken(version(), impl_->remote_address_);
+      ngtcp2_vec vec = token;
+      if (NGTCP2_ERR(ngtcp2_conn_submit_new_token(*this, vec.base, vec.len))) {
+        // Submitting the new token failed... In this case we're going to
+        // fail because submitting the new token should only fail if we
+        // ran out of memory or some other unrecoverable state.
+        return false;
+      }
+    }
   }
 
-  static void DoSendDatagram(const FunctionCallbackInfo<Value>& args) {
-    auto env = Environment::GetCurrent(args);
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    DCHECK(args[0]->IsArrayBufferView());
-    args.GetReturnValue().Set(BigInt::New(
-        env->isolate(),
-        session->SendDatagram(Store(args[0].As<ArrayBufferView>()))));
+  EmitHandshakeComplete();
+
+  return true;
+}
+
+void Session::HandshakeConfirmed() {
+  DCHECK(!is_destroyed());
+  DCHECK(!impl_->state_->handshake_confirmed);
+  Debug(this, "Session handshake confirmed");
+  impl_->state_->handshake_confirmed = 1;
+  auto& stats_ = impl_->stats_;
+  STAT_RECORD_TIMESTAMP(Stats, handshake_confirmed_at);
+}
+
+void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) {
+  if (options().preferred_address_strategy ==
+      PreferredAddress::Policy::IGNORE_PREFERRED) {
+    Debug(this, "Ignoring preferred address");
+    return;
   }
 
-  static int on_acknowledge_stream_data_offset(ngtcp2_conn* conn,
-                                               int64_t stream_id,
-                                               uint64_t offset,
-                                               uint64_t datalen,
-                                               void* user_data,
-                                               void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().AcknowledgeStreamData(Stream::From(stream_user_data),
-                                                 datalen);
-    return NGTCP2_SUCCESS;
+  switch (endpoint().local_address().family()) {
+    case AF_INET: {
+      Debug(this, "Selecting preferred address for AF_INET");
+      auto ipv4 = preferredAddress->ipv4();
+      if (ipv4.has_value()) {
+        if (ipv4->address.empty() || ipv4->port == 0) return;
+        CHECK(SocketAddress::New(AF_INET,
+                                 std::string(ipv4->address).c_str(),
+                                 ipv4->port,
+                                 &impl_->remote_address_));
+        preferredAddress->Use(ipv4.value());
+      }
+      break;
+    }
+    case AF_INET6: {
+      Debug(this, "Selecting preferred address for AF_INET6");
+      auto ipv6 = preferredAddress->ipv6();
+      if (ipv6.has_value()) {
+        if (ipv6->address.empty() || ipv6->port == 0) return;
+        CHECK(SocketAddress::New(AF_INET,
+                                 std::string(ipv6->address).c_str(),
+                                 ipv6->port,
+                                 &impl_->remote_address_));
+        preferredAddress->Use(ipv6.value());
+      }
+      break;
+    }
   }
+}
 
-  static int on_acknowledge_datagram(ngtcp2_conn* conn,
-                                     uint64_t dgram_id,
-                                     void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->DatagramStatus(dgram_id, quic::DatagramStatus::ACKNOWLEDGED);
-    return NGTCP2_SUCCESS;
+CID Session::new_cid(size_t len) const {
+  return options().cid_factory->Generate(len);
+}
+
+void Session::ProcessPendingBidiStreams() {
+  // It shouldn't be possible to get here if can_create_streams() is false.
+  DCHECK(can_create_streams());
+
+  int64_t id;
+
+  while (!impl_->pending_bidi_stream_queue_.IsEmpty()) {
+    if (ngtcp2_conn_get_streams_bidi_left(*this) == 0) {
+      return;
+    }
+
+    switch (ngtcp2_conn_open_bidi_stream(*this, &id, nullptr)) {
+      case 0: {
+        impl_->pending_bidi_stream_queue_.PopFront()->fulfill(id);
+        continue;
+      }
+      case NGTCP2_ERR_STREAM_ID_BLOCKED: {
+        // This case really should not happen since we've checked the number
+        // of bidi streams left above. However, if it does happen we'll treat
+        // it the same as if the get_streams_bidi_left call returned zero.
+        return;
+      }
+      default: {
+        // We failed to open the stream for some reason other than being
+        // blocked. Report the failure.
+        impl_->pending_bidi_stream_queue_.PopFront()->reject(
+            QuicError::ForTransport(NGTCP2_STREAM_LIMIT_ERROR));
+        continue;
+      }
+    }
   }
+}
 
-  static int on_cid_status(ngtcp2_conn* conn,
-                           ngtcp2_connection_id_status_type type,
-                           uint64_t seq,
-                           const ngtcp2_cid* cid,
-                           const uint8_t* token,
-                           void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    std::optional<StatelessResetToken> maybe_reset_token;
-    if (token != nullptr) maybe_reset_token.emplace(token);
-    auto& endpoint = session->endpoint();
-    switch (type) {
-      case NGTCP2_CONNECTION_ID_STATUS_TYPE_ACTIVATE: {
-        endpoint.AssociateCID(session->config_.scid, CID(cid));
-        if (token != nullptr) {
-          endpoint.AssociateStatelessResetToken(StatelessResetToken(token),
-                                                session);
-        }
-        break;
+void Session::ProcessPendingUniStreams() {
+  // It shouldn't be possible to get here if can_create_streams() is false.
+  DCHECK(can_create_streams());
+
+  int64_t id;
+
+  while (!impl_->pending_uni_stream_queue_.IsEmpty()) {
+    if (ngtcp2_conn_get_streams_uni_left(*this) == 0) {
+      return;
+    }
+
+    switch (ngtcp2_conn_open_uni_stream(*this, &id, nullptr)) {
+      case 0: {
+        impl_->pending_uni_stream_queue_.PopFront()->fulfill(id);
+        continue;
       }
-      case NGTCP2_CONNECTION_ID_STATUS_TYPE_DEACTIVATE: {
-        endpoint.DisassociateCID(CID(cid));
-        if (token != nullptr) {
-          endpoint.DisassociateStatelessResetToken(StatelessResetToken(token));
-        }
-        break;
+      case NGTCP2_ERR_STREAM_ID_BLOCKED: {
+        // This case really should not happen since we've checked the number
+        // of bidi streams left above. However, if it does happen we'll treat
+        // it the same as if the get_streams_bidi_left call returned zero.
+        return;
+      }
+      default: {
+        // We failed to open the stream for some reason other than being
+        // blocked. Report the failure.
+        impl_->pending_uni_stream_queue_.PopFront()->reject(
+            QuicError::ForTransport(NGTCP2_STREAM_LIMIT_ERROR));
+        continue;
       }
     }
-    return NGTCP2_SUCCESS;
   }
+}
 
-  static int on_extend_max_remote_streams_bidi(ngtcp2_conn* conn,
-                                               uint64_t max_streams,
-                                               void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().ExtendMaxStreams(
-        EndpointLabel::REMOTE, Direction::BIDIRECTIONAL, max_streams);
-    return NGTCP2_SUCCESS;
-  }
+// JavaScript callouts
 
-  static int on_extend_max_remote_streams_uni(ngtcp2_conn* conn,
-                                              uint64_t max_streams,
-                                              void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().ExtendMaxStreams(
-        EndpointLabel::REMOTE, Direction::UNIDIRECTIONAL, max_streams);
-    return NGTCP2_SUCCESS;
-  }
+void Session::EmitClose(const QuicError& error) {
+  DCHECK(!is_destroyed());
+  // When EmitClose is called, the expectation is that the JavaScript
+  // side will close the loop and call destroy on the underlying session.
+  // If we cannot call out into JavaScript at this point, go ahead and
+  // skip to calling destroy directly.
+  if (!env()->can_call_into_js()) return Destroy();
 
-  static int on_extend_max_streams_bidi(ngtcp2_conn* conn,
-                                        uint64_t max_streams,
-                                        void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().ExtendMaxStreams(
-        EndpointLabel::LOCAL, Direction::BIDIRECTIONAL, max_streams);
-    return NGTCP2_SUCCESS;
-  }
+  CallbackScope<Session> cb_scope(this);
 
-  static int on_extend_max_streams_uni(ngtcp2_conn* conn,
-                                       uint64_t max_streams,
-                                       void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().ExtendMaxStreams(
-        EndpointLabel::LOCAL, Direction::UNIDIRECTIONAL, max_streams);
-    return NGTCP2_SUCCESS;
+  Local<Value> argv[] = {
+      Integer::New(env()->isolate(), static_cast<int>(error.type())),
+      BigInt::NewFromUnsigned(env()->isolate(), error.code()),
+      Undefined(env()->isolate()),
+  };
+  if (error.reason().length() > 0 &&
+      !ToV8Value(env()->context(), error.reason()).ToLocal(&argv[2])) {
+    return;
   }
 
-  static int on_extend_max_stream_data(ngtcp2_conn* conn,
-                                       int64_t stream_id,
-                                       uint64_t max_data,
-                                       void* user_data,
-                                       void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().ExtendMaxStreamData(Stream::From(stream_user_data),
-                                               max_data);
-    return NGTCP2_SUCCESS;
-  }
+  MakeCallback(
+      BindingData::Get(env()).session_close_callback(), arraysize(argv), argv);
 
-  static int on_get_new_cid(ngtcp2_conn* conn,
-                            ngtcp2_cid* cid,
-                            uint8_t* token,
-                            size_t cidlen,
-                            void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    return session->GenerateNewConnectionId(cid, cidlen, token)
-               ? NGTCP2_SUCCESS
-               : NGTCP2_ERR_CALLBACK_FAILURE;
-  }
+  // Importantly, the session instance itself should have been destroyed!
+  CHECK(is_destroyed());
+}
 
-  static int on_handshake_completed(ngtcp2_conn* conn, void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    return session->HandshakeCompleted() ? NGTCP2_SUCCESS
-                                         : NGTCP2_ERR_CALLBACK_FAILURE;
-  }
+void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
+  DCHECK(!is_destroyed());
+  if (!env()->can_call_into_js()) return;
 
-  static int on_handshake_confirmed(ngtcp2_conn* conn, void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->HandshakeConfirmed();
-    return NGTCP2_SUCCESS;
-  }
+  CallbackScope<Session> cbv_scope(this);
 
-  static int on_lost_datagram(ngtcp2_conn* conn,
-                              uint64_t dgram_id,
-                              void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->DatagramStatus(dgram_id, quic::DatagramStatus::LOST);
-    return NGTCP2_SUCCESS;
-  }
+  Local<Value> argv[] = {datagram.ToUint8Array(env()),
+                         Boolean::New(env()->isolate(), flag.early)};
 
-  static int on_path_validation(ngtcp2_conn* conn,
-                                uint32_t flags,
-                                const ngtcp2_path* path,
-                                const ngtcp2_path* old_path,
-                                ngtcp2_path_validation_result res,
-                                void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    bool flag_preferred_address =
-        flags & NGTCP2_PATH_VALIDATION_FLAG_PREFERRED_ADDR;
-    ValidatedPath newValidatedPath{
-        std::make_shared<SocketAddress>(path->local.addr),
-        std::make_shared<SocketAddress>(path->remote.addr)};
-    std::optional<ValidatedPath> oldValidatedPath = std::nullopt;
-    if (old_path != nullptr) {
-      oldValidatedPath =
-          ValidatedPath{std::make_shared<SocketAddress>(old_path->local.addr),
-                        std::make_shared<SocketAddress>(old_path->remote.addr)};
+  MakeCallback(BindingData::Get(env()).session_datagram_callback(),
+               arraysize(argv),
+               argv);
+}
+
+void Session::EmitDatagramStatus(uint64_t id, quic::DatagramStatus status) {
+  DCHECK(!is_destroyed());
+
+  if (!env()->can_call_into_js()) return;
+
+  CallbackScope<Session> cb_scope(this);
+
+  auto& state = BindingData::Get(env());
+
+  const auto status_to_string = ([&] {
+    switch (status) {
+      case quic::DatagramStatus::ACKNOWLEDGED:
+        return state.acknowledged_string();
+      case quic::DatagramStatus::LOST:
+        return state.lost_string();
     }
-    session->EmitPathValidation(static_cast<PathValidationResult>(res),
-                                PathValidationFlags{flag_preferred_address},
-                                newValidatedPath,
-                                oldValidatedPath);
-    return NGTCP2_SUCCESS;
+    UNREACHABLE();
+  })();
+
+  Local<Value> argv[] = {BigInt::NewFromUnsigned(env()->isolate(), id),
+                         status_to_string};
+
+  MakeCallback(state.session_datagram_status_callback(), arraysize(argv), argv);
+}
+
+void Session::EmitHandshakeComplete() {
+  DCHECK(!is_destroyed());
+
+  if (!env()->can_call_into_js()) return;
+
+  CallbackScope<Session> cb_scope(this);
+
+  auto isolate = env()->isolate();
+
+  static constexpr auto kServerName = 0;
+  static constexpr auto kSelectedAlpn = 1;
+  static constexpr auto kCipherName = 2;
+  static constexpr auto kCipherVersion = 3;
+  static constexpr auto kValidationErrorReason = 4;
+  static constexpr auto kValidationErrorCode = 5;
+
+  Local<Value> argv[] = {
+      Undefined(isolate),  // The negotiated server name
+      Undefined(isolate),  // The selected protocol
+      Undefined(isolate),  // Cipher name
+      Undefined(isolate),  // Cipher version
+      Undefined(isolate),  // Validation error reason
+      Undefined(isolate),  // Validation error code
+      Boolean::New(isolate, tls_session().early_data_was_accepted())};
+
+  auto& tls = tls_session();
+  auto peerVerifyError = tls.VerifyPeerIdentity(env());
+  if (peerVerifyError.has_value() &&
+      (!peerVerifyError->reason.ToLocal(&argv[kValidationErrorReason]) ||
+       !peerVerifyError->code.ToLocal(&argv[kValidationErrorCode]))) {
+    return;
   }
 
-  static int on_receive_datagram(ngtcp2_conn* conn,
-                                 uint32_t flags,
-                                 const uint8_t* data,
-                                 size_t datalen,
-                                 void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    DatagramReceivedFlags f;
-    f.early = flags & NGTCP2_DATAGRAM_FLAG_0RTT;
-    session->DatagramReceived(data, datalen, f);
-    return NGTCP2_SUCCESS;
+  if (!ToV8Value(env()->context(), tls.servername())
+           .ToLocal(&argv[kServerName]) ||
+      !ToV8Value(env()->context(), tls.protocol())
+           .ToLocal(&argv[kSelectedAlpn]) ||
+      !tls.cipher_name(env()).ToLocal(&argv[kCipherName]) ||
+      !tls.cipher_version(env()).ToLocal(&argv[kCipherVersion])) {
+    return;
   }
 
-  static int on_receive_new_token(ngtcp2_conn* conn,
-                                  const uint8_t* token,
-                                  size_t tokenlen,
-                                  void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    // We currently do nothing with this callback.
-    return NGTCP2_SUCCESS;
+  MakeCallback(BindingData::Get(env()).session_handshake_callback(),
+               arraysize(argv),
+               argv);
+}
+
+void Session::EmitPathValidation(PathValidationResult result,
+                                 PathValidationFlags flags,
+                                 const ValidatedPath& newPath,
+                                 const std::optional<ValidatedPath>& oldPath) {
+  DCHECK(!is_destroyed());
+
+  if (!env()->can_call_into_js()) return;
+
+  if (impl_->state_->path_validation == 0) [[likely]] {
+    return;
   }
 
-  static int on_receive_rx_key(ngtcp2_conn* conn,
-                               ngtcp2_encryption_level level,
-                               void* user_data) {
-    auto session = Impl::From(conn, user_data);
-    if (session->is_destroyed()) [[unlikely]] {
-      return NGTCP2_ERR_CALLBACK_FAILURE;
-    }
-    CHECK(!session->is_server());
+  auto isolate = env()->isolate();
+  CallbackScope<Session> cb_scope(this);
+  auto& state = BindingData::Get(env());
 
-    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
+  const auto resultToString = ([&] {
+    switch (result) {
+      case PathValidationResult::ABORTED:
+        return state.aborted_string();
+      case PathValidationResult::FAILURE:
+        return state.failure_string();
+      case PathValidationResult::SUCCESS:
+        return state.success_string();
+    }
+    UNREACHABLE();
+  })();
 
-    Debug(session,
-          "Receiving RX key for level %d for dcid %s",
-          to_string(level),
-          session->config().dcid);
+  Local<Value> argv[] = {
+      resultToString,
+      SocketAddressBase::Create(env(), newPath.local)->object(),
+      SocketAddressBase::Create(env(), newPath.remote)->object(),
+      Undefined(isolate),
+      Undefined(isolate),
+      Boolean::New(isolate, flags.preferredAddress)};
 
-    return session->application().Start() ? NGTCP2_SUCCESS
-                                          : NGTCP2_ERR_CALLBACK_FAILURE;
+  if (oldPath.has_value()) {
+    argv[3] = SocketAddressBase::Create(env(), oldPath->local)->object();
+    argv[4] = SocketAddressBase::Create(env(), oldPath->remote)->object();
   }
 
-  static int on_receive_stateless_reset(ngtcp2_conn* conn,
-                                        const ngtcp2_pkt_stateless_reset* sr,
-                                        void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->state_->stateless_reset = 1;
-    return NGTCP2_SUCCESS;
+  Debug(this, "Notifying JavaScript of path validation");
+  MakeCallback(state.session_path_validation_callback(), arraysize(argv), argv);
+}
+
+void Session::EmitSessionTicket(Store&& ticket) {
+  DCHECK(!is_destroyed());
+
+  if (!env()->can_call_into_js()) return;
+
+  // If there is nothing listening for the session ticket, don't bother
+  // emitting.
+  if (impl_->state_->session_ticket == 0) [[likely]] {
+    Debug(this, "Session ticket was discarded");
+    return;
   }
 
-  static int on_receive_stream_data(ngtcp2_conn* conn,
-                                    uint32_t flags,
-                                    int64_t stream_id,
-                                    uint64_t offset,
-                                    const uint8_t* data,
-                                    size_t datalen,
-                                    void* user_data,
-                                    void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    Stream::ReceiveDataFlags f;
-    f.early = flags & NGTCP2_STREAM_DATA_FLAG_0RTT;
-    f.fin = flags & NGTCP2_STREAM_DATA_FLAG_FIN;
-
-    if (stream_user_data == nullptr) {
-      // We have an implicitly created stream.
-      auto stream = session->CreateStream(stream_id);
-      if (stream) {
-        session->EmitStream(stream);
-        session->application().ReceiveStreamData(
-            stream.get(), data, datalen, f);
-      } else {
-        return ngtcp2_conn_shutdown_stream(
-                   *session, 0, stream_id, NGTCP2_APP_NOERROR) == 0
-                   ? NGTCP2_SUCCESS
-                   : NGTCP2_ERR_CALLBACK_FAILURE;
+  CallbackScope<Session> cb_scope(this);
+
+  auto& remote_params = remote_transport_params();
+  Store transport_params;
+  if (remote_params) {
+    if (auto transport_params = remote_params.Encode(env())) {
+      SessionTicket session_ticket(std::move(ticket),
+                                   std::move(transport_params));
+      Local<Value> argv;
+      if (session_ticket.encode(env()).ToLocal(&argv)) [[likely]] {
+        MakeCallback(
+            BindingData::Get(env()).session_ticket_callback(), 1, &argv);
       }
-    } else {
-      session->application().ReceiveStreamData(
-          Stream::From(stream_user_data), data, datalen, f);
     }
-    return NGTCP2_SUCCESS;
   }
+}
 
-  static int on_receive_tx_key(ngtcp2_conn* conn,
-                               ngtcp2_encryption_level level,
-                               void* user_data) {
-    auto session = Impl::From(conn, user_data);
-    if (session->is_destroyed()) [[unlikely]] {
-      return NGTCP2_ERR_CALLBACK_FAILURE;
-    }
-    CHECK(session->is_server());
+void Session::EmitStream(const BaseObjectWeakPtr<Stream>& stream) {
+  DCHECK(!is_destroyed());
 
-    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
+  if (!env()->can_call_into_js()) return;
+  CallbackScope<Session> cb_scope(this);
 
-    Debug(session,
-          "Receiving TX key for level %d for dcid %s",
-          to_string(level),
-          session->config().dcid);
-    return session->application().Start() ? NGTCP2_SUCCESS
-                                          : NGTCP2_ERR_CALLBACK_FAILURE;
-  }
+  auto isolate = env()->isolate();
+  Local<Value> argv[] = {
+      stream->object(),
+      Integer::NewFromUnsigned(isolate,
+                               static_cast<uint32_t>(stream->direction())),
+  };
 
-  static int on_receive_version_negotiation(ngtcp2_conn* conn,
-                                            const ngtcp2_pkt_hd* hd,
-                                            const uint32_t* sv,
-                                            size_t nsv,
-                                            void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->EmitVersionNegotiation(*hd, sv, nsv);
-    return NGTCP2_SUCCESS;
-  }
+  MakeCallback(
+      BindingData::Get(env()).stream_created_callback(), arraysize(argv), argv);
+}
 
-  static int on_remove_connection_id(ngtcp2_conn* conn,
-                                     const ngtcp2_cid* cid,
-                                     void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->endpoint().DisassociateCID(CID(cid));
-    return NGTCP2_SUCCESS;
-  }
+void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
+                                     const uint32_t* sv,
+                                     size_t nsv) {
+  DCHECK(!is_destroyed());
+  DCHECK(!is_server());
 
-  static int on_select_preferred_address(ngtcp2_conn* conn,
-                                         ngtcp2_path* dest,
-                                         const ngtcp2_preferred_addr* paddr,
-                                         void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    PreferredAddress preferred_address(dest, paddr);
-    session->SelectPreferredAddress(&preferred_address);
-    return NGTCP2_SUCCESS;
-  }
+  if (!env()->can_call_into_js()) return;
 
-  static int on_stream_close(ngtcp2_conn* conn,
-                             uint32_t flags,
-                             int64_t stream_id,
-                             uint64_t app_error_code,
-                             void* user_data,
-                             void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) {
-      session->application().StreamClose(
-          Stream::From(stream_user_data),
-          QuicError::ForApplication(app_error_code));
-    } else {
-      session->application().StreamClose(Stream::From(stream_user_data));
-    }
-    return NGTCP2_SUCCESS;
-  }
+  CallbackScope<Session> cb_scope(this);
+  auto& opts = options();
 
-  static int on_stream_reset(ngtcp2_conn* conn,
-                             int64_t stream_id,
-                             uint64_t final_size,
-                             uint64_t app_error_code,
-                             void* user_data,
-                             void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().StreamReset(
-        Stream::From(stream_user_data),
-        final_size,
-        QuicError::ForApplication(app_error_code));
-    return NGTCP2_SUCCESS;
-  }
+  // version() is the version that was actually configured for this session.
+  // versions are the versions requested by the peer.
+  // supported are the versions supported by Node.js.
 
-  static int on_stream_stop_sending(ngtcp2_conn* conn,
-                                    int64_t stream_id,
-                                    uint64_t app_error_code,
-                                    void* user_data,
-                                    void* stream_user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->application().StreamStopSending(
-        Stream::From(stream_user_data),
-        QuicError::ForApplication(app_error_code));
-    return NGTCP2_SUCCESS;
+  LocalVector<Value> versions(env()->isolate(), nsv);
+  for (size_t n = 0; n < nsv; n++) {
+    versions.push_back(Integer::NewFromUnsigned(env()->isolate(), sv[n]));
   }
 
-  static void on_rand(uint8_t* dest,
-                      size_t destlen,
-                      const ngtcp2_rand_ctx* rand_ctx) {
-    CHECK(ncrypto::CSPRNG(dest, destlen));
-  }
+  // supported are the versions we acutually support expressed as a range.
+  // The first value is the minimum version, the second is the maximum.
+  Local<Value> supported[] = {
+      Integer::NewFromUnsigned(env()->isolate(), opts.min_version),
+      Integer::NewFromUnsigned(env()->isolate(), opts.version)};
 
-  static int on_early_data_rejected(ngtcp2_conn* conn, void* user_data) {
-    // TODO(@jasnell): Called when early data was rejected by server during the
-    // TLS handshake or client decided not to attempt early data.
-    return NGTCP2_SUCCESS;
-  }
+  Local<Value> argv[] = {
+      // The version configured for this session.
+      Integer::NewFromUnsigned(env()->isolate(), version()),
+      // The versions requested.
+      Array::New(env()->isolate(), versions.data(), nsv),
+      // The versions we actually support.
+      Array::New(env()->isolate(), supported, arraysize(supported))};
 
-  static constexpr ngtcp2_callbacks CLIENT = {
-      ngtcp2_crypto_client_initial_cb,
-      nullptr,
-      ngtcp2_crypto_recv_crypto_data_cb,
-      on_handshake_completed,
-      on_receive_version_negotiation,
-      ngtcp2_crypto_encrypt_cb,
-      ngtcp2_crypto_decrypt_cb,
-      ngtcp2_crypto_hp_mask_cb,
-      on_receive_stream_data,
-      on_acknowledge_stream_data_offset,
-      nullptr,
-      on_stream_close,
-      on_receive_stateless_reset,
-      ngtcp2_crypto_recv_retry_cb,
-      on_extend_max_streams_bidi,
-      on_extend_max_streams_uni,
-      on_rand,
-      on_get_new_cid,
-      on_remove_connection_id,
-      ngtcp2_crypto_update_key_cb,
-      on_path_validation,
-      on_select_preferred_address,
-      on_stream_reset,
-      on_extend_max_remote_streams_bidi,
-      on_extend_max_remote_streams_uni,
-      on_extend_max_stream_data,
-      on_cid_status,
-      on_handshake_confirmed,
-      on_receive_new_token,
-      ngtcp2_crypto_delete_crypto_aead_ctx_cb,
-      ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
-      on_receive_datagram,
-      on_acknowledge_datagram,
-      on_lost_datagram,
-      ngtcp2_crypto_get_path_challenge_data_cb,
-      on_stream_stop_sending,
-      ngtcp2_crypto_version_negotiation_cb,
-      on_receive_rx_key,
-      nullptr,
-      on_early_data_rejected};
+  MakeCallback(BindingData::Get(env()).session_version_negotiation_callback(),
+               arraysize(argv),
+               argv);
+}
 
-  static constexpr ngtcp2_callbacks SERVER = {
-      nullptr,
-      ngtcp2_crypto_recv_client_initial_cb,
-      ngtcp2_crypto_recv_crypto_data_cb,
-      on_handshake_completed,
-      nullptr,
-      ngtcp2_crypto_encrypt_cb,
-      ngtcp2_crypto_decrypt_cb,
-      ngtcp2_crypto_hp_mask_cb,
-      on_receive_stream_data,
-      on_acknowledge_stream_data_offset,
-      nullptr,
-      on_stream_close,
-      on_receive_stateless_reset,
-      nullptr,
-      on_extend_max_streams_bidi,
-      on_extend_max_streams_uni,
-      on_rand,
-      on_get_new_cid,
-      on_remove_connection_id,
-      ngtcp2_crypto_update_key_cb,
-      on_path_validation,
-      nullptr,
-      on_stream_reset,
-      on_extend_max_remote_streams_bidi,
-      on_extend_max_remote_streams_uni,
-      on_extend_max_stream_data,
-      on_cid_status,
-      nullptr,
-      nullptr,
-      ngtcp2_crypto_delete_crypto_aead_ctx_cb,
-      ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
-      on_receive_datagram,
-      on_acknowledge_datagram,
-      on_lost_datagram,
-      ngtcp2_crypto_get_path_challenge_data_cb,
-      on_stream_stop_sending,
-      ngtcp2_crypto_version_negotiation_cb,
-      nullptr,
-      on_receive_tx_key,
-      on_early_data_rejected};
-};
+void Session::EmitKeylog(const char* line) {
+  if (!env()->can_call_into_js()) return;
+  if (keylog_stream_) {
+    Debug(this, "Emitting keylog line");
+    env()->SetImmediate([ptr = keylog_stream_, data = std::string(line) + "\n"](
+                            Environment* env) { ptr->Emit(data); });
+  }
+}
 
-#undef NGTCP2_CALLBACK_SCOPE
+// ============================================================================
 
 Local<FunctionTemplate> Session::GetConstructorTemplate(Environment* env) {
   auto& state = BindingData::Get(env);
@@ -2304,54 +2791,16 @@ void Session::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
 #undef V
 }
 
-Session::QuicConnectionPointer Session::InitConnection() {
-  ngtcp2_conn* conn;
-  Path path(local_address_, remote_address_);
-  Debug(this, "Initializing session for path %s", path);
-  TransportParams::Config tp_config(
-      config_.side, config_.ocid, config_.retry_scid);
-  TransportParams transport_params(tp_config, config_.options.transport_params);
-  transport_params.GenerateSessionTokens(this);
-
-  switch (config_.side) {
-    case Side::SERVER: {
-      CHECK_EQ(ngtcp2_conn_server_new(&conn,
-                                      config_.dcid,
-                                      config_.scid,
-                                      path,
-                                      config_.version,
-                                      &Impl::SERVER,
-                                      &config_.settings,
-                                      transport_params,
-                                      &allocator_,
-                                      this),
-               0);
-      break;
-    }
-    case Side::CLIENT: {
-      CHECK_EQ(ngtcp2_conn_client_new(&conn,
-                                      config_.dcid,
-                                      config_.scid,
-                                      path,
-                                      config_.version,
-                                      &Impl::CLIENT,
-                                      &config_.settings,
-                                      transport_params,
-                                      &allocator_,
-                                      this),
-               0);
-      break;
-    }
-  }
-  return QuicConnectionPointer(conn);
-}
-
-void Session::InitPerIsolate(IsolateData* data,
-                             v8::Local<v8::ObjectTemplate> target) {
+void Session::InitPerIsolate(IsolateData* data, Local<ObjectTemplate> target) {
   // TODO(@jasnell): Implement the per-isolate state
 }
 
 void Session::InitPerContext(Realm* realm, Local<Object> target) {
+#define V(name, str)                                                           \
+  NODE_DEFINE_CONSTANT(target, CC_ALGO_##name);                                \
+  NODE_DEFINE_STRING_CONSTANT(target, "CC_ALGO_" #name "_STR", #str);
+  CC_ALGOS(V)
+#undef V
   // Make sure the Session constructor template is initialized.
   USE(GetConstructorTemplate(realm->env()));
 
diff --git a/src/quic/session.h b/src/quic/session.h
index f980af9611c6c7..be59a6ed7dec9d 100644
--- a/src/quic/session.h
+++ b/src/quic/session.h
@@ -50,6 +50,13 @@ class Endpoint;
 // secure the communication. Once those keys are established, the Session can be
 // used to open Streams. Based on how the Session is configured, any number of
 // Streams can exist concurrently on a single Session.
+//
+// The Session wraps an ngtcp2_conn that is initialized when the session object
+// is created. This ngtcp2_conn is destroyed when the session object is freed.
+// However, the session can be in a closed/destroyed state and still have a
+// valid ngtcp2_conn pointer. This is important because the ngtcp2 still might
+// be processsing data within the scope of an ngtcp2_conn after the session
+// object itself is closed/destroyed by user code.
 class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
  public:
   // For simplicity, we use the same Application::Options struct for all
@@ -92,6 +99,17 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
   // of a QUIC Session.
   class Application;
 
+  // The ApplicationProvider optionally supplies the underlying application
+  // protocol handler used by a session. The ApplicationProvider is supplied
+  // in the *internal* options (that is, it is not exposed as a public, user
+  // facing API. If the ApplicationProvider is not specified, then the
+  // DefaultApplication is used (see application.cc).
+  class ApplicationProvider : public BaseObject {
+   public:
+    using BaseObject::BaseObject;
+    virtual std::unique_ptr<Application> Create(Session* session) = 0;
+  };
+
   // The options used to configure a session. Most of these deal directly with
   // the transport parameters that are exchanged with the remote peer during
   // handshake.
@@ -102,26 +120,63 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
     // Te minimum QUIC protocol version supported by this session.
     uint32_t min_version = NGTCP2_PROTO_VER_MIN;
 
-    // By default a client session will use the preferred address advertised by
-    // the the server. This option is only relevant for client sessions.
+    // By default a client session will ignore the preferred address
+    // advertised by the the server. This option is only relevant for
+    // client sessions.
     PreferredAddress::Policy preferred_address_strategy =
-        PreferredAddress::Policy::USE_PREFERRED;
+        PreferredAddress::Policy::IGNORE_PREFERRED;
 
     TransportParams::Options transport_params =
         TransportParams::Options::kDefault;
     TLSContext::Options tls_options = TLSContext::Options::kDefault;
-    Application_Options application_options = Application_Options::kDefault;
 
     // A reference to the CID::Factory used to generate CID instances
     // for this session.
     const CID::Factory* cid_factory = &CID::Factory::random();
     // If the CID::Factory is a base object, we keep a reference to it
     // so that it cannot be garbage collected.
-    BaseObjectPtr<BaseObject> cid_factory_ref = BaseObjectPtr<BaseObject>();
+    BaseObjectPtr<BaseObject> cid_factory_ref = {};
+
+    // If the application provider is specified, it will be used to create
+    // the underlying Application instance for the session.
+    BaseObjectPtr<ApplicationProvider> application_provider = {};
 
     // When true, QLog output will be enabled for the session.
     bool qlog = false;
 
+    // The amount of time (in milliseconds) that the endpoint will wait for the
+    // completion of the tls handshake.
+    uint64_t handshake_timeout = UINT64_MAX;
+
+    // Maximum initial flow control window size for a stream.
+    uint64_t max_stream_window = 0;
+
+    // Maximum initial flow control window size for the connection.
+    uint64_t max_window = 0;
+
+    // The max_payload_size is the maximum size of a serialized QUIC packet. It
+    // should always be set small enough to fit within a single MTU without
+    // fragmentation. The default is set by the QUIC specification at 1200. This
+    // value should not be changed unless you know for sure that the entire path
+    // supports a given MTU without fragmenting at any point in the path.
+    uint64_t max_payload_size = kDefaultMaxPacketLength;
+
+    // The unacknowledged_packet_threshold is the maximum number of
+    // unacknowledged packets that an ngtcp2 session will accumulate before
+    // sending an acknowledgement. Setting this to 0 uses the ngtcp2 defaults,
+    // which is what most will want. The value can be changed to fine tune some
+    // of the performance characteristics of the session. This should only be
+    // changed if you have a really good reason for doing so.
+    uint64_t unacknowledged_packet_threshold = 0;
+
+    // There are several common congestion control algorithms that ngtcp2 uses
+    // to determine how it manages the flow control window: RENO, CUBIC, and
+    // BBR. The details of how each works is not relevant here. The choice of
+    // which to use by default is arbitrary and we can choose whichever we'd
+    // like. Additional performance profiling will be needed to determine which
+    // is the better of the two for our needs.
+    ngtcp2_cc_algo cc_algorithm = CC_ALGO_CUBIC;
+
     void MemoryInfo(MemoryTracker* tracker) const override;
     SET_MEMORY_INFO_NAME(Session::Options)
     SET_SELF_SIZE(Options)
@@ -167,8 +222,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
     operator ngtcp2_settings*() { return &settings; }
     operator const ngtcp2_settings*() const { return &settings; }
 
-    Config(Side side,
-           const Endpoint& endpoint,
+    Config(Environment* env,
+           Side side,
            const Options& options,
            uint32_t version,
            const SocketAddress& local_address,
@@ -177,7 +232,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
            const CID& scid,
            const CID& ocid = CID::kInvalid);
 
-    Config(const Endpoint& endpoint,
+    Config(Environment* env,
            const Options& options,
            const SocketAddress& local_address,
            const SocketAddress& remote_address,
@@ -216,115 +271,113 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
           const Config& config,
           TLSContext* tls_context,
           const std::optional<SessionTicket>& ticket);
+  DISALLOW_COPY_AND_MOVE(Session)
   ~Session() override;
 
+  bool is_destroyed() const;
+  bool is_server() const;
+
   uint32_t version() const;
   Endpoint& endpoint() const;
-  TLSSession& tls_session();
-  Application& application();
+  TLSSession& tls_session() const;
+  Application& application() const;
   const Config& config() const;
   const Options& options() const;
   const SocketAddress& remote_address() const;
   const SocketAddress& local_address() const;
 
-  bool is_closing() const;
-  bool is_graceful_closing() const;
-  bool is_silent_closing() const;
-  bool is_destroyed() const;
-  bool is_server() const;
-
-  size_t max_packet_size() const;
-
-  void set_priority_supported(bool on = true);
-
   std::string diagnostic_name() const override;
 
-  // Use the configured CID::Factory to generate a new CID.
-  CID new_cid(size_t len = CID::kMaxLength) const;
-
-  void HandleQlog(uint32_t flags, const void* data, size_t len);
-
-  TransportParams GetLocalTransportParams() const;
-  TransportParams GetRemoteTransportParams() const;
-  void UpdatePacketTxTime();
-
   void MemoryInfo(MemoryTracker* tracker) const override;
   SET_MEMORY_INFO_NAME(Session)
   SET_SELF_SIZE(Session)
 
-  struct State;
-  struct Stats;
-
   operator ngtcp2_conn*() const;
 
-  BaseObjectPtr<Stream> FindStream(int64_t id) const;
-  BaseObjectPtr<Stream> CreateStream(int64_t id);
-  BaseObjectPtr<Stream> OpenStream(Direction direction);
-  void ExtendStreamOffset(int64_t id, size_t amount);
-  void ExtendOffset(size_t amount);
-  void SetLastError(QuicError&& error);
-  uint64_t max_data_left() const;
-
-  enum class CloseMethod {
-    // Roundtrip through JavaScript, causing all currently opened streams
-    // to be closed. An attempt will be made to send a CONNECTION_CLOSE
-    // frame to the peer. If closing while within the ngtcp2 callback scope,
-    // sending the CONNECTION_CLOSE will be deferred until the scope exits.
-    DEFAULT,
-    // The connected peer will not be notified.
-    SILENT,
-    // Closing gracefully disables the ability to open or accept new streams for
-    // this Session. Existing streams are allowed to close naturally on their
-    // own.
-    // Once called, the Session will be immediately closed once there are no
-    // remaining streams. No notification is given to the connected peer that we
-    // are in a graceful closing state. A CONNECTION_CLOSE will be sent only
-    // once
-    // Close() is called.
-    GRACEFUL
-  };
-  void Close(CloseMethod method = CloseMethod::DEFAULT);
-
-  struct SendPendingDataScope {
+  // Ensures that the session/application sends pending data when the scope
+  // exits. Scopes can be nested. When nested, pending data will be sent
+  // only when the outermost scope is exited.
+  struct SendPendingDataScope final {
     Session* session;
     explicit SendPendingDataScope(Session* session);
     explicit SendPendingDataScope(const BaseObjectPtr<Session>& session);
-    DISALLOW_COPY_AND_MOVE(SendPendingDataScope)
     ~SendPendingDataScope();
+    DISALLOW_COPY_AND_MOVE(SendPendingDataScope)
   };
 
+  struct State;
+  struct Stats;
+
+  void HandleQlog(uint32_t flags, const void* data, size_t len);
+
  private:
   struct Impl;
-  struct MaybeCloseConnectionScope;
 
   using StreamsMap = std::unordered_map<int64_t, BaseObjectPtr<Stream>>;
   using QuicConnectionPointer = DeleteFnPtr<ngtcp2_conn, ngtcp2_conn_del>;
 
-  struct PathValidationFlags {
+  struct PathValidationFlags final {
     bool preferredAddress = false;
   };
 
-  struct DatagramReceivedFlags {
+  struct DatagramReceivedFlags final {
     bool early = false;
   };
 
-  void Destroy();
-
   bool Receive(Store&& store,
                const SocketAddress& local_address,
                const SocketAddress& remote_address);
 
-  void Send(Packet* packet);
-  void Send(Packet* packet, const PathStorage& path);
+  void Send(const BaseObjectPtr<Packet>& packet);
+  void Send(const BaseObjectPtr<Packet>& packet, const PathStorage& path);
   uint64_t SendDatagram(Store&& data);
 
-  void AddStream(const BaseObjectPtr<Stream>& stream);
+  // A non-const variation to allow certain modifications.
+  Config& config();
+
+  enum class CreateStreamOption {
+    NOTIFY,
+    DO_NOT_NOTIFY,
+  };
+  BaseObjectPtr<Stream> FindStream(int64_t id) const;
+  BaseObjectPtr<Stream> CreateStream(
+      int64_t id,
+      CreateStreamOption option = CreateStreamOption::NOTIFY,
+      std::shared_ptr<DataQueue> data_source = nullptr);
+  void AddStream(BaseObjectPtr<Stream> stream,
+                 CreateStreamOption option = CreateStreamOption::NOTIFY);
   void RemoveStream(int64_t id);
   void ResumeStream(int64_t id);
-  void ShutdownStream(int64_t id, QuicError error);
   void StreamDataBlocked(int64_t id);
+  void ShutdownStream(int64_t id, QuicError error = QuicError());
   void ShutdownStreamWrite(int64_t id, QuicError code = QuicError());
 
+  // Use the configured CID::Factory to generate a new CID.
+  CID new_cid(size_t len = CID::kMaxLength) const;
+
+  const TransportParams local_transport_params() const;
+  const TransportParams remote_transport_params() const;
+
+  bool is_destroyed_or_closing() const;
+  size_t max_packet_size() const;
+  void set_priority_supported(bool on = true);
+
+  // Open a new locally-initialized stream with the specified directionality.
+  // If the session is not yet in a state where the stream can be openen --
+  // such as when the handshake is not yet sufficiently far along and ORTT
+  // session resumption is not being used -- then the stream will be created
+  // in a pending state where actually opening the stream will be deferred.
+  v8::MaybeLocal<v8::Object> OpenStream(
+      Direction direction, std::shared_ptr<DataQueue> data_source = nullptr);
+
+  void ExtendStreamOffset(int64_t id, size_t amount);
+  void ExtendOffset(size_t amount);
+  void SetLastError(QuicError&& error);
+  uint64_t max_data_left() const;
+
+  PendingStream::PendingStreamQueue& pending_bidi_stream_queue() const;
+  PendingStream::PendingStreamQueue& pending_uni_stream_queue() const;
+
   // Implementation of SessionTicket::AppData::Source
   void CollectSessionTicketAppData(
       SessionTicket::AppData* app_data) const override;
@@ -349,8 +402,17 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
   bool can_send_packets() const;
 
   // Returns false if the Session is currently in a state where it cannot create
-  // new streams.
+  // new streams. Specifically, a stream is not in a state to create streams if
+  // it has been destroyed or is closing.
   bool can_create_streams() const;
+
+  // Returns false if the Session is currently in a state where it cannot open
+  // a new locally-initiated stream. When using 0RTT session resumption, this
+  // will become true immediately after the session ticket and transport params
+  // have been configured. Otherwise, it becomes true after the remote transport
+  // params and tx keys have been installed.
+  bool can_open_streams() const;
+
   uint64_t max_local_streams_uni() const;
   uint64_t max_local_streams_bidi() const;
 
@@ -362,12 +424,46 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
   // defined there to manage it.
   void set_wrapped();
 
-  void DoClose(bool silent = false);
-  void UpdateDataStats();
+  enum class CloseMethod {
+    // Immediate close with a roundtrip through JavaScript, causing all
+    // currently opened streams to be closed. An attempt will be made to
+    // send a CONNECTION_CLOSE frame to the peer. If closing while within
+    // the ngtcp2 callback scope, sending the CONNECTION_CLOSE will be
+    // deferred until the scope exits.
+    DEFAULT,
+    // Same as DEFAULT except that no attempt to notify the peer will be
+    // made.
+    SILENT,
+    // Closing gracefully disables the ability to open or accept new streams
+    // for this Session. Existing streams are allowed to close naturally on
+    // their own.
+    // Once called, the Session will be immediately closed once there are no
+    // remaining streams. No notification is given to the connected peer that
+    // we are in a graceful closing state. A CONNECTION_CLOSE will be sent
+    // only once FinishClose() is called.
+    GRACEFUL
+  };
+  // Initiate closing of the session.
+  void Close(CloseMethod method = CloseMethod::DEFAULT);
+
+  void FinishClose();
+  void Destroy();
+
+  // Close the session and send a connection close packet to the peer.
+  // If creating the packet fails the session will be silently closed.
+  // The connection close packet will use the value of last_error_ as
+  // the error code transmitted to the peer.
   void SendConnectionClose();
   void OnTimeout();
+
   void UpdateTimer();
-  bool StartClosingPeriod();
+  // Has to be called after certain operations that generate packets.
+  void UpdatePacketTxTime();
+  void UpdateDataStats();
+  void UpdatePath(const PathStorage& path);
+
+  void ProcessPendingBidiStreams();
+  void ProcessPendingUniStreams();
 
   // JavaScript callouts
 
@@ -387,54 +483,43 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
                           const ValidatedPath& newPath,
                           const std::optional<ValidatedPath>& oldPath);
   void EmitSessionTicket(Store&& ticket);
-  void EmitStream(BaseObjectPtr<Stream> stream);
+  void EmitStream(const BaseObjectWeakPtr<Stream>& stream);
   void EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
                               const uint32_t* sv,
                               size_t nsv);
-
   void DatagramStatus(uint64_t datagramId, DatagramStatus status);
   void DatagramReceived(const uint8_t* data,
                         size_t datalen,
                         DatagramReceivedFlags flag);
-  bool GenerateNewConnectionId(ngtcp2_cid* cid, size_t len, uint8_t* token);
+  void GenerateNewConnectionId(ngtcp2_cid* cid, size_t len, uint8_t* token);
   bool HandshakeCompleted();
   void HandshakeConfirmed();
   void SelectPreferredAddress(PreferredAddress* preferredAddress);
-  void UpdatePath(const PathStorage& path);
 
-  QuicConnectionPointer InitConnection();
+  static std::unique_ptr<Application> SelectApplication(Session* session,
+                                                        const Config& config);
 
-  std::unique_ptr<Application> select_application();
+  QuicConnectionPointer InitConnection();
 
-  AliasedStruct<Stats> stats_;
-  AliasedStruct<State> state_;
+  Side side_;
   ngtcp2_mem allocator_;
-  BaseObjectWeakPtr<Endpoint> endpoint_;
-  Config config_;
-  SocketAddress local_address_;
-  SocketAddress remote_address_;
+  std::unique_ptr<Impl> impl_;
   QuicConnectionPointer connection_;
   std::unique_ptr<TLSSession> tls_session_;
-  std::unique_ptr<Application> application_;
-  StreamsMap streams_;
-  TimerWrapHandle timer_;
-  size_t send_scope_depth_ = 0;
-  size_t connection_close_depth_ = 0;
-  QuicError last_error_;
-  Packet* conn_closebuf_;
   BaseObjectPtr<LogStream> qlog_stream_;
   BaseObjectPtr<LogStream> keylog_stream_;
 
   friend class Application;
   friend class DefaultApplication;
+  friend class Http3ApplicationImpl;
   friend class Endpoint;
-  friend struct Impl;
-  friend struct MaybeCloseConnectionScope;
-  friend struct SendPendingDataScope;
   friend class Stream;
+  friend class PendingStream;
   friend class TLSContext;
   friend class TLSSession;
   friend class TransportParams;
+  friend struct Impl;
+  friend struct SendPendingDataScope;
 };
 
 }  // namespace node::quic
diff --git a/src/quic/sessionticket.cc b/src/quic/sessionticket.cc
index 481409457226cb..701d6d2eb16856 100644
--- a/src/quic/sessionticket.cc
+++ b/src/quic/sessionticket.cc
@@ -155,9 +155,8 @@ std::optional<const uv_buf_t> SessionTicket::AppData::Get() const {
 }
 
 void SessionTicket::AppData::Collect(SSL* ssl) {
-  auto source = GetAppDataSource(ssl);
-  if (source != nullptr) {
-    SessionTicket::AppData app_data(ssl);
+  SessionTicket::AppData app_data(ssl);
+  if (auto source = GetAppDataSource(ssl)) {
     source->CollectSessionTicketAppData(&app_data);
   }
 }
diff --git a/src/quic/streams.cc b/src/quic/streams.cc
index ec6bfb80a56a00..f7b2ed275f9e15 100644
--- a/src/quic/streams.cc
+++ b/src/quic/streams.cc
@@ -21,12 +21,14 @@ using v8::ArrayBufferView;
 using v8::BigInt;
 using v8::FunctionCallbackInfo;
 using v8::FunctionTemplate;
+using v8::Global;
 using v8::Integer;
 using v8::Just;
 using v8::Local;
 using v8::Maybe;
 using v8::Nothing;
 using v8::Object;
+using v8::ObjectTemplate;
 using v8::PropertyAttribute;
 using v8::SharedArrayBuffer;
 using v8::Uint32;
@@ -36,13 +38,14 @@ namespace quic {
 
 #define STREAM_STATE(V)                                                        \
   V(ID, id, int64_t)                                                           \
+  V(PENDING, pending, uint8_t)                                                 \
   V(FIN_SENT, fin_sent, uint8_t)                                               \
   V(FIN_RECEIVED, fin_received, uint8_t)                                       \
   V(READ_ENDED, read_ended, uint8_t)                                           \
   V(WRITE_ENDED, write_ended, uint8_t)                                         \
-  V(DESTROYED, destroyed, uint8_t)                                             \
   V(PAUSED, paused, uint8_t)                                                   \
   V(RESET, reset, uint8_t)                                                     \
+  V(HAS_OUTBOUND, has_outbound, uint8_t)                                       \
   V(HAS_READER, has_reader, uint8_t)                                           \
   /* Set when the stream has a block event handler */                          \
   V(WANTS_BLOCK, wants_block, uint8_t)                                         \
@@ -54,12 +57,20 @@ namespace quic {
   V(WANTS_TRAILERS, wants_trailers, uint8_t)
 
 #define STREAM_STATS(V)                                                        \
+  /* Marks the timestamp when the stream object was created. */                \
   V(CREATED_AT, created_at)                                                    \
+  /* Marks the timestamp when the stream was opened. This can be different */  \
+  /* from the created_at timestamp if the stream was created in as pending */  \
+  V(OPENED_AT, opened_at)                                                      \
+  /* Marks the timestamp when the stream last received data */                 \
   V(RECEIVED_AT, received_at)                                                  \
+  /* Marks the timestamp when the stream last received an acknowledgement */   \
   V(ACKED_AT, acked_at)                                                        \
-  V(CLOSING_AT, closing_at)                                                    \
+  /* Marks the timestamp when the stream was destroyed */                      \
   V(DESTROYED_AT, destroyed_at)                                                \
+  /* Records the total number of bytes receied by the stream */                \
   V(BYTES_RECEIVED, bytes_received)                                            \
+  /* Records the total number of bytes sent by the stream */                   \
   V(BYTES_SENT, bytes_sent)                                                    \
   V(MAX_OFFSET, max_offset)                                                    \
   V(MAX_OFFSET_ACK, max_offset_ack)                                            \
@@ -76,6 +87,53 @@ namespace quic {
   V(GetPriority, getPriority, true)                                            \
   V(GetReader, getReader, false)
 
+// ============================================================================
+
+PendingStream::PendingStream(Direction direction,
+                             Stream* stream,
+                             BaseObjectWeakPtr<Session> session)
+    : direction_(direction), stream_(stream), session_(session) {
+  if (session_) {
+    if (direction == Direction::BIDIRECTIONAL) {
+      session_->pending_bidi_stream_queue().PushBack(this);
+    } else {
+      session_->pending_uni_stream_queue().PushBack(this);
+    }
+  }
+}
+
+PendingStream::~PendingStream() {
+  pending_stream_queue_.Remove();
+  if (waiting_) {
+    Debug(stream_, "A pending stream was canceled");
+  }
+}
+
+void PendingStream::fulfill(int64_t id) {
+  CHECK(waiting_);
+  waiting_ = false;
+  stream_->NotifyStreamOpened(id);
+}
+
+void PendingStream::reject(QuicError error) {
+  CHECK(waiting_);
+  waiting_ = false;
+  stream_->Destroy(error);
+}
+
+struct Stream::PendingHeaders {
+  HeadersKind kind;
+  v8::Global<v8::Array> headers;
+  HeadersFlags flags;
+  PendingHeaders(HeadersKind kind_,
+                 v8::Global<v8::Array> headers_,
+                 HeadersFlags flags_)
+      : kind(kind_), headers(std::move(headers_)), flags(flags_) {}
+  DISALLOW_COPY_AND_MOVE(PendingHeaders)
+};
+
+// ============================================================================
+
 struct Stream::State {
 #define V(_, name, type) type name;
   STREAM_STATE(V)
@@ -86,28 +144,30 @@ STAT_STRUCT(Stream, STREAM)
 
 // ============================================================================
 
-namespace {
-Maybe<std::shared_ptr<DataQueue>> GetDataQueueFromSource(Environment* env,
-                                                         Local<Value> value) {
+Maybe<std::shared_ptr<DataQueue>> Stream::GetDataQueueFromSource(
+    Environment* env, Local<Value> value) {
   DCHECK_IMPLIES(!value->IsUndefined(), value->IsObject());
+  std::vector<std::unique_ptr<DataQueue::Entry>> entries;
   if (value->IsUndefined()) {
     return Just(std::shared_ptr<DataQueue>());
   } else if (value->IsArrayBuffer()) {
     auto buffer = value.As<ArrayBuffer>();
-    std::vector<std::unique_ptr<DataQueue::Entry>> entries(1);
     entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore(
         buffer->GetBackingStore(), 0, buffer->ByteLength()));
     return Just(DataQueue::CreateIdempotent(std::move(entries)));
   } else if (value->IsSharedArrayBuffer()) {
     auto buffer = value.As<SharedArrayBuffer>();
-    std::vector<std::unique_ptr<DataQueue::Entry>> entries(1);
     entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore(
         buffer->GetBackingStore(), 0, buffer->ByteLength()));
     return Just(DataQueue::CreateIdempotent(std::move(entries)));
   } else if (value->IsArrayBufferView()) {
-    std::vector<std::unique_ptr<DataQueue::Entry>> entries(1);
-    entries.push_back(
-        DataQueue::CreateInMemoryEntryFromView(value.As<ArrayBufferView>()));
+    auto entry =
+        DataQueue::CreateInMemoryEntryFromView(value.As<ArrayBufferView>());
+    if (!entry) {
+      THROW_ERR_INVALID_ARG_TYPE(env, "Data source not detachable");
+      return Nothing<std::shared_ptr<DataQueue>>();
+    }
+    entries.push_back(std::move(entry));
     return Just(DataQueue::CreateIdempotent(std::move(entries)));
   } else if (Blob::HasInstance(env, value)) {
     Blob* blob;
@@ -119,9 +179,11 @@ Maybe<std::shared_ptr<DataQueue>> GetDataQueueFromSource(Environment* env,
   THROW_ERR_INVALID_ARG_TYPE(env, "Invalid data source type");
   return Nothing<std::shared_ptr<DataQueue>>();
 }
-}  // namespace
 
+// Provides the implementation of the various JavaScript APIs for the
+// Stream object.
 struct Stream::Impl {
+  // Attaches an outbound data source to the stream.
   static void AttachSource(const FunctionCallbackInfo<Value>& args) {
     Environment* env = Environment::GetCurrent(args);
 
@@ -158,7 +220,13 @@ struct Stream::Impl {
     HeadersFlags flags =
         static_cast<HeadersFlags>(args[2].As<Uint32>()->Value());
 
-    if (stream->is_destroyed()) return args.GetReturnValue().Set(false);
+    // If the stream is pending, the headers will be queued until the
+    // stream is opened, at which time the queued header block will be
+    // immediately sent when the stream is opened.
+    if (stream->is_pending()) {
+      stream->EnqueuePendingHeaders(kind, headers, flags);
+      return args.GetReturnValue().Set(true);
+    }
 
     args.GetReturnValue().Set(stream->session().application().SendHeaders(
         *stream, kind, headers, flags));
@@ -173,14 +241,19 @@ struct Stream::Impl {
     uint64_t code = NGTCP2_APP_NOERROR;
     CHECK_IMPLIES(!args[0]->IsUndefined(), args[0]->IsBigInt());
     if (!args[0]->IsUndefined()) {
-      bool lossless = false;  // not used but still necessary.
-      code = args[0].As<BigInt>()->Uint64Value(&lossless);
+      bool unused = false;  // not used but still necessary.
+      code = args[0].As<BigInt>()->Uint64Value(&unused);
     }
 
-    if (stream->is_destroyed()) return;
     stream->EndReadable();
-    Session::SendPendingDataScope send_scope(&stream->session());
-    ngtcp2_conn_shutdown_stream_read(stream->session(), 0, stream->id(), code);
+
+    if (!stream->is_pending()) {
+      // If the stream is a local unidirectional there's nothing to do here.
+      if (stream->is_local_unidirectional()) return;
+      stream->NotifyReadableEnded(code);
+    } else {
+      stream->pending_close_read_code_ = code;
+    }
   }
 
   // Sends a reset stream to the peer to tell it we will not be sending any
@@ -197,15 +270,21 @@ struct Stream::Impl {
       code = args[0].As<BigInt>()->Uint64Value(&lossless);
     }
 
-    if (stream->is_destroyed() || stream->state_->reset == 1) return;
+    if (stream->state_->reset == 1) return;
+
     stream->EndWritable();
     // We can release our outbound here now. Since the stream is being reset
     // on the ngtcp2 side, we do not need to keep any of the data around
     // waiting for acknowledgement that will never come.
     stream->outbound_.reset();
     stream->state_->reset = 1;
-    Session::SendPendingDataScope send_scope(&stream->session());
-    ngtcp2_conn_shutdown_stream_write(stream->session(), 0, stream->id(), code);
+
+    if (!stream->is_pending()) {
+      if (stream->is_remote_unidirectional()) return;
+      stream->NotifyWritableEnded(code);
+    } else {
+      stream->pending_close_write_code_ = code;
+    }
   }
 
   static void SetPriority(const FunctionCallbackInfo<Value>& args) {
@@ -219,12 +298,26 @@ struct Stream::Impl {
     StreamPriorityFlags flags =
         static_cast<StreamPriorityFlags>(args[1].As<Uint32>()->Value());
 
-    stream->session().application().SetStreamPriority(*stream, priority, flags);
+    if (stream->is_pending()) {
+      stream->pending_priority_ = Stream::PendingPriority{
+          .priority = priority,
+          .flags = flags,
+      };
+    } else {
+      stream->session().application().SetStreamPriority(
+          *stream, priority, flags);
+    }
   }
 
   static void GetPriority(const FunctionCallbackInfo<Value>& args) {
     Stream* stream;
     ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
+
+    if (stream->is_pending()) {
+      return args.GetReturnValue().Set(
+          static_cast<uint32_t>(StreamPriority::DEFAULT));
+    }
+
     auto priority = stream->session().application().GetStreamPriority(*stream);
     args.GetReturnValue().Set(static_cast<uint32_t>(priority));
   }
@@ -316,7 +409,7 @@ class Stream::Outbound final : public MemoryRetainer {
     // Calling cap without a value halts the ability to add any
     // new data to the queue if it is not idempotent. If it is
     // idempotent, it's a non-op.
-    queue_->cap();
+    if (queue_) queue_->cap();
   }
 
   int Pull(bob::Next<ngtcp2_vec> next,
@@ -391,7 +484,7 @@ class Stream::Outbound final : public MemoryRetainer {
             // Here, there is no more data to read, but we will might have data
             // in the uncommitted queue. We'll resume the stream so that the
             // session will try to read from it again.
-            if (next_pending_ && !stream_->is_destroyed()) {
+            if (next_pending_) {
               stream_->session().ResumeStream(stream_->id());
             }
             return;
@@ -415,7 +508,7 @@ class Stream::Outbound final : public MemoryRetainer {
           // being asynchronous, our stream is blocking waiting for the data.
           // Now that we have data, let's resume the stream so the session will
           // pull from it again.
-          if (next_pending_ && !stream_->is_destroyed()) {
+          if (next_pending_) {
             stream_->session().ResumeStream(stream_->id());
           }
         },
@@ -638,8 +731,12 @@ void Stream::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
 #undef V
 }
 
-void Stream::Initialize(Environment* env, Local<Object> target) {
-  USE(GetConstructorTemplate(env));
+void Stream::InitPerIsolate(IsolateData* data, Local<ObjectTemplate> target) {
+  // TODO(@jasnell): Implement the per-isolate state
+}
+
+void Stream::InitPerContext(Realm* realm, Local<Object> target) {
+  USE(GetConstructorTemplate(realm->env()));
 
 #define V(name, _) IDX_STATS_STREAM_##name,
   enum StreamStatsIdx { STREAM_STATS(V) IDX_STATS_STREAM_COUNT };
@@ -692,13 +789,29 @@ BaseObjectPtr<Stream> Stream::Create(Session* session,
            ->InstanceTemplate()
            ->NewInstance(session->env()->context())
            .ToLocal(&obj)) {
-    return BaseObjectPtr<Stream>();
+    return {};
   }
 
   return MakeDetachedBaseObject<Stream>(
       BaseObjectWeakPtr<Session>(session), obj, id, std::move(source));
 }
 
+BaseObjectPtr<Stream> Stream::Create(Session* session,
+                                     Direction direction,
+                                     std::shared_ptr<DataQueue> source) {
+  DCHECK_NOT_NULL(session);
+  Local<Object> obj;
+  if (!GetConstructorTemplate(session->env())
+           ->InstanceTemplate()
+           ->NewInstance(session->env()->context())
+           .ToLocal(&obj)) {
+    return {};
+  }
+
+  return MakeBaseObject<Stream>(
+      BaseObjectWeakPtr<Session>(session), obj, direction, std::move(source));
+}
+
 Stream::Stream(BaseObjectWeakPtr<Session> session,
                v8::Local<v8::Object> object,
                int64_t id,
@@ -707,12 +820,45 @@ Stream::Stream(BaseObjectWeakPtr<Session> session,
       stats_(env()->isolate()),
       state_(env()->isolate()),
       session_(std::move(session)),
-      origin_(id & 0b01 ? Side::SERVER : Side::CLIENT),
-      direction_(id & 0b10 ? Direction::UNIDIRECTIONAL
-                           : Direction::BIDIRECTIONAL),
       inbound_(DataQueue::Create()) {
   MakeWeak();
   state_->id = id;
+  state_->pending = 0;
+  // Allows us to be notified when data is actually read from the
+  // inbound queue so that we can update the stream flow control.
+  inbound_->addBackpressureListener(this);
+
+  const auto defineProperty = [&](auto name, auto value) {
+    object
+        ->DefineOwnProperty(
+            env()->context(), name, value, PropertyAttribute::ReadOnly)
+        .Check();
+  };
+
+  defineProperty(env()->state_string(), state_.GetArrayBuffer());
+  defineProperty(env()->stats_string(), stats_.GetArrayBuffer());
+
+  set_outbound(std::move(source));
+
+  auto params = ngtcp2_conn_get_local_transport_params(this->session());
+  STAT_SET(Stats, max_offset, params->initial_max_data);
+  STAT_SET(Stats, opened_at, stats_->created_at);
+}
+
+Stream::Stream(BaseObjectWeakPtr<Session> session,
+               v8::Local<v8::Object> object,
+               Direction direction,
+               std::shared_ptr<DataQueue> source)
+    : AsyncWrap(session->env(), object, AsyncWrap::PROVIDER_QUIC_STREAM),
+      stats_(env()->isolate()),
+      state_(env()->isolate()),
+      session_(std::move(session)),
+      inbound_(DataQueue::Create()),
+      maybe_pending_stream_(
+          std::make_unique<PendingStream>(direction, this, session_)) {
+  MakeWeak();
+  state_->id = -1;
+  state_->pending = 1;
 
   // Allows us to be notified when data is actually read from the
   // inbound queue so that we can update the stream flow control.
@@ -735,8 +881,77 @@ Stream::Stream(BaseObjectWeakPtr<Session> session,
 }
 
 Stream::~Stream() {
-  // Make sure that Destroy() was called before Stream is destructed.
-  DCHECK(is_destroyed());
+  // Make sure that Destroy() was called before Stream is actually destructed.
+  DCHECK_NE(stats_->destroyed_at, 0);
+}
+
+void Stream::NotifyStreamOpened(int64_t id) {
+  CHECK(is_pending());
+  Debug(this, "Pending stream opened with id %" PRIi64, id);
+  state_->pending = 0;
+  state_->id = id;
+  STAT_RECORD_TIMESTAMP(Stats, opened_at);
+  // Now that the stream is actually opened, add it to the sessions
+  // list of known open streams.
+  session().AddStream(BaseObjectPtr<Stream>(this),
+                      Session::CreateStreamOption::DO_NOT_NOTIFY);
+
+  CHECK_EQ(ngtcp2_conn_set_stream_user_data(this->session(), id, this), 0);
+  maybe_pending_stream_.reset();
+
+  if (pending_priority_) {
+    auto& priority = pending_priority_.value();
+    session().application().SetStreamPriority(
+        *this, priority.priority, priority.flags);
+    pending_priority_ = std::nullopt;
+  }
+  decltype(pending_headers_queue_) queue;
+  pending_headers_queue_.swap(queue);
+  for (auto& headers : queue) {
+    // TODO(@jasnell): What if the application does not support headers?
+    session().application().SendHeaders(*this,
+                                        headers->kind,
+                                        headers->headers.Get(env()->isolate()),
+                                        headers->flags);
+  }
+  // If the stream is not a local undirectional stream and is_readable is
+  // false, then we should shutdown the streams readable side now.
+  if (!is_local_unidirectional() && !is_readable()) {
+    NotifyReadableEnded(pending_close_read_code_);
+  }
+  if (!is_remote_unidirectional() && !is_writable()) {
+    NotifyWritableEnded(pending_close_write_code_);
+  }
+
+  // Finally, if we have an outbound data source attached already, make
+  // sure our stream is scheduled. This is likely a bit superfluous
+  // since the stream likely hasn't had any opporunity to get blocked
+  // yet, but just for completeness, let's make sure.
+  if (outbound_) session().ResumeStream(id);
+}
+
+void Stream::NotifyReadableEnded(uint64_t code) {
+  CHECK(!is_pending());
+  Session::SendPendingDataScope send_scope(&session());
+  ngtcp2_conn_shutdown_stream_read(session(), 0, id(), code);
+}
+
+void Stream::NotifyWritableEnded(uint64_t code) {
+  CHECK(!is_pending());
+  Session::SendPendingDataScope send_scope(&session());
+  ngtcp2_conn_shutdown_stream_write(session(), 0, id(), code);
+}
+
+void Stream::EnqueuePendingHeaders(HeadersKind kind,
+                                   Local<Array> headers,
+                                   HeadersFlags flags) {
+  Debug(this, "Enqueuing headers for pending stream");
+  pending_headers_queue_.push_back(std::make_unique<PendingHeaders>(
+      kind, Global<Array>(env()->isolate(), headers), flags));
+}
+
+bool Stream::is_pending() const {
+  return state_->pending;
 }
 
 int64_t Stream::id() const {
@@ -744,19 +959,32 @@ int64_t Stream::id() const {
 }
 
 Side Stream::origin() const {
-  return origin_;
+  CHECK(!is_pending());
+  return (state_->id & 0b01) ? Side::SERVER : Side::CLIENT;
 }
 
 Direction Stream::direction() const {
-  return direction_;
+  if (state_->pending) {
+    CHECK(maybe_pending_stream_.has_value());
+    auto& val = maybe_pending_stream_.value();
+    return val->direction();
+  }
+  return (state_->id & 0b10) ? Direction::UNIDIRECTIONAL
+                             : Direction::BIDIRECTIONAL;
 }
 
 Session& Stream::session() const {
   return *session_;
 }
 
-bool Stream::is_destroyed() const {
-  return state_->destroyed;
+bool Stream::is_local_unidirectional() const {
+  return direction() == Direction::UNIDIRECTIONAL &&
+         ngtcp2_conn_is_local_stream(*session_, id());
+}
+
+bool Stream::is_remote_unidirectional() const {
+  return direction() == Direction::UNIDIRECTIONAL &&
+         !ngtcp2_conn_is_local_stream(*session_, id());
 }
 
 bool Stream::is_eos() const {
@@ -764,40 +992,27 @@ bool Stream::is_eos() const {
 }
 
 bool Stream::is_writable() const {
-  if (direction() == Direction::UNIDIRECTIONAL) {
-    switch (origin()) {
-      case Side::CLIENT: {
-        if (session_->is_server()) return false;
-        break;
-      }
-      case Side::SERVER: {
-        if (!session_->is_server()) return false;
-        break;
-      }
-    }
+  // Remote unidirectional streams are never writable, and remote streams can
+  // never be pending.
+  if (!is_pending() && direction() == Direction::UNIDIRECTIONAL &&
+      !ngtcp2_conn_is_local_stream(session(), id())) {
+    return false;
   }
   return state_->write_ended == 0;
 }
 
 bool Stream::is_readable() const {
-  if (direction() == Direction::UNIDIRECTIONAL) {
-    switch (origin()) {
-      case Side::CLIENT: {
-        if (!session_->is_server()) return false;
-        break;
-      }
-      case Side::SERVER: {
-        if (session_->is_server()) return false;
-        break;
-      }
-    }
+  // Local unidirectional streams are never readable, and remote streams can
+  // never be pending.
+  if (!is_pending() && direction() == Direction::UNIDIRECTIONAL &&
+      ngtcp2_conn_is_local_stream(session(), id())) {
+    return false;
   }
   return state_->read_ended == 0;
 }
 
 BaseObjectPtr<Blob::Reader> Stream::get_reader() {
-  if (!is_readable() || state_->has_reader)
-    return BaseObjectPtr<Blob::Reader>();
+  if (!is_readable() || state_->has_reader) return {};
   state_->has_reader = 1;
   return Blob::Reader::Create(env(), Blob::Create(env(), inbound_));
 }
@@ -810,17 +1025,19 @@ void Stream::set_final_size(uint64_t final_size) {
 }
 
 void Stream::set_outbound(std::shared_ptr<DataQueue> source) {
-  if (!source || is_destroyed() || !is_writable()) return;
+  if (!source || !is_writable()) return;
+  Debug(this, "Setting the outbound data source");
   DCHECK_NULL(outbound_);
   outbound_ = std::make_unique<Outbound>(this, std::move(source));
-  session_->ResumeStream(id());
+  state_->has_outbound = 1;
+  if (!is_pending()) session_->ResumeStream(id());
 }
 
 void Stream::EntryRead(size_t amount) {
-  // Tells us that amount bytes were read from inbound_
+  // Tells us that amount bytes we're reading from inbound_
   // We use this as a signal to extend the flow control
   // window to receive more bytes.
-  if (!is_destroyed() && session_) session_->ExtendStreamOffset(id(), amount);
+  session().ExtendStreamOffset(id(), amount);
 }
 
 int Stream::DoPull(bob::Next<ngtcp2_vec> next,
@@ -828,7 +1045,7 @@ int Stream::DoPull(bob::Next<ngtcp2_vec> next,
                    ngtcp2_vec* data,
                    size_t count,
                    size_t max_count_hint) {
-  if (is_destroyed() || is_eos()) {
+  if (is_eos()) {
     std::move(next)(bob::Status::STATUS_EOS, nullptr, 0, [](int) {});
     return bob::Status::STATUS_EOS;
   }
@@ -848,7 +1065,6 @@ int Stream::DoPull(bob::Next<ngtcp2_vec> next,
 }
 
 void Stream::BeginHeaders(HeadersKind kind) {
-  if (is_destroyed()) return;
   headers_length_ = 0;
   headers_.clear();
   set_headers_kind(kind);
@@ -860,8 +1076,8 @@ void Stream::set_headers_kind(HeadersKind kind) {
 
 bool Stream::AddHeader(const Header& header) {
   size_t len = header.length();
-  if (is_destroyed() || !session_->application().CanAddHeader(
-                            headers_.size(), headers_length_, len)) {
+  if (!session_->application().CanAddHeader(
+          headers_.size(), headers_length_, len)) {
     return false;
   }
 
@@ -882,42 +1098,59 @@ bool Stream::AddHeader(const Header& header) {
 }
 
 void Stream::Acknowledge(size_t datalen) {
-  if (is_destroyed() || outbound_ == nullptr) return;
+  if (outbound_ == nullptr) return;
+
+  Debug(this, "Acknowledging %zu bytes", datalen);
 
   // ngtcp2 guarantees that offset must always be greater than the previously
   // received offset.
   DCHECK_GE(datalen, STAT_GET(Stats, max_offset_ack));
   STAT_SET(Stats, max_offset_ack, datalen);
 
-  // // Consumes the given number of bytes in the buffer.
+  // Consumes the given number of bytes in the buffer.
   outbound_->Acknowledge(datalen);
 }
 
 void Stream::Commit(size_t datalen) {
-  if (!is_destroyed() && outbound_) outbound_->Commit(datalen);
+  Debug(this, "Commiting %zu bytes", datalen);
+  STAT_RECORD_TIMESTAMP(Stats, acked_at);
+  if (outbound_) outbound_->Commit(datalen);
 }
 
 void Stream::EndWritable() {
-  if (is_destroyed() || !is_writable()) return;
+  if (!is_writable()) return;
   // If an outbound_ has been attached, we want to mark it as being ended.
   // If the outbound_ is wrapping an idempotent DataQueue, then capping
   // will be a non-op since we're not going to be writing any more data
   // into it anyway.
-  if (outbound_ != nullptr) outbound_->Cap();
+  if (outbound_) outbound_->Cap();
   state_->write_ended = 1;
 }
 
 void Stream::EndReadable(std::optional<uint64_t> maybe_final_size) {
-  if (is_destroyed() || !is_readable()) return;
+  if (!is_readable()) return;
   state_->read_ended = 1;
   set_final_size(maybe_final_size.value_or(STAT_GET(Stats, bytes_received)));
   inbound_->cap(STAT_GET(Stats, final_size));
 }
 
 void Stream::Destroy(QuicError error) {
-  if (is_destroyed()) return;
+  if (stats_->destroyed_at != 0) return;
+  // Record the destroyed at timestamp before notifying the JavaScript side
+  // that the stream is being destroyed.
+  STAT_RECORD_TIMESTAMP(Stats, destroyed_at);
+
   DCHECK_NOT_NULL(session_.get());
-  Debug(this, "Stream %" PRIi64 " being destroyed with error %s", id(), error);
+
+  if (!state_->pending) {
+    Debug(
+        this, "Stream %" PRIi64 " being destroyed with error %s", id(), error);
+  } else {
+    Debug(this, "Pending stream being destroyed with error %s", error);
+  }
+  state_->pending = 0;
+
+  maybe_pending_stream_.reset();
 
   // End the writable before marking as destroyed.
   EndWritable();
@@ -925,10 +1158,6 @@ void Stream::Destroy(QuicError error) {
   // Also end the readable side if it isn't already.
   EndReadable();
 
-  state_->destroyed = 1;
-
-  EmitClose(error);
-
   // We are going to release our reference to the outbound_ queue here.
   outbound_.reset();
 
@@ -936,40 +1165,55 @@ void Stream::Destroy(QuicError error) {
   // the JavaScript side could still have a reader on the inbound DataQueue,
   // which may keep that data alive a bit longer.
   inbound_->removeBackpressureListener(this);
-
   inbound_.reset();
 
-  CHECK_NOT_NULL(session_.get());
+  // Notify the JavaScript side that our handle is being destroyed. The
+  // JavaScript side should clean up any state that it needs to and should
+  // detach itself from the handle. After this is called, it should no
+  // longer be considered safe for the JavaScript side to access the
+  // handle.
+  EmitClose(error);
+
+  auto session = session_;
+  session_.reset();
+  session->RemoveStream(id());
 
-  // Finally, remove the stream from the session and clear our reference
-  // to the session.
-  session_->RemoveStream(id());
+  // Critically, make sure that the RemoveStream call is the last thing
+  // trying to use this stream object. Once that call is made, the stream
+  // object is no longer valid and should not be accessed.
+  // Specifically, the session object's streams map holds the its
+  // BaseObjectPtr<Stream> instances in a detached state, meaning that
+  // once that BaseObjectPtr is deleted the Stream will be freed as well.
 }
 
 void Stream::ReceiveData(const uint8_t* data,
                          size_t len,
                          ReceiveDataFlags flags) {
-  if (is_destroyed()) return;
-
   // If reading has ended, or there is no data, there's nothing to do but maybe
   // end the readable side if this is the last bit of data we've received.
+
+  Debug(this, "Receiving %zu bytes of data", len);
+
   if (state_->read_ended == 1 || len == 0) {
     if (flags.fin) EndReadable();
     return;
   }
 
   STAT_INCREMENT_N(Stats, bytes_received, len);
+  STAT_RECORD_TIMESTAMP(Stats, received_at);
   auto backing = ArrayBuffer::NewBackingStore(env()->isolate(), len);
   memcpy(backing->Data(), data, len);
   inbound_->append(DataQueue::CreateInMemoryEntryFromBackingStore(
       std::move(backing), 0, len));
+
   if (flags.fin) EndReadable();
 }
 
 void Stream::ReceiveStopSending(QuicError error) {
   // Note that this comes from *this* endpoint, not the other side. We handle it
   // if we haven't already shutdown our *receiving* side of the stream.
-  if (is_destroyed() || state_->read_ended) return;
+  if (state_->read_ended) return;
+  Debug(this, "Received stop sending with error %s", error);
   ngtcp2_conn_shutdown_stream_read(session(), 0, id(), error.code());
   EndReadable();
 }
@@ -980,6 +1224,10 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) {
   // has abruptly terminated the writable end of their stream with an error.
   // Any data we have received up to this point remains in the queue waiting to
   // be read.
+  Debug(this,
+        "Received stream reset with final size %" PRIu64 " and error %s",
+        final_size,
+        error);
   EndReadable(final_size);
   EmitReset(error);
 }
@@ -989,8 +1237,8 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) {
 void Stream::EmitBlocked() {
   // state_->wants_block will be set from the javascript side if the
   // stream object has a handler for the blocked event.
-  if (is_destroyed() || !env()->can_call_into_js() ||
-      state_->wants_block == 0) {
+  Debug(this, "Blocked");
+  if (!env()->can_call_into_js() || !state_->wants_block) {
     return;
   }
   CallbackScope<Stream> cb_scope(this);
@@ -998,17 +1246,17 @@ void Stream::EmitBlocked() {
 }
 
 void Stream::EmitClose(const QuicError& error) {
-  if (is_destroyed() || !env()->can_call_into_js()) return;
+  if (!env()->can_call_into_js()) return;
   CallbackScope<Stream> cb_scope(this);
   Local<Value> err;
   if (!error.ToV8Value(env()).ToLocal(&err)) return;
-
   MakeCallback(BindingData::Get(env()).stream_close_callback(), 1, &err);
 }
 
 void Stream::EmitHeaders() {
-  if (is_destroyed() || !env()->can_call_into_js() ||
-      state_->wants_headers == 0) {
+  // state_->wants_headers will be set from the javascript side if the
+  // stream object has a handler for the headers event.
+  if (!env()->can_call_into_js() || !state_->wants_headers) {
     return;
   }
   CallbackScope<Stream> cb_scope(this);
@@ -1025,8 +1273,9 @@ void Stream::EmitHeaders() {
 }
 
 void Stream::EmitReset(const QuicError& error) {
-  if (is_destroyed() || !env()->can_call_into_js() ||
-      state_->wants_reset == 0) {
+  // state_->wants_reset will be set from the javascript side if the
+  // stream object has a handler for the reset event.
+  if (!env()->can_call_into_js() || !state_->wants_reset) {
     return;
   }
   CallbackScope<Stream> cb_scope(this);
@@ -1037,8 +1286,9 @@ void Stream::EmitReset(const QuicError& error) {
 }
 
 void Stream::EmitWantTrailers() {
-  if (is_destroyed() || !env()->can_call_into_js() ||
-      state_->wants_trailers == 0) {
+  // state_->wants_trailers will be set from the javascript side if the
+  // stream object has a handler for the trailers event.
+  if (!env()->can_call_into_js() || !state_->wants_trailers) {
     return;
   }
   CallbackScope<Stream> cb_scope(this);
@@ -1049,11 +1299,12 @@ void Stream::EmitWantTrailers() {
 
 void Stream::Schedule(Stream::Queue* queue) {
   // If this stream is not already in the queue to send data, add it.
-  if (!is_destroyed() && outbound_ && stream_queue_.IsEmpty())
-    queue->PushBack(this);
+  Debug(this, "Scheduled");
+  if (outbound_ && stream_queue_.IsEmpty()) queue->PushBack(this);
 }
 
 void Stream::Unschedule() {
+  Debug(this, "Unscheduled");
   stream_queue_.Remove();
 }
 
diff --git a/src/quic/streams.h b/src/quic/streams.h
index 0bacb37faf542d..4c6f63a851cf03 100644
--- a/src/quic/streams.h
+++ b/src/quic/streams.h
@@ -12,15 +12,61 @@
 #include <node_blob.h>
 #include <node_bob.h>
 #include <node_http_common.h>
+#include <util.h>
 #include "bindingdata.h"
 #include "data.h"
 
 namespace node::quic {
 
 class Session;
+class Stream;
 
 using Ngtcp2Source = bob::SourceImpl<ngtcp2_vec>;
 
+// When a request to open a stream is made before a Session is able to actually
+// open a stream (either because the handshake is not yet sufficiently complete
+// or concurrency limits are temporarily reached) then the request to open the
+// stream is represented as a queued PendingStream.
+//
+// The PendingStream instance itself is held by the stream but sits in a linked
+// list in the session.
+//
+// The PendingStream request can be canceled by dropping the PendingStream
+// instance before it can be fulfilled, at which point it is removed from the
+// pending stream queue.
+//
+// Note that only locally initiated streams can be created in a pending state.
+class PendingStream final {
+ public:
+  PendingStream(Direction direction,
+                Stream* stream,
+                BaseObjectWeakPtr<Session> session);
+  DISALLOW_COPY_AND_MOVE(PendingStream)
+  ~PendingStream();
+
+  // Called when the stream has been opened. Transitions the stream from a
+  // pending state to an opened state.
+  void fulfill(int64_t id);
+
+  // Called when opening the stream fails or is canceled. Transitions the
+  // stream into a closed/destroyed state.
+  void reject(QuicError error = QuicError());
+
+  inline Direction direction() const { return direction_; }
+
+ private:
+  Direction direction_;
+  Stream* stream_;
+  BaseObjectWeakPtr<Session> session_;
+  bool waiting_ = true;
+
+  ListNode<PendingStream> pending_stream_queue_;
+
+ public:
+  using PendingStreamQueue =
+      ListHead<PendingStream, &PendingStream::pending_stream_queue_>;
+};
+
 // QUIC Stream's are simple data flows that may be:
 //
 // * Bidirectional (both sides can send) or Unidirectional (one side can send)
@@ -63,7 +109,7 @@ using Ngtcp2Source = bob::SourceImpl<ngtcp2_vec>;
 // the right thing.
 //
 // A Stream may be in a fully closed state (No longer readable nor writable)
-// state but still have unacknowledged data in it's inbound and outbound
+// state but still have unacknowledged data in both the inbound and outbound
 // queues.
 //
 // A Stream is gracefully closed when (a) both read and write states are closed,
@@ -78,50 +124,98 @@ using Ngtcp2Source = bob::SourceImpl<ngtcp2_vec>;
 //
 // QUIC streams in general do not have headers. Some QUIC applications, however,
 // may associate headers with the stream (HTTP/3 for instance).
-class Stream : public AsyncWrap,
-               public Ngtcp2Source,
-               public DataQueue::BackpressureListener {
+//
+// Streams may be created in a pending state. This means that while the Stream
+// object is created, it has not yet been opened in ngtcp2 and therefore has
+// no official status yet. Certain operations can still be performed on the
+// stream object such as providing data and headers, and destroying the stream.
+//
+// When a stream is created the data source for the stream must be given.
+// If no data source is given, then the stream is assumed to not have any
+// outbound data. The data source can be fixed length or may support
+// streaming. What this means practically is, when a stream is opened,
+// you must already have a sense of whether that will provide data or
+// not. When in doubt, specify a streaming data source, which can produce
+// zero-length output.
+class Stream final : public AsyncWrap,
+                     public Ngtcp2Source,
+                     public DataQueue::BackpressureListener {
  public:
   using Header = NgHeaderBase<BindingData>;
 
+  static v8::Maybe<std::shared_ptr<DataQueue>> GetDataQueueFromSource(
+      Environment* env, v8::Local<v8::Value> value);
+
   static Stream* From(void* stream_user_data);
 
   static bool HasInstance(Environment* env, v8::Local<v8::Value> value);
   static v8::Local<v8::FunctionTemplate> GetConstructorTemplate(
       Environment* env);
-  static void Initialize(Environment* env, v8::Local<v8::Object> target);
+  static void InitPerIsolate(IsolateData* data,
+                             v8::Local<v8::ObjectTemplate> target);
+  static void InitPerContext(Realm* realm, v8::Local<v8::Object> target);
   static void RegisterExternalReferences(ExternalReferenceRegistry* registry);
 
+  // Creates a new non-pending stream.
   static BaseObjectPtr<Stream> Create(
       Session* session,
       int64_t id,
       std::shared_ptr<DataQueue> source = nullptr);
 
+  // Creates a new pending stream.
+  static BaseObjectPtr<Stream> Create(
+      Session* session,
+      Direction direction,
+      std::shared_ptr<DataQueue> source = nullptr);
+
   // The constructor is only public to be visible by MakeDetachedBaseObject.
   // Call Create to create new instances of Stream.
   Stream(BaseObjectWeakPtr<Session> session,
          v8::Local<v8::Object> obj,
          int64_t id,
          std::shared_ptr<DataQueue> source);
+
+  // Creates the stream in a pending state. The constructor is only public
+  // to be visible to MakeDetachedBaseObject. Call Create to create new
+  // instances of Stream.
+  Stream(BaseObjectWeakPtr<Session> session,
+         v8::Local<v8::Object> obj,
+         Direction direction,
+         std::shared_ptr<DataQueue> source);
+  DISALLOW_COPY_AND_MOVE(Stream)
   ~Stream() override;
 
+  // While the stream is still pending, the id will be -1.
   int64_t id() const;
+
+  // While the stream is still pending, the origin will be invalid.
   Side origin() const;
+
   Direction direction() const;
+
   Session& session() const;
 
-  bool is_destroyed() const;
+  // True if this stream was created in a pending state and is still waiting
+  // to be created.
+  bool is_pending() const;
 
   // True if we've completely sent all outbound data for this stream.
+  // Importantly, this does not necessarily mean that we are completely
+  // done with the outbound data. We may still be waiting on outbound
+  // data to be acknowledged by the remote peer.
   bool is_eos() const;
 
+  // True if this stream is still in a readable state.
   bool is_readable() const;
+
+  // True if this stream is still in a writable state.
   bool is_writable() const;
 
   // Called by the session/application to indicate that the specified number
   // of bytes have been acknowledged by the peer.
   void Acknowledge(size_t datalen);
   void Commit(size_t datalen);
+
   void EndWritable();
   void EndReadable(std::optional<uint64_t> maybe_final_size = std::nullopt);
   void EntryRead(size_t amount) override;
@@ -133,7 +227,8 @@ class Stream : public AsyncWrap,
              size_t count,
              size_t max_count_hint) override;
 
-  // Forcefully close the stream immediately. All queued data and pending
+  // Forcefully close the stream immediately. Data already queued in the
+  // inbound is preserved but new data will not be accepted. All pending
   // writes are abandoned, and the stream is immediately closed at the ngtcp2
   // level without waiting for any outstanding acknowledgements.
   void Destroy(QuicError error = QuicError());
@@ -152,12 +247,15 @@ class Stream : public AsyncWrap,
   void ReceiveStopSending(QuicError error);
   void ReceiveStreamReset(uint64_t final_size, QuicError error);
 
+  // Currently, only HTTP/3 streams support headers. These methods are here
+  // to support that. They are not used when using any other QUIC application.
+
   void BeginHeaders(HeadersKind kind);
+  void set_headers_kind(HeadersKind kind);
   // Returns false if the header cannot be added. This will typically happen
   // if the application does not support headers, a maximum number of headers
   // have already been added, or the maximum total header length is reached.
   bool AddHeader(const Header& header);
-  void set_headers_kind(HeadersKind kind);
 
   SET_NO_MEMORY_INFO()
   SET_MEMORY_INFO_NAME(Stream)
@@ -166,15 +264,10 @@ class Stream : public AsyncWrap,
   struct State;
   struct Stats;
 
-  // Notifies the JavaScript side that sending data on the stream has been
-  // blocked because of flow control restriction.
-  void EmitBlocked();
-
-  // Delivers the set of inbound headers that have been collected.
-  void EmitHeaders();
-
  private:
   struct Impl;
+  struct PendingHeaders;
+
   class Outbound;
 
   // Gets a reader for the data received for this stream from the peer,
@@ -183,6 +276,9 @@ class Stream : public AsyncWrap,
   void set_final_size(uint64_t amount);
   void set_outbound(std::shared_ptr<DataQueue> source);
 
+  bool is_local_unidirectional() const;
+  bool is_remote_unidirectional() const;
+
   // JavaScript callouts
 
   // Notifies the JavaScript side that the stream has been destroyed.
@@ -195,19 +291,61 @@ class Stream : public AsyncWrap,
   // trailing headers.
   void EmitWantTrailers();
 
+  // Notifies the JavaScript side that sending data on the stream has been
+  // blocked because of flow control restriction.
+  void EmitBlocked();
+
+  // Delivers the set of inbound headers that have been collected.
+  void EmitHeaders();
+
+  void NotifyReadableEnded(uint64_t code);
+  void NotifyWritableEnded(uint64_t code);
+
+  // When a pending stream is finally opened, the NotifyStreamOpened method
+  // will be called and the id will be assigned.
+  void NotifyStreamOpened(int64_t id);
+  void EnqueuePendingHeaders(HeadersKind kind,
+                             v8::Local<v8::Array> headers,
+                             HeadersFlags flags);
+
   AliasedStruct<Stats> stats_;
   AliasedStruct<State> state_;
   BaseObjectWeakPtr<Session> session_;
-  const Side origin_;
-  const Direction direction_;
   std::unique_ptr<Outbound> outbound_;
   std::shared_ptr<DataQueue> inbound_;
 
+  // If the stream cannot be opened yet, it will be created in a pending state.
+  // Once the owning session is able to, it will complete opening of the stream
+  // and the stream id will be assigned.
+  std::optional<std::unique_ptr<PendingStream>> maybe_pending_stream_ =
+      std::nullopt;
+  std::vector<std::unique_ptr<PendingHeaders>> pending_headers_queue_;
+  uint64_t pending_close_read_code_ = NGTCP2_APP_NOERROR;
+  uint64_t pending_close_write_code_ = NGTCP2_APP_NOERROR;
+
+  struct PendingPriority {
+    StreamPriority priority;
+    StreamPriorityFlags flags;
+  };
+  std::optional<PendingPriority> pending_priority_ = std::nullopt;
+
+  // The headers_ field holds a block of headers that have been received and
+  // are being buffered for delivery to the JavaScript side.
+  // TODO(@jasnell): Use v8::Global instead of v8::Local here.
   std::vector<v8::Local<v8::Value>> headers_;
+
+  // The headers_kind_ field indicates the kind of headers that are being
+  // buffered.
   HeadersKind headers_kind_ = HeadersKind::INITIAL;
+
+  // The headers_length_ field holds the total length of the headers that have
+  // been buffered.
   size_t headers_length_ = 0;
 
   friend struct Impl;
+  friend class PendingStream;
+  friend class Http3ApplicationImpl;
+  friend class DefaultApplication;
 
  public:
   // The Queue/Schedule/Unschedule here are part of the mechanism used to
diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc
index 358bad2ee3697f..fda49710e85938 100644
--- a/src/quic/tlscontext.cc
+++ b/src/quic/tlscontext.cc
@@ -170,7 +170,7 @@ int TLSContext::OnSelectAlpn(SSL* ssl,
   static constexpr size_t kMaxAlpnLen = 255;
   auto& session = TLSSession::From(ssl);
 
-  const auto& requested = session.context().options().alpn;
+  const auto& requested = session.context().options().protocol;
   if (requested.length() > kMaxAlpnLen) return SSL_TLSEXT_ERR_NOACK;
 
   // The Session supports exactly one ALPN identifier. If that does not match
@@ -266,11 +266,13 @@ crypto::SSLCtxPointer TLSContext::Initialize() {
                            OnVerifyClientCertificate);
       }
 
-      CHECK_EQ(SSL_CTX_set_session_ticket_cb(ctx.get(),
-                                             SessionTicket::GenerateCallback,
-                                             SessionTicket::DecryptedCallback,
-                                             nullptr),
-               1);
+      // TODO(@jasnell): There's a bug int the GenerateCallback flow somewhere.
+      // Need to update in order to support session tickets.
+      // CHECK_EQ(SSL_CTX_set_session_ticket_cb(ctx.get(),
+      //                                        SessionTicket::GenerateCallback,
+      //                                        SessionTicket::DecryptedCallback,
+      //                                        nullptr),
+      //          1);
       break;
     }
     case Side::CLIENT: {
@@ -434,11 +436,11 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
   SetOption<TLSContext::Options, &TLSContext::Options::name>(                  \
       env, &options, params, state.name##_string())
 
-  if (!SET(verify_client) || !SET(enable_tls_trace) || !SET(alpn) ||
-      !SET(sni) || !SET(ciphers) || !SET(groups) || !SET(verify_private_key) ||
-      !SET(keylog) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
-      !SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
-      !SET_VECTOR(Store, crl)) {
+  if (!SET(verify_client) || !SET(enable_tls_trace) || !SET(protocol) ||
+      !SET(servername) || !SET(ciphers) || !SET(groups) ||
+      !SET(verify_private_key) || !SET(keylog) ||
+      !SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
+      !SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
     return Nothing<Options>();
   }
 
@@ -449,8 +451,8 @@ std::string TLSContext::Options::ToString() const {
   DebugIndentScope indent;
   auto prefix = indent.Prefix();
   std::string res("{");
-  res += prefix + "alpn: " + alpn;
-  res += prefix + "sni: " + sni;
+  res += prefix + "protocol: " + protocol;
+  res += prefix + "servername: " + servername;
   res +=
       prefix + "keylog: " + (keylog ? std::string("yes") : std::string("no"));
   res += prefix + "verify client: " +
@@ -496,6 +498,12 @@ TLSSession::TLSSession(Session* session,
   Debug(session_, "Created new TLS session for %s", session->config().dcid);
 }
 
+TLSSession::~TLSSession() {
+  if (ssl_) {
+    SSL_set_app_data(ssl_.get(), nullptr);
+  }
+}
+
 TLSSession::operator SSL*() const {
   CHECK(ssl_);
   return ssl_.get();
@@ -530,14 +538,14 @@ crypto::SSLPointer TLSSession::Initialize(
       SSL_set_connect_state(ssl.get());
       if (SSL_set_alpn_protos(
               ssl.get(),
-              reinterpret_cast<const unsigned char*>(options.alpn.data()),
-              options.alpn.size()) != 0) {
+              reinterpret_cast<const unsigned char*>(options.protocol.data()),
+              options.protocol.size()) != 0) {
         validation_error_ = "Invalid ALPN";
         return crypto::SSLPointer();
       }
 
-      if (!options.sni.empty()) {
-        SSL_set_tlsext_host_name(ssl.get(), options.sni.data());
+      if (!options.servername.empty()) {
+        SSL_set_tlsext_host_name(ssl.get(), options.servername.data());
       } else {
         SSL_set_tlsext_host_name(ssl.get(), "localhost");
       }
@@ -619,7 +627,7 @@ const std::string_view TLSSession::servername() const {
                                : std::string_view();
 }
 
-const std::string_view TLSSession::alpn() const {
+const std::string_view TLSSession::protocol() const {
   const unsigned char* alpn_buf = nullptr;
   unsigned int alpnlen;
   SSL_get0_alpn_selected(ssl_.get(), &alpn_buf, &alpnlen);
@@ -629,7 +637,7 @@ const std::string_view TLSSession::alpn() const {
 }
 
 bool TLSSession::InitiateKeyUpdate() {
-  if (session_->is_destroyed() || in_key_update_) return false;
+  if (in_key_update_) return false;
   auto leave = OnScopeLeave([this] { in_key_update_ = false; });
   in_key_update_ = true;
 
diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h
index 3f2f8aff42a8a5..77771d1a252a24 100644
--- a/src/quic/tlscontext.h
+++ b/src/quic/tlscontext.h
@@ -34,6 +34,7 @@ class TLSSession final : public MemoryRetainer {
              std::shared_ptr<TLSContext> context,
              const std::optional<SessionTicket>& maybeSessionTicket);
   DISALLOW_COPY_AND_MOVE(TLSSession)
+  ~TLSSession();
 
   inline operator bool() const { return ssl_ != nullptr; }
   inline Session& session() const { return *session_; }
@@ -54,7 +55,7 @@ class TLSSession final : public MemoryRetainer {
   const std::string_view servername() const;
 
   // The ALPN (protocol name) negotiated for the session
-  const std::string_view alpn() const;
+  const std::string_view protocol() const;
 
   // Triggers key update to begin. This will fail and return false if either a
   // previous key update is in progress or if the initial handshake has not yet
@@ -113,11 +114,11 @@ class TLSContext final : public MemoryRetainer,
   struct Options final : public MemoryRetainer {
     // The SNI servername to use for this session. This option is only used by
     // the client.
-    std::string sni = "localhost";
+    std::string servername = "localhost";
 
     // The ALPN (protocol name) to use for this session. This option is only
     // used by the client.
-    std::string alpn = NGHTTP3_ALPN_H3;
+    std::string protocol = NGHTTP3_ALPN_H3;
 
     // The list of TLS ciphers to use for this session.
     std::string ciphers = DEFAULT_CIPHERS;
diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc
index 2e8cd26a0cef9e..0f54fe2d499060 100644
--- a/src/quic/transportparams.cc
+++ b/src/quic/transportparams.cc
@@ -62,7 +62,7 @@ Maybe<TransportParams::Options> TransportParams::Options::From(
       !SET(initial_max_streams_bidi) || !SET(initial_max_streams_uni) ||
       !SET(max_idle_timeout) || !SET(active_connection_id_limit) ||
       !SET(ack_delay_exponent) || !SET(max_ack_delay) ||
-      !SET(max_datagram_frame_size) || !SET(disable_active_migration)) {
+      !SET(max_datagram_frame_size)) {
     return Nothing<Options>();
   }
 
@@ -153,6 +153,7 @@ TransportParams::TransportParams(const Config& config, const Options& options)
     // For the server side, the original dcid is always set.
     CHECK(config.ocid);
     params_.original_dcid = config.ocid;
+    params_.original_dcid_present = 1;
 
     // The retry_scid is only set if the server validated a retry token.
     if (config.retry_scid) {
@@ -179,25 +180,25 @@ TransportParams::TransportParams(const ngtcp2_vec& vec, int version)
   }
 }
 
-Store TransportParams::Encode(Environment* env, int version) {
+Store TransportParams::Encode(Environment* env, int version) const {
   if (ptr_ == nullptr) {
-    error_ = QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR);
     return Store();
   }
 
   // Preflight to see how much storage we'll need.
   ssize_t size =
       ngtcp2_transport_params_encode_versioned(nullptr, 0, version, &params_);
+  if (size == 0) {
+    return Store();
+  }
 
-  DCHECK_GT(size, 0);
-
-  auto result = ArrayBuffer::NewBackingStore(env->isolate(), size);
+  auto result = ArrayBuffer::NewBackingStore(
+      env->isolate(), size, v8::BackingStoreInitializationMode::kUninitialized);
 
   auto ret = ngtcp2_transport_params_encode_versioned(
       static_cast<uint8_t*>(result->Data()), size, version, &params_);
 
   if (ret != 0) {
-    error_ = QuicError::ForNgtcp2Error(ret);
     return Store();
   }
 
@@ -232,7 +233,7 @@ void TransportParams::SetPreferredAddress(const SocketAddress& address) {
 
 void TransportParams::GenerateSessionTokens(Session* session) {
   if (session->is_server()) {
-    GenerateStatelessResetToken(session->endpoint(), session->config_.scid);
+    GenerateStatelessResetToken(session->endpoint(), session->config().scid);
     GeneratePreferredAddressToken(session);
   }
 }
@@ -247,14 +248,15 @@ void TransportParams::GenerateStatelessResetToken(const Endpoint& endpoint,
 
 void TransportParams::GeneratePreferredAddressToken(Session* session) {
   DCHECK(ptr_ == &params_);
+  Session::Config& config = session->config();
   if (params_.preferred_addr_present) {
-    session->config_.preferred_address_cid = session->new_cid();
-    params_.preferred_addr.cid = session->config_.preferred_address_cid;
+    config.preferred_address_cid = session->new_cid();
+    params_.preferred_addr.cid = config.preferred_address_cid;
     auto& endpoint = session->endpoint();
     endpoint.AssociateStatelessResetToken(
         endpoint.GenerateNewStatelessResetToken(
             params_.preferred_addr.stateless_reset_token,
-            session->config_.preferred_address_cid),
+            config.preferred_address_cid),
         session);
   }
 }
diff --git a/src/quic/transportparams.h b/src/quic/transportparams.h
index af6af3fc0266b3..77f367deaa4d41 100644
--- a/src/quic/transportparams.h
+++ b/src/quic/transportparams.h
@@ -107,7 +107,8 @@ class TransportParams final {
     // When true, communicates that the Session does not support active
     // connection migration. See the QUIC specification for more details on
     // connection migration.
-    bool disable_active_migration = false;
+    // TODO(@jasnell): We currently do not implementation active migration.
+    bool disable_active_migration = true;
 
     static const Options kDefault;
 
@@ -151,7 +152,7 @@ class TransportParams final {
   // Returns an ArrayBuffer containing the encoded transport parameters.
   // If an error occurs during encoding, an empty shared_ptr will be returned
   // and the error() property will be set to an appropriate QuicError.
-  Store Encode(Environment* env, int version = QUIC_TRANSPORT_PARAMS_V1);
+  Store Encode(Environment* env, int version = QUIC_TRANSPORT_PARAMS_V1) const;
 
  private:
   ngtcp2_transport_params params_{};
diff --git a/src/req_wrap-inl.h b/src/req_wrap-inl.h
index 6bb5a58cb85494..bfcb13b9036310 100644
--- a/src/req_wrap-inl.h
+++ b/src/req_wrap-inl.h
@@ -49,6 +49,11 @@ void ReqWrap<T>::Cancel() {
     uv_cancel(reinterpret_cast<uv_req_t*>(&req_));
 }
 
+template <typename T>
+bool ReqWrap<T>::IsDispatched() {
+  return req_.data != nullptr;
+}
+
 template <typename T>
 AsyncWrap* ReqWrap<T>::GetAsyncWrap() {
   return this;
diff --git a/src/req_wrap.h b/src/req_wrap.h
index 611e438275a13a..d4d29de53a9fd7 100644
--- a/src/req_wrap.h
+++ b/src/req_wrap.h
@@ -48,6 +48,8 @@ class ReqWrap : public AsyncWrap, public ReqWrapBase {
   template <typename LibuvFunction, typename... Args>
   inline int Dispatch(LibuvFunction fn, Args... args);
 
+  inline bool IsDispatched();
+
  private:
   friend int GenDebugSymbols();
 
diff --git a/src/timer_wrap.h b/src/timer_wrap.h
index ac8f00f0d470f5..9f0f672ecbbaab 100644
--- a/src/timer_wrap.h
+++ b/src/timer_wrap.h
@@ -61,6 +61,8 @@ class TimerWrapHandle : public MemoryRetainer  {
 
   void Update(uint64_t interval, uint64_t repeat = 0);
 
+  inline operator bool() const { return timer_ != nullptr; }
+
   void Ref();
   void Unref();
 
diff --git a/test/parallel/test-blob.js b/test/parallel/test-blob.js
index bcf88699b2910c..df753c4b2975ba 100644
--- a/test/parallel/test-blob.js
+++ b/test/parallel/test-blob.js
@@ -339,7 +339,7 @@ assert.throws(() => new Blob({}), {
   setTimeout(() => {
     // The blob stream is now a byte stream hence after the first read,
     // it should pull in the next 'hello' which is 5 bytes hence -5.
-    assert.strictEqual(stream[kState].controller.desiredSize, -5);
+    assert.strictEqual(stream[kState].controller.desiredSize, 0);
   }, 0);
 })().then(common.mustCall());
 
@@ -366,7 +366,7 @@ assert.throws(() => new Blob({}), {
   assert.strictEqual(value.byteLength, 5);
   assert(!done);
   setTimeout(() => {
-    assert.strictEqual(stream[kState].controller.desiredSize, -5);
+    assert.strictEqual(stream[kState].controller.desiredSize, 0);
   }, 0);
 })().then(common.mustCall());
 
diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js
index c0ba01d3891477..c75ee390dcd195 100644
--- a/test/parallel/test-bootstrap-modules.js
+++ b/test/parallel/test-bootstrap-modules.js
@@ -87,8 +87,6 @@ expected.beforePreExec = new Set([
   'NativeModule internal/process/signal',
   'Internal Binding fs',
   'NativeModule internal/encoding',
-  'NativeModule internal/webstreams/util',
-  'NativeModule internal/webstreams/queuingstrategies',
   'NativeModule internal/blob',
   'NativeModule internal/fs/utils',
   'NativeModule fs',
diff --git a/test/parallel/test-process-get-builtin.mjs b/test/parallel/test-process-get-builtin.mjs
index 3cf8179f7286bb..b376e1b88f905a 100644
--- a/test/parallel/test-process-get-builtin.mjs
+++ b/test/parallel/test-process-get-builtin.mjs
@@ -35,6 +35,8 @@ if (!hasIntl) {
   publicBuiltins.delete('inspector');
   publicBuiltins.delete('trace_events');
 }
+// TODO(@jasnell): Remove this once node:quic graduates from unflagged.
+publicBuiltins.delete('node:quic');
 
 for (const id of publicBuiltins) {
   assert.strictEqual(process.getBuiltinModule(id), require(id));
diff --git a/test/parallel/test-quic-handshake.js b/test/parallel/test-quic-handshake.js
new file mode 100644
index 00000000000000..63dfcdeef2bf8f
--- /dev/null
+++ b/test/parallel/test-quic-handshake.js
@@ -0,0 +1,82 @@
+// Flags: --experimental-quic --no-warnings
+'use strict';
+
+const { hasQuic } = require('../common');
+const { Buffer } = require('node:buffer');
+
+const {
+  describe,
+  it,
+} = require('node:test');
+
+// TODO(@jasnell): Temporarily skip the test on mac until we can figure
+// out while it is failing on macs in CI but running locally on macs ok.
+const isMac = process.platform === 'darwin';
+const skip = isMac || !hasQuic;
+
+async function readAll(readable, resolve) {
+  const chunks = [];
+  for await (const chunk of readable) {
+    chunks.push(chunk);
+  }
+  resolve(Buffer.concat(chunks));
+}
+
+describe('quic basic server/client handshake works', { skip }, async () => {
+  const { createPrivateKey } = require('node:crypto');
+  const fixtures = require('../common/fixtures');
+  const keys = createPrivateKey(fixtures.readKey('agent1-key.pem'));
+  const certs = fixtures.readKey('agent1-cert.pem');
+
+  const {
+    listen,
+    connect,
+  } = require('node:quic');
+
+  const {
+    strictEqual,
+    ok,
+  } = require('node:assert');
+
+  it('a quic client can connect to a quic server in the same process', async () => {
+    const p1 = Promise.withResolvers();
+    const p2 = Promise.withResolvers();
+    const p3 = Promise.withResolvers();
+
+    const serverEndpoint = await listen((serverSession) => {
+
+      serverSession.opened.then((info) => {
+        strictEqual(info.servername, 'localhost');
+        strictEqual(info.protocol, 'h3');
+        strictEqual(info.cipher, 'TLS_AES_128_GCM_SHA256');
+        p1.resolve();
+      });
+
+      serverSession.onstream = (stream) => {
+        readAll(stream.readable, p3.resolve).then(() => {
+          serverSession.close();
+        });
+      };
+    }, { keys, certs });
+
+    ok(serverEndpoint.address !== undefined);
+
+    const clientSession = await connect(serverEndpoint.address);
+    clientSession.opened.then((info) => {
+      strictEqual(info.servername, 'localhost');
+      strictEqual(info.protocol, 'h3');
+      strictEqual(info.cipher, 'TLS_AES_128_GCM_SHA256');
+      p2.resolve();
+    });
+
+    const body = new Blob(['hello']);
+    const stream = await clientSession.createUnidirectionalStream({
+      body,
+    });
+    ok(stream);
+
+    const { 2: data } = await Promise.all([p1.promise, p2.promise, p3.promise]);
+    clientSession.close();
+    strictEqual(Buffer.from(data).toString(), 'hello');
+  });
+});
diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.js b/test/parallel/test-quic-internal-endpoint-listen-defaults.js
index 598eac7693aa1a..d5a96c252298f2 100644
--- a/test/parallel/test-quic-internal-endpoint-listen-defaults.js
+++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.js
@@ -11,41 +11,54 @@ const {
 describe('quic internal endpoint listen defaults', { skip: !hasQuic }, async () => {
   const {
     ok,
+    rejects,
     strictEqual,
     throws,
   } = require('node:assert');
 
+  const {
+    kState,
+  } = require('internal/quic/symbols');
+
+  const { createPrivateKey } = require('node:crypto');
+  const fixtures = require('../common/fixtures');
+  const keys = createPrivateKey(fixtures.readKey('agent1-key.pem'));
+  const certs = fixtures.readKey('agent1-cert.pem');
+
   const {
     SocketAddress,
   } = require('net');
 
   const {
     QuicEndpoint,
+    listen,
   } = require('internal/quic/quic');
 
   it('are reasonable and work as expected', async () => {
-    const endpoint = new QuicEndpoint({
-      onsession() {},
-    });
+    const endpoint = new QuicEndpoint();
 
-    ok(!endpoint.state.isBound);
-    ok(!endpoint.state.isReceiving);
-    ok(!endpoint.state.isListening);
+    ok(!endpoint[kState].isBound);
+    ok(!endpoint[kState].isReceiving);
+    ok(!endpoint[kState].isListening);
 
     strictEqual(endpoint.address, undefined);
 
-    throws(() => endpoint.listen(123), {
+    await rejects(listen(123, { keys, certs, endpoint }), {
+      code: 'ERR_INVALID_ARG_TYPE',
+    });
+
+    await rejects(listen(() => {}, 123), {
       code: 'ERR_INVALID_ARG_TYPE',
     });
 
-    endpoint.listen();
-    throws(() => endpoint.listen(), {
+    await listen(() => {}, { keys, certs, endpoint });
+    await rejects(listen(() => {}, { keys, certs, endpoint }), {
       code: 'ERR_INVALID_STATE',
     });
 
-    ok(endpoint.state.isBound);
-    ok(endpoint.state.isReceiving);
-    ok(endpoint.state.isListening);
+    ok(endpoint[kState].isBound);
+    ok(endpoint[kState].isReceiving);
+    ok(endpoint[kState].isListening);
 
     const address = endpoint.address;
     ok(address instanceof SocketAddress);
@@ -61,7 +74,7 @@ describe('quic internal endpoint listen defaults', { skip: !hasQuic }, async ()
     await endpoint.closed;
     ok(endpoint.destroyed);
 
-    throws(() => endpoint.listen(), {
+    await rejects(listen(() => {}, { keys, certs, endpoint }), {
       code: 'ERR_INVALID_STATE',
     });
     throws(() => { endpoint.busy = true; }, {
diff --git a/test/parallel/test-quic-internal-endpoint-options.js b/test/parallel/test-quic-internal-endpoint-options.js
index b9ebaa0ffef2d3..db8b13fe4bdb10 100644
--- a/test/parallel/test-quic-internal-endpoint-options.js
+++ b/test/parallel/test-quic-internal-endpoint-options.js
@@ -1,4 +1,4 @@
-// Flags: --expose-internals
+// Flags: --experimental-quic --no-warnings
 'use strict';
 
 const { hasQuic } = require('../common');
@@ -16,7 +16,7 @@ describe('quic internal endpoint options', { skip: !hasQuic }, async () => {
 
   const {
     QuicEndpoint,
-  } = require('internal/quic/quic');
+  } = require('node:quic');
 
   const {
     inspect,
@@ -86,20 +86,6 @@ describe('quic internal endpoint options', { skip: !hasQuic }, async () => {
         ],
         invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}]
       },
-      {
-        key: 'maxPayloadSize',
-        valid: [
-          1, 10, 100, 1000, 10000, 10000n,
-        ],
-        invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}]
-      },
-      {
-        key: 'unacknowledgedPacketThreshold',
-        valid: [
-          1, 10, 100, 1000, 10000, 10000n,
-        ],
-        invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}]
-      },
       {
         key: 'validateAddress',
         valid: [true, false, 0, 1, 'a'],
@@ -115,18 +101,6 @@ describe('quic internal endpoint options', { skip: !hasQuic }, async () => {
         valid: [true, false, 0, 1, 'a'],
         invalid: [],
       },
-      {
-        key: 'cc',
-        valid: [
-          QuicEndpoint.CC_ALGO_RENO,
-          QuicEndpoint.CC_ALGO_CUBIC,
-          QuicEndpoint.CC_ALGO_BBR,
-          QuicEndpoint.CC_ALGO_RENO_STR,
-          QuicEndpoint.CC_ALGO_CUBIC_STR,
-          QuicEndpoint.CC_ALGO_BBR_STR,
-        ],
-        invalid: [-1, 4, 1n, 'a', null, false, true, {}, [], () => {}],
-      },
       {
         key: 'udpReceiveBufferSize',
         valid: [0, 1, 2, 3, 4, 1000],
@@ -189,20 +163,12 @@ describe('quic internal endpoint options', { skip: !hasQuic }, async () => {
         const options = {};
         options[key] = value;
         throws(() => new QuicEndpoint(options), {
-          code: 'ERR_INVALID_ARG_VALUE',
-        });
+          message: new RegExp(`${key}`),
+        }, value);
       }
     }
   });
 
-  it('endpoint can be ref/unrefed without error', async () => {
-    const endpoint = new QuicEndpoint();
-    endpoint.unref();
-    endpoint.ref();
-    endpoint.close();
-    await endpoint.closed;
-  });
-
   it('endpoint can be inspected', async () => {
     const endpoint = new QuicEndpoint({});
     strictEqual(typeof inspect(endpoint), 'string');
@@ -214,7 +180,10 @@ describe('quic internal endpoint options', { skip: !hasQuic }, async () => {
     new QuicEndpoint({
       address: { host: '127.0.0.1:0' },
     });
-    throws(() => new QuicEndpoint({ address: '127.0.0.1:0' }), {
+    new QuicEndpoint({
+      address: '127.0.0.1:0',
+    });
+    throws(() => new QuicEndpoint({ address: 123 }), {
       code: 'ERR_INVALID_ARG_TYPE',
     });
   });
diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.js b/test/parallel/test-quic-internal-endpoint-stats-state.js
index f0302d2791e2b3..0565eaa979a3ed 100644
--- a/test/parallel/test-quic-internal-endpoint-stats-state.js
+++ b/test/parallel/test-quic-internal-endpoint-stats-state.js
@@ -11,15 +11,22 @@ const {
 describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
   const {
     QuicEndpoint,
-    QuicStreamState,
-    QuicStreamStats,
+  } = require('internal/quic/quic');
+
+  const {
     QuicSessionState,
+    QuicStreamState,
+  } = require('internal/quic/state');
+
+  const {
     QuicSessionStats,
-  } = require('internal/quic/quic');
+    QuicStreamStats,
+  } = require('internal/quic/stats');
 
   const {
     kFinishClose,
     kPrivateConstructor,
+    kState,
   } = require('internal/quic/symbols');
 
   const {
@@ -35,14 +42,14 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
   it('endpoint state', () => {
     const endpoint = new QuicEndpoint();
 
-    strictEqual(endpoint.state.isBound, false);
-    strictEqual(endpoint.state.isReceiving, false);
-    strictEqual(endpoint.state.isListening, false);
-    strictEqual(endpoint.state.isClosing, false);
-    strictEqual(endpoint.state.isBusy, false);
-    strictEqual(endpoint.state.pendingCallbacks, 0n);
+    strictEqual(endpoint[kState].isBound, false);
+    strictEqual(endpoint[kState].isReceiving, false);
+    strictEqual(endpoint[kState].isListening, false);
+    strictEqual(endpoint[kState].isClosing, false);
+    strictEqual(endpoint[kState].isBusy, false);
+    strictEqual(endpoint[kState].pendingCallbacks, 0n);
 
-    deepStrictEqual(JSON.parse(JSON.stringify(endpoint.state)), {
+    deepStrictEqual(JSON.parse(JSON.stringify(endpoint[kState])), {
       isBound: false,
       isReceiving: false,
       isListening: false,
@@ -52,26 +59,24 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
     });
 
     endpoint.busy = true;
-    strictEqual(endpoint.state.isBusy, true);
+    strictEqual(endpoint[kState].isBusy, true);
     endpoint.busy = false;
-    strictEqual(endpoint.state.isBusy, false);
+    strictEqual(endpoint[kState].isBusy, false);
 
     it('state can be inspected without errors', () => {
-      strictEqual(typeof inspect(endpoint.state), 'string');
+      strictEqual(typeof inspect(endpoint[kState]), 'string');
     });
   });
 
   it('state is not readable after close', () => {
     const endpoint = new QuicEndpoint();
-    endpoint.state[kFinishClose]();
-    throws(() => endpoint.state.isBound, {
-      name: 'Error',
-    });
+    endpoint[kState][kFinishClose]();
+    strictEqual(endpoint[kState].isBound, undefined);
   });
 
   it('state constructor argument is ArrayBuffer', () => {
     const endpoint = new QuicEndpoint();
-    const Cons = endpoint.state.constructor;
+    const Cons = endpoint[kState].constructor;
     throws(() => new Cons(kPrivateConstructor, 1), {
       code: 'ERR_INVALID_ARG_TYPE'
     });
@@ -142,18 +147,16 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
     const streamState = new QuicStreamState(kPrivateConstructor, new ArrayBuffer(1024));
     const sessionState = new QuicSessionState(kPrivateConstructor, new ArrayBuffer(1024));
 
+    strictEqual(streamState.pending, false);
     strictEqual(streamState.finSent, false);
     strictEqual(streamState.finReceived, false);
     strictEqual(streamState.readEnded, false);
     strictEqual(streamState.writeEnded, false);
-    strictEqual(streamState.destroyed, false);
     strictEqual(streamState.paused, false);
     strictEqual(streamState.reset, false);
     strictEqual(streamState.hasReader, false);
     strictEqual(streamState.wantsBlock, false);
-    strictEqual(streamState.wantsHeaders, false);
     strictEqual(streamState.wantsReset, false);
-    strictEqual(streamState.wantsTrailers, false);
 
     strictEqual(sessionState.hasPathValidationListener, false);
     strictEqual(sessionState.hasVersionNegotiationListener, false);
@@ -163,7 +166,6 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
     strictEqual(sessionState.isGracefulClose, false);
     strictEqual(sessionState.isSilentClose, false);
     strictEqual(sessionState.isStatelessReset, false);
-    strictEqual(sessionState.isDestroyed, false);
     strictEqual(sessionState.isHandshakeCompleted, false);
     strictEqual(sessionState.isHandshakeConfirmed, false);
     strictEqual(sessionState.isStreamOpenAllowed, false);
@@ -180,34 +182,31 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
   it('stream and session stats', () => {
     const streamStats = new QuicStreamStats(kPrivateConstructor, new ArrayBuffer(1024));
     const sessionStats = new QuicSessionStats(kPrivateConstructor, new ArrayBuffer(1024));
-    strictEqual(streamStats.createdAt, undefined);
-    strictEqual(streamStats.receivedAt, undefined);
-    strictEqual(streamStats.ackedAt, undefined);
-    strictEqual(streamStats.closingAt, undefined);
-    strictEqual(streamStats.destroyedAt, undefined);
-    strictEqual(streamStats.bytesReceived, undefined);
-    strictEqual(streamStats.bytesSent, undefined);
-    strictEqual(streamStats.maxOffset, undefined);
-    strictEqual(streamStats.maxOffsetAcknowledged, undefined);
-    strictEqual(streamStats.maxOffsetReceived, undefined);
-    strictEqual(streamStats.finalSize, undefined);
+    strictEqual(streamStats.createdAt, 0n);
+    strictEqual(streamStats.openedAt, 0n);
+    strictEqual(streamStats.receivedAt, 0n);
+    strictEqual(streamStats.ackedAt, 0n);
+    strictEqual(streamStats.destroyedAt, 0n);
+    strictEqual(streamStats.bytesReceived, 0n);
+    strictEqual(streamStats.bytesSent, 0n);
+    strictEqual(streamStats.maxOffset, 0n);
+    strictEqual(streamStats.maxOffsetAcknowledged, 0n);
+    strictEqual(streamStats.maxOffsetReceived, 0n);
+    strictEqual(streamStats.finalSize, 0n);
     strictEqual(typeof streamStats.toJSON(), 'object');
     strictEqual(typeof inspect(streamStats), 'string');
     streamStats[kFinishClose]();
 
     strictEqual(typeof sessionStats.createdAt, 'bigint');
     strictEqual(typeof sessionStats.closingAt, 'bigint');
-    strictEqual(typeof sessionStats.destroyedAt, 'bigint');
     strictEqual(typeof sessionStats.handshakeCompletedAt, 'bigint');
     strictEqual(typeof sessionStats.handshakeConfirmedAt, 'bigint');
-    strictEqual(typeof sessionStats.gracefulClosingAt, 'bigint');
     strictEqual(typeof sessionStats.bytesReceived, 'bigint');
     strictEqual(typeof sessionStats.bytesSent, 'bigint');
     strictEqual(typeof sessionStats.bidiInStreamCount, 'bigint');
     strictEqual(typeof sessionStats.bidiOutStreamCount, 'bigint');
     strictEqual(typeof sessionStats.uniInStreamCount, 'bigint');
     strictEqual(typeof sessionStats.uniOutStreamCount, 'bigint');
-    strictEqual(typeof sessionStats.lossRetransmitCount, 'bigint');
     strictEqual(typeof sessionStats.maxBytesInFlights, 'bigint');
     strictEqual(typeof sessionStats.bytesInFlight, 'bigint');
     strictEqual(typeof sessionStats.blockCount, 'bigint');
diff --git a/test/parallel/test-require-resolve.js b/test/parallel/test-require-resolve.js
index b69192635e6d79..93896486ff5dbe 100644
--- a/test/parallel/test-require-resolve.js
+++ b/test/parallel/test-require-resolve.js
@@ -60,6 +60,8 @@ require(fixtures.path('resolve-paths', 'default', 'verify-paths.js'));
 {
   // builtinModules.
   builtinModules.forEach((mod) => {
+    // TODO(@jasnell): Remove once node:quic is no longer flagged
+    if (mod === 'node:quic') return;
     assert.strictEqual(require.resolve.paths(mod), null);
     if (!mod.startsWith('node:')) {
       assert.strictEqual(require.resolve.paths(`node:${mod}`), null);
diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs
index 06fc72dab73cdb..02a0dfcbcda525 100644
--- a/tools/doc/type-parser.mjs
+++ b/tools/doc/type-parser.mjs
@@ -283,6 +283,31 @@ const customTypesMap = {
   'Response': 'https://developer.mozilla.org/en-US/docs/Web/API/Response',
   'Request': 'https://developer.mozilla.org/en-US/docs/Web/API/Request',
   'Disposable': 'https://tc39.es/proposal-explicit-resource-management/#sec-disposable-interface',
+
+  'quic.QuicEndpoint': 'quic.html#class-quicendpoint',
+  'quic.QuicEndpoint.Stats': 'quic.html#class-quicendpointstats',
+  'quic.QuicSession': 'quic.html#class-quicsession',
+  'quic.QuicSession.Stats': 'quic.html#class-quicsessionstats',
+  'quic.QuicStream': 'quic.html#class-quicstream',
+  'quic.QuicStream.Stats': 'quic.html#class-quicstreamstats',
+  'quic.EndpointOptions': 'quic.html#type-endpointoptions',
+  'quic.SessionOptions': 'quic.html#type-sessionoptions',
+  'quic.ApplicationOptions': 'quic.html#type-applicationoptions',
+  'quic.TlsOptions': 'quic.html#type-tlsoptions',
+  'quic.TransportParams': 'quic.html#type-transportparams',
+  'quic.OnSessionCallback': 'quic.html#callback-onsessioncallback',
+  'quic.OnStreamCallback': 'quic.html#callback-onstreamcallback',
+  'quic.OnDatagramCallback': 'quic.html#callback-ondatagramcallback',
+  'quic.OnDatagramStatusCallback': 'quic.html#callback-ondatagramstatuscallback',
+  'quic.OnPathValidationCallback': 'quic.html#callback-onpathvalidationcallback',
+  'quic.OnSessionTicketCallback': 'quic.html#callback-onsessionticketcallback',
+  'quic.OnVersionNegotiationCallback': 'quic.html#callback-onversionnegotiationcallback',
+  'quic.OnHandshakeCallback': 'quic.html#callback-onhandshakecallback',
+  'quic.OnBlockedCallback': 'quic.html#callback-onblockedcallback',
+  'quic.OnStreamErrorCallback': 'quic.html#callback-onstreamerrorcallback',
+  'quic.OnHeadersCallback': 'quic.html#callback-onheaderscallback',
+  'quic.OnTrailersCallback': 'quic.html#callback-ontrailerscallback',
+  'quic.OnPullCallback': 'quic.html#callback-onpullcallback',
 };
 
 const arrayPart = /(?:\[])+$/;