diff --git a/doc/api/cli.md b/doc/api/cli.md
index 87eda0deb66b46..4290f5461c6538 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
@@ -3090,6 +3098,7 @@ one is included in the list below.
 * `--experimental-modules`
 * `--experimental-permission`
 * `--experimental-print-required-tla`
+* `--experimental-quic`
 * `--experimental-require-module`
 * `--experimental-shadow-realm`
 * `--experimental-specifier-resolution`
diff --git a/doc/api/quic.md b/doc/api/quic.md
index af6883f61d2f49..fbe85d22f77016 100644
--- a/doc/api/quic.md
+++ b/doc/api/quic.md
@@ -23,28 +23,89 @@ const quic = require('node:quic');
 
 The module is only available under the `node:` scheme.
 
-## Class: `QuicEndpoint`
+## `quic.connect(address[, options])`
 
-A `QuicEndpoint` encapsulates the local UDP-port binding for QUIC. It can be
-used as both a client and a server.
+<!-- 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 { QuicEndpoint } from 'node:quic';
+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'),
+});
+```
 
-const endpoint = new QuicEndpoint();
+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.
 
-// Server...
-endpoint.listen((session) => {
-  session.onstream = (stream) => {
-    // Handle the stream....
-  };
+```mjs
+import { QuicEndpoint, connect } from 'node:quic';
+
+const endpoint = new QuicEndpoint({
+  address: '127.0.0.1:1234',
 });
 
-// Client...
-const client = endpoint.connect('123.123.123.123:8888');
-const stream = client.openBidirectionalStream();
+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
@@ -111,17 +172,16 @@ added: REPLACEME
 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.connect(address[, options])`
+### `endpoint.closing`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* `address` {string|net.SocketAddress}
-* `options` {quic.SessionOptions}
-* Returns: {quic.QuicSession}
+* {boolean}
 
-Initiate a new client-side session using this endpoint.
+True if `endpoint.close()` has been called and closing the endpoint has not yet completed.
+Read only.
 
 ### `endpoint.destroy([error])`
 
@@ -130,10 +190,9 @@ added: REPLACEME
 -->
 
 * `error` {any}
-* Returns: {Promise}
 
 Forcefully closes the endpoint by forcing all open sessions to be immediately
-closed. Returns `endpoint.closed`.
+closed.
 
 ### `endpoint.destroyed`
 
@@ -145,56 +204,13 @@ added: REPLACEME
 
 True if `endpoint.destroy()` has been called. Read only.
 
-### `endpoint.listen([onsession,][options])`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* `onsession` {quic.OnSessionCallback}
-* `options` {quic.SessionOptions}
-
-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.
-
-The `onsession` callback must be specified either here or by setting the `onsession`
-property or an error will be thrown.
-
-### `endpoint.onsession`
-
-* {quic.OnSessionCallback}
-
-The callback function that is invoked when a new session is initiated by a remote peer.
-Read/write.
-
-### `endpoint.sessions`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {Iterator} of {quic.QuicSession}.
-
-An iterator over all sessions associated with this endpoint. Read only.
-
-### `endpoint.state`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.QuicEndpointState}
-
-The state associated with an active session. Read only.
-
 ### `endpoint.stats`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {quic.QuicEndpointStats}
+* {quic.QuicEndpoint.Stats}
 
 The statistics collected for an active session. Read only.
 
@@ -207,64 +223,7 @@ added: REPLACEME
 Calls `endpoint.close()` and returns a promise that fulfills when the
 endpoint has closed.
 
-## Class: `QuicEndpointState`
-
-<!-- YAML
-added: REPLACEME
--->
-
-A view of the internal state of an endpoint.
-
-### `endpointState.isBound`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean} True if the endpoint is bound to a local UDP port.
-
-### `endpointState.isReceiving`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean} True if the endpoint is bound to a local UDP port and actively listening
-  for packets.
-
-### `endpointState.isListening`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean} True if the endpoint is listening as a server.
-
-### `endpointState.isClosing`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean} True if the endpoint is in the process of closing down.
-
-### `endpointState.isBusy`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean} True if the endpoint has been marked busy.
-
-### `endpointState.pendingCallbacks`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint} The total number of pending callbacks the endpoint is waiting on currently.
-
-## Class: `QuicEndpointStats`
+## Class: `QuicEndpoint.Stats`
 
 <!-- YAML
 added: REPLACEME
@@ -383,6 +342,8 @@ added: REPLACEME
 added: REPLACEME
 -->
 
+A `QuicSession` represents the local side of a QUIC connection.
+
 ### `session.close()`
 
 <!-- YAML
@@ -391,6 +352,11 @@ 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
@@ -399,6 +365,8 @@ added: REPLACEME
 
 * {Promise}
 
+A promise that is fulfilled once the session is destroyed.
+
 ### `session.destroy([error])`
 
 <!-- YAML
@@ -406,7 +374,9 @@ added: REPLACEME
 -->
 
 * `error` {any}
-* Returns: {Promise}
+
+Immediately destroy the session. All streams will be destroys and the
+session will be closed.
 
 ### `session.destroyed`
 
@@ -416,6 +386,8 @@ added: REPLACEME
 
 * {boolean}
 
+True if `session.destroy()` has been called. Read only.
+
 ### `session.endpoint`
 
 <!-- YAML
@@ -424,6 +396,8 @@ added: REPLACEME
 
 * {quic.QuicEndpoint}
 
+The endpoint that created this session. Read only.
+
 ### `session.onstream`
 
 <!-- YAML
@@ -432,6 +406,8 @@ added: REPLACEME
 
 * {quic.OnStreamCallback}
 
+The callback to invoke when a new stream is initiated by a remote peer. Read/write.
+
 ### `session.ondatagram`
 
 <!-- YAML
@@ -440,6 +416,8 @@ added: REPLACEME
 
 * {quic.OnDatagramCallback}
 
+The callback to invoke when a new datagram is received from a remote peer. Read/write.
+
 ### `session.ondatagramstatus`
 
 <!-- YAML
@@ -448,6 +426,8 @@ added: REPLACEME
 
 * {quic.OnDatagramStatusCallback}
 
+The callback to invoke when the status of a datagram is updated. Read/write.
+
 ### `session.onpathvalidation`
 
 <!-- YAML
@@ -456,6 +436,8 @@ added: REPLACEME
 
 * {quic.OnPathValidationCallback}
 
+The callback to invoke when the path validation is updated. Read/write.
+
 ### `seesion.onsessionticket`
 
 <!-- YAML
@@ -464,6 +446,8 @@ added: REPLACEME
 
 * {quic.OnSessionTicketCallback}
 
+The callback to invoke when a new session ticket is received. Read/write.
+
 ### `session.onversionnegotiation`
 
 <!-- YAML
@@ -472,6 +456,8 @@ added: REPLACEME
 
 * {quic.OnVersionNegotiationCallback}
 
+The callback to invoke when a version negotiation is initiated. Read/write.
+
 ### `session.onhandshake`
 
 <!-- YAML
@@ -480,25 +466,33 @@ added: REPLACEME
 
 * {quic.OnHandshakeCallback}
 
-### `session.openBidirectionalStream([options])`
+The callback to invoke when the TLS handshake is completed. Read/write.
+
+### `session.createBidirectionalStream([options])`
 
 <!-- YAML
 added: REPLACEME
 -->
 
 * `options` {Object}
-  * `headers` {Object}
-* Returns: {quic.QuicStream}
+  * `body` {ArrayBuffer | ArrayBufferView | Blob}
+* Returns: {Promise} for a {quic.QuicStream}
 
-### `session.openUnidirectionalStream([options])`
+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}
-  * `headers` {Object
-* Returns: {quic.QuicStream}
+  * `body` {ArrayBuffer | ArrayBufferView | Blob}
+* 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`
 
@@ -510,22 +504,20 @@ added: REPLACEME
   * `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` {Uint8Array}
+* `datagram` {string|ArrayBufferView}
 * Returns: {bigint}
 
-### `session.state`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.QuicSessionState}
+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`
 
@@ -533,147 +525,28 @@ added: REPLACEME
 added: REPLACEME
 -->
 
-* {quic.QuicSessionStats}
-
-### `session.updateKey()`
-
-<!-- YAML
-added: REPLACEME
--->
-
-### `session[Symbol.asyncDispose]()`
-
-<!-- YAML
-added: REPLACEME
--->
-
-## Class: `QuicSessionState`
-
-<!-- YAML
-added: REPLACEME
--->
-
-### `sessionState.hasPathValidationListener`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.hasVersionNegotiationListener`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.hasDatagramListener`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.hasSessionTicketListener`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isClosing`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isGracefulClose`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
+* {quic.QuicSession.Stats}
 
-### `sessionState.isSilentClose`
+Return the current statistics for the session. Read only.
 
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isStatelessReset`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isDestroyed`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isHandshakeCompleted`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isHandshakeConfirmed`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isStreamOpenAllowed`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isPrioritySupported`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `sessionState.isWrapped`
+### `session.updateKey()`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+Initiate a key update for the session.
 
-### `sessionState.lastDatagramId`
+### `session[Symbol.asyncDispose]()`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint}
+Calls `session.close()` and returns a promise that fulfills when the
+session has closed.
 
-## Class: `QuicSessionStats`
+## Class: `QuicSession.Stats`
 
 <!-- YAML
 added: REPLACEME
@@ -695,14 +568,6 @@ added: REPLACEME
 
 * {bigint}
 
-### `sessionStats.destroyedAt`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint}
-
 ### `sessionStats.handshakeCompletedAt`
 
 <!-- YAML
@@ -719,14 +584,6 @@ added: REPLACEME
 
 * {bigint}
 
-### `sessionStats.gracefulClosingAt`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint}
-
 ### `sessionStats.bytesReceived`
 
 <!-- YAML
@@ -775,14 +632,6 @@ added: REPLACEME
 
 * {bigint}
 
-### `sessionStats.lossRetransmitCount`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint}
-
 ### `sessionStats.maxBytesInFlights`
 
 <!-- YAML
@@ -901,6 +750,8 @@ added: REPLACEME
 
 * {Promise}
 
+A promise that is fulfilled when the stream is fully closed.
+
 ### `stream.destroy([error])`
 
 <!-- YAML
@@ -908,7 +759,8 @@ added: REPLACEME
 -->
 
 * `error` {any}
-* Returns: {Promise}
+
+Immediately and abruptly destroys the stream.
 
 ### `stream.destroyed`
 
@@ -918,6 +770,8 @@ added: REPLACEME
 
 * {boolean}
 
+True if `stream.destroy()` has been called.
+
 ### `stream.direction`
 
 <!-- YAML
@@ -926,6 +780,8 @@ added: REPLACEME
 
 * {string} One of either `'bidi'` or `'uni'`.
 
+The directionality of the stream. Read only.
+
 ### `stream.id`
 
 <!-- YAML
@@ -934,6 +790,8 @@ added: REPLACEME
 
 * {bigint}
 
+The stream ID. Read only.
+
 ### `stream.onblocked`
 
 <!-- YAML
@@ -942,13 +800,7 @@ added: REPLACEME
 
 * {quic.OnBlockedCallback}
 
-### `stream.onheaders`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.OnHeadersCallback}
+The callback to invoke when the stream is blocked. Read/write.
 
 ### `stream.onreset`
 
@@ -956,253 +808,48 @@ added: REPLACEME
 added: REPLACEME
 -->
 
-* {quic.OnStreamErrorCallback}
-
-### `stream.ontrailers`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.OnTrailersCallback}
-
-### `stream.pull(callback)`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* `callback` {quic.OnPullCallback}
-
-### `stream.sendHeaders(headers)`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* `headers` {Object}
-
-### `stream.session`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.QuicSession}
-
-### `stream.state`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.QuicStreamState}
-
-### `stream.stats`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {quic.QuicStreamStats}
-
-## Class: `QuicStreamState`
-
-<!-- YAML
-added: REPLACEME
--->
-
-### `streamState.destroyed`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.finReceived`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.finSent`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.hasReader`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.id`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint}
-
-### `streamState.paused`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.pending`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.readEnded`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.reset`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.wantsBlock`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.wantsHeaders`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.wantsReset`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.wantsTrailers`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-### `streamState.writeEnded`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-## Class: `QuicStreamStats`
-
-<!-- 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}
+* {quic.OnStreamErrorCallback}
 
-### `streamStats.finalSize`
+The callback to invoke when the stream is reset. Read/write.
+
+### `stream.pull(callback)`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint}
+* `callback` {quic.OnPullCallback}
 
-### `streamStats.isConnected`
+Read received data from the stream. The callback will be invoked with the
+next chunk of received data.
+
+### `stream.session`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint}
+* {quic.QuicSession}
 
-### `streamStats.maxOffset`
+The session that created this stream. Read only.
+
+### `stream.stats`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint}
+* {quic.QuicStream.Stats}
 
-### `streamStats.maxOffsetAcknowledged`
+The current statistics for the stream. Read only.
+
+## Class: `QuicStream.Stats`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint}
-
-### `streamStats.maxOffsetReceived`
+### `streamStats.ackedAt`
 
 <!-- YAML
 added: REPLACEME
@@ -1210,7 +857,7 @@ added: REPLACEME
 
 * {bigint}
 
-### `streamStats.openedAt`
+### `streamStats.bytesReceived`
 
 <!-- YAML
 added: REPLACEME
@@ -1218,7 +865,7 @@ added: REPLACEME
 
 * {bigint}
 
-### `streamStats.receivedAt`
+### `streamStats.bytesSent`
 
 <!-- YAML
 added: REPLACEME
@@ -1226,81 +873,79 @@ added: REPLACEME
 
 * {bigint}
 
-## Types
-
-### Type: `ApplicationOptions`
+### `streamStats.createdAt`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-#### `applicationOptions.maxHeaderPairs`
+* {bigint}
+
+### `streamStats.destroyedAt`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The maximum number of header pairs that can be received.
+* {bigint}
 
-#### `applicationOptions.maxHeaderLength`
+### `streamStats.finalSize`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The maximum number of header bytes that can be received.
+* {bigint}
 
-#### `applicationOptions.maxFieldSectionSize`
+### `streamStats.isConnected`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The maximum header field section size.
+* {bigint}
 
-#### `applicationOptions.qpackMaxDTableCapacity`
+### `streamStats.maxOffset`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The QPack maximum dynamic table capacity.
+* {bigint}
 
-#### `applicationOptions.qpackEncoderMaxDTableCapacity`
+### `streamStats.maxOffsetAcknowledged`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The QPack encoder maximum dynamic table capacity.
+* {bigint}
 
-#### `applicationOptions.qpackBlockedStreams`
+### `streamStats.maxOffsetReceived`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number} The maximum number of QPack blocked streams.
+* {bigint}
 
-#### `applicationOptions.enableConnectProtocol`
+### `streamStats.openedAt`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
-
-True to allow use of the `CONNECT` method when using HTTP/3.
+* {bigint}
 
-#### `applicationOptions.enableDatagrams`
+### `streamStats.receivedAt`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+* {bigint}
 
-True to allow use of unreliable datagrams.
+## Types
 
 ### Type: `EndpointOptions`
 
@@ -1310,6 +955,8 @@ added: REPLACEME
 
 * {Object}
 
+The endpoint configuration options passed when constructing a new `QuicEndpoint` instance.
+
 #### `endpointOptions.address`
 
 <!-- YAML
@@ -1333,51 +980,6 @@ 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.cc`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {string|number}
-
-Specifies the congestion control algorithm that will be used by all sessions
-using this endpoint. Must be set to one of:
-
-* `QuicEndpoint.CC_ALGO_RENO`
-* `QuicEndpoint.CC_ALGP_RENO_STR`
-* `QuicEndpoint.CC_ALGO_CUBIC`
-* `QuicEndpoint.CC_ALGO_CUBIC_STR`
-* `QuicEndpoint.CC_ALGO_BBR`
-* `QuicEndpoint.CC_ALGO_BBR_STR`
-
-This is an advanced option that users typically won't have need to specify
-unless.
-
-#### `endpointOptions.disableActiveMigration`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
-When `true`, this option disables the ability for a session to migrate to a different
-socket address.
-
-\*\* THIS OPTION IS AT RISK OF BEING DROPPED \*\*
-
-#### `endpointOptions.handshakeTimeout`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint|number}
-
-Specifies the maximum number of milliseconds a TLS handshake is permitted to take
-to complete before timing out.
-
 #### `endpointOptions.ipv6Only`
 
 <!-- YAML
@@ -1408,16 +1010,6 @@ added: REPLACEME
 
 Specifies the maximum total number of concurrent sessions.
 
-#### `endpointOptions.maxPayloadSize`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint|number}
-
-Specifies the maximum UDP packet payload size.
-
 #### `endpointOptions.maxRetries`
 
 <!-- YAML
@@ -1438,34 +1030,6 @@ added: REPLACEME
 
 Specifies the maximum number of stateless resets that are allowed per remote peer address.
 
-#### `endpointOptions.maxStreamWindow`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint|number}
-
-Specifies the maximum stream flow-control window size.
-
-#### `endpointOptions.maxWindow`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {bigint|number}
-
-Specifies the maxumum session flow-control window size.
-
-#### `endpointOptions.noUdpPayloadSizeShaping`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
 #### `endpointOptions.retryTokenExpiration`
 
 <!-- YAML
@@ -1530,181 +1094,208 @@ added: REPLACEME
 
 * {number}
 
-#### `endpointOptions.unacknowledgedPacketThreshold`
+#### `endpointOptions.validateAddress`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {bigint|number}
+* {boolean}
 
-Specifies the maximum number of unacknowledged packets a session should allow.
+When `true`, requires that the endpoint validate peer addresses using retry packets
+while establishing a new connection.
 
-#### `endpointOptions.validateAddress`
+### Type: `SessionOptions`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+#### `sessionOptions.alpn`
+
+<!-- YAML
+added: REPLACEME
+-->
 
-When `true`, requires that the endpoint validate peer addresses while establishing
-a connection.
+* {string}
 
-### Type: `SessionOptions`
+The ALPN protocol identifier.
+
+#### `sessionOptions.ca`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-#### `sessionOptions.application`
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+
+The CA certificates to use for sessions.
+
+#### `sessionOptions.cc`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {quic.ApplicationOptions}
+* {string}
+
+Specifies the congestion control algorithm that will be used
+. Must be set to one of either `'reno'`, `'cubic'`, or `'bbr'`.
 
-The application-level options to use for the session.
+This is an advanced option that users typically won't have need to specify.
 
-#### `sessionOptions.minVersion`
+#### `sessionOptions.certs`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {number}
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
 
-The minimum QUIC version number to allow. This is an advanced option that users
-typically won't have need to specify.
+The TLS certificates to use for sessions.
 
-#### `sessionOptions.preferredAddressPolicy`
+#### `sessionOptions.ciphers`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {string} One of `'use'`, `'ignore'`, or `'default'`.
+* {string}
 
-When the remote peer advertises a preferred address, this option specifies whether
-to use it or ignore it.
+The list of supported TLS 1.3 cipher algorithms.
 
-#### `sessionOptions.qlog`
+#### `sessionOptions.crl`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
 
-True if qlog output should be enabled.
+The CRL to use for sessions.
 
-#### `sessionOptions.sessionTicket`
+#### `sessionOptions.groups`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {ArrayBufferView} A session ticket to use for 0RTT session resumption.
+* {string}
+
+The list of support TLS 1.3 cipher groups.
 
-#### `sessionOptions.tls`
+#### `sessionOptions.keylog`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {quic.TlsOptions}
+* {boolean}
 
-The TLS options to use for the session.
+True to enable TLS keylogging output.
 
-#### `sessionOptions.transportParams`
+#### `sessionOptions.keys`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {quic.TransportParams}
+* {KeyObject|CryptoKey|KeyObject\[]|CryptoKey\[]}
 
-The QUIC transport parameters to use for the session.
+The TLS crypto keys to use for sessions.
 
-#### `sessionOptions.version`
+#### `sessionOptions.maxPayloadSize`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {number}
+* {bigint|number}
 
-The QUIC version number to use. This is an advanced option that users typically
-won't have need to specify.
+Specifies the maximum UDP packet payload size.
 
-### Type: `TlsOptions`
+#### `sessionOptions.maxStreamWindow`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-#### `tlsOptions.sni`
+* {bigint|number}
+
+Specifies the maximum stream flow-control window size.
+
+#### `sessionOptions.maxWindow`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {string}
+* {bigint|number}
 
-The peer server name to target.
+Specifies the maxumum session flow-control window size.
 
-#### `tlsOptions.alpn`
+#### `sessionOptions.minVersion`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {string}
+* {number}
 
-The ALPN protocol identifier.
+The minimum QUIC version number to allow. This is an advanced option that users
+typically won't have need to specify.
 
-#### `tlsOptions.ciphers`
+#### `sessionOptions.preferredAddressPolicy`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {string}
+* {string} One of `'use'`, `'ignore'`, or `'default'`.
 
-The list of supported TLS 1.3 cipher algorithms.
+When the remote peer advertises a preferred address, this option specifies whether
+to use it or ignore it.
 
-#### `tlsOptions.groups`
+#### `sessionOptions.qlog`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {string}
+* {boolean}
 
-The list of support TLS 1.3 cipher groups.
+True if qlog output should be enabled.
 
-#### `tlsOptions.keylog`
+#### `sessionOptions.sessionTicket`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+* {ArrayBufferView} A session ticket to use for 0RTT session resumption.
 
-True to enable TLS keylogging output.
+#### `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.
 
-#### `tlsOptions.verifyClient`
+#### `sessionOptions.sni`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+* {string}
 
-True to require verification of TLS client certificate.
+The peer server name to target.
 
-#### `tlsOptions.tlsTrace`
+#### `sessionOptions.tlsTrace`
 
 <!-- YAML
 added: REPLACEME
@@ -1714,55 +1305,56 @@ added: REPLACEME
 
 True to enable TLS tracing output.
 
-#### `tlsOptions.verifyPrivateKey`
+#### `sessionOptions.transportParams`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {boolean}
+* {quic.TransportParams}
 
-True to require private key verification.
+The QUIC transport parameters to use for the session.
 
-#### `tlsOptions.keys`
+#### `sessionOptions.unacknowledgedPacketThreshold`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {KeyObject|CryptoKey|KeyObject\[]|CryptoKey\[]}
+* {bigint|number}
 
-The TLS crypto keys to use for sessions.
+Specifies the maximum number of unacknowledged packets a session should allow.
 
-#### `tlsOptions.certs`
+#### `sessionOptions.verifyClient`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+* {boolean}
 
-The TLS certificates to use for sessions.
+True to require verification of TLS client certificate.
 
-#### `tlsOptions.ca`
+#### `sessionOptions.verifyPrivateKey`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+* {boolean}
 
-The CA certificates to use for sessions.
+True to require private key verification.
 
-#### `tlsOptions.crl`
+#### `sessionOptions.version`
 
 <!-- YAML
 added: REPLACEME
 -->
 
-* {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]}
+* {number}
 
-The CRL to use for sessions.
+The QUIC version number to use. This is an advanced option that users typically
+won't have need to specify.
 
 ### Type: `TransportParams`
 
@@ -1874,14 +1466,6 @@ added: REPLACEME
 
 * {bigint|number}
 
-#### `transportParams.disableActiveMigration`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* {boolean}
-
 ## Callbacks
 
 ### Callback: `OnSessionCallback`
@@ -1990,24 +1574,6 @@ added: REPLACEME
 * `this` {quic.QuicStream}
 * `error` {any}
 
-### Callback: `OnHeadersCallback`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* `this` {quic.QuicStream}
-* `headers` {Object}
-* `kind` {string}
-
-### Callback: `OnTrailersCallback`
-
-<!-- YAML
-added: REPLACEME
--->
-
-* `this` {quic.QuicStream}
-
 ### Callback: `OnPullCallback`
 
 <!-- YAML
diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index 0a047f4e2cf034..dc1f36ac0b170f 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;
 
@@ -29,6 +29,7 @@ let debug = require('internal/util/debuglog').debuglog('quic', (fn) => {
 
 const {
   Endpoint: Endpoint_,
+  Http3Application: Http3,
   setCallbacks,
 
   // The constants to be exposed to end users for various options.
@@ -71,6 +72,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,
@@ -88,6 +90,7 @@ const {
 
 const {
   isBlob,
+  kHandle: kBlobHandle,
 } = require('internal/blob');
 
 const {
@@ -109,7 +112,9 @@ const {
 const kEmptyObject = { __proto__: null };
 
 const {
+  kApplicationProvider,
   kBlocked,
+  kConnect,
   kDatagram,
   kDatagramStatus,
   kFinishClose,
@@ -117,18 +122,25 @@ const {
   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 {
@@ -173,49 +185,28 @@ 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
+ */
+
 /**
  * @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
  */
 
 /**
@@ -233,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
  */
 
 /**
@@ -250,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
  */
 
 /**
@@ -295,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}
@@ -322,28 +310,6 @@ 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
  * @this {QuicStream} stream
@@ -381,26 +347,31 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
  * @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} 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({
   // QuicEndpoint callbacks
 
@@ -459,23 +430,20 @@ setCallbacks({
 
   /**
    * 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) {
-    debug('session handshake callback', sni, alpn, 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);
   },
 
   /**
@@ -490,8 +458,11 @@ setCallbacks({
   onSessionPathValidation(result, newLocalAddress, newRemoteAddress,
                           oldLocalAddress, oldRemoteAddress, preferredAddress) {
     debug('session path validation callback', this[kOwner]);
-    this[kOwner][kPathValidation](result, newLocalAddress, newRemoteAddress,
-                                  oldLocalAddress, oldRemoteAddress,
+    this[kOwner][kPathValidation](result,
+                                  new InternalSocketAddress(newLocalAddress),
+                                  new InternalSocketAddress(newRemoteAddress),
+                                  new InternalSocketAddress(oldLocalAddress),
+                                  new InternalSocketAddress(oldRemoteAddress),
                                   preferredAddress);
   },
 
@@ -568,6 +539,24 @@ setCallbacks({
   },
 });
 
+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;
@@ -649,30 +638,30 @@ class QuicStream {
   }
 
   /** @type {OnHeadersCallback} */
-  get onheaders() { return this.#onheaders; }
+  get [kOnHeaders]() { return this.#onheaders; }
 
-  set onheaders(fn) {
+  set [kOnHeaders](fn) {
     if (fn === undefined) {
       this.#onheaders = undefined;
-      this.#state.wantsHeaders = false;
+      this.#state[kWantsHeaders] = false;
     } else {
       validateFunction(fn, 'onheaders');
       this.#onheaders = fn.bind(this);
-      this.#state.wantsHeaders = true;
+      this.#state[kWantsHeaders] = true;
     }
   }
 
   /** @type {OnTrailersCallback} */
-  get ontrailers() { return this.#ontrailers; }
+  get [kOnTrailers]() { return this.#ontrailers; }
 
-  set ontrailers(fn) {
+  set [kOnTrailers](fn) {
     if (fn === undefined) {
       this.#ontrailers = undefined;
-      this.#state.wantsTrailers = false;
+      this.#state[kWantsTrailers] = false;
     } else {
       validateFunction(fn, 'ontrailers');
       this.#ontrailers = fn.bind(this);
-      this.#state.wantsTrailers = true;
+      this.#state[kWantsTrailers] = true;
     }
   }
 
@@ -714,15 +703,73 @@ class QuicStream {
     this.#reader.pull(callback);
   }
 
+  /**
+   * @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.
    */
-  sendHeaders(headers) {
+  [kSendHeaders](headers) {
     validateObject(headers, 'headers');
     if (this.pending) {
       debug('pending stream enqueing headers', headers);
@@ -818,7 +865,7 @@ class QuicStream {
       direction: this.direction,
       pending: this.pending,
       stats: this.stats,
-      state: this.state,
+      state: this.#state,
       session: this.session,
     }, opts)}`;
   }
@@ -833,8 +880,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} */
@@ -845,16 +892,8 @@ class QuicSession {
   #onstream = undefined;
   /** @type {OnDatagramCallback|undefined} */
   #ondatagram = undefined;
-  /** @type {OnDatagramStatusCallback|undefined} */
-  #ondatagramstatus = undefined;
-  /** @type {OnPathValidationCallback|undefined} */
-  #onpathvalidation = undefined;
-  /** @type {OnSessionTicketCallback|undefined} */
-  #onsessionticket = undefined;
-  /** @type {OnVersionNegotiationCallback|undefined} */
-  #onversionnegotiation = undefined;
-  /** @type {OnHandshakeCallback} */
-  #onhandshake = undefined;
+  /** @type {{}} */
+  #sessionticket = undefined;
 
   /**
    * @param {symbol} privateSymbol
@@ -872,6 +911,9 @@ class QuicSession {
     this.#handle[kOwner] = this;
     this.#stats = new QuicSessionStats(kPrivateConstructor, handle.stats);
     this.#state = new QuicSessionState(kPrivateConstructor, handle.state);
+    this.#state.hasVersionNegotiationListener = true;
+    this.#state.hasPathValidationListener = true;
+    this.#state.hasSessionTicketListener = true;
 
     debug('session created');
   }
@@ -881,6 +923,9 @@ class QuicSession {
     return this.#handle === undefined || this.#isPendingClose;
   }
 
+  /** @type {any} */
+  get sessionticket() { return this.#sessionticket; }
+
   /** @type {OnStreamCallback} */
   get onstream() { return this.#onstream; }
 
@@ -907,211 +952,81 @@ class QuicSession {
     }
   }
 
-  /** @type {OnDatagramStatusCallback} */
-  get ondatagramstatus() { return this.#ondatagramstatus; }
-
-  set ondatagramstatus(fn) {
-    if (fn === undefined) {
-      this.#ondatagramstatus = undefined;
-    } else {
-      validateFunction(fn, 'ondatagramstatus');
-      this.#ondatagramstatus = fn.bind(this);
-    }
-  }
-
-  /** @type {OnPathValidationCallback} */
-  get onpathvalidation() { return this.#onpathvalidation; }
-
-  set onpathvalidation(fn) {
-    if (fn === undefined) {
-      this.#onpathvalidation = undefined;
-      this.#state.hasPathValidationListener = false;
-    } else {
-      validateFunction(fn, 'onpathvalidation');
-      this.#onpathvalidation = fn.bind(this);
-      this.#state.hasPathValidationListener = true;
-    }
-  }
-
-  /** @type {OnSessionCallback} */
-  get onsessionticket() { return this.#onsessionticket; }
-
-  set onsessionticket(fn) {
-    if (fn === undefined) {
-      this.#onsessionticket = undefined;
-      this.#state.hasSessionTicketListener = false;
-    } else {
-      validateFunction(fn, 'onsessionticket');
-      this.#onsessionticket = fn.bind(this);
-      this.#state.hasSessionTicketListener = true;
-    }
-  }
-
-  /** @type {OnVersionNegotiationCallback} */
-  get onversionnegotiation() { return this.#onversionnegotiation; }
-
-  set onversionnegotiation(fn) {
-    if (fn === undefined) {
-      this.#onversionnegotiation = undefined;
-      this.#state.hasVersionNegotiationListener = false;
-    } else {
-      validateFunction(fn, 'onversionnegotiation');
-      this.#onversionnegotiation = fn.bind(this);
-      this.#state.hasVersionNegotiationListener = true;
-    }
-  }
-
-  /** @type {OnHandshakeCallback} */
-  get onhandshake() { return this.#onhandshake; }
-
-  set onhandshake(fn) {
-    if (fn === undefined) {
-      this.#onhandshake = undefined;
-    } else {
-      validateFunction(fn, 'onhandshake');
-      this.#onhandshake = fn.bind(this);
-    }
-  }
-
   /** @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; }
 
-  /** @type {boolean} */
-  get pending() { return !this.#state.isStreamOpenAllowed; }
-
-  /**
-   * 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(options = kEmptyObject) {
+  async #createStream(direction, options = kEmptyObject) {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.');
     }
+    const dir = direction === STREAM_DIRECTION_BIDIRECTIONAL ? 'bidi' : 'uni';
     if (this.pending) {
-      debug('opening new pending bidirectional stream');
+      debug(`opening new pending ${dir} stream`);
     } else {
-      debug('opening new bidirectional stream');
+      debug(`opening new ${dir} stream`);
     }
 
     validateObject(options, 'options');
     const {
       body,
-      headers,
+      [kHeaders]: headers,
     } = options;
     if (headers !== undefined) {
       validateObject(headers, 'options.headers');
     }
 
-    const handle = this.#handle.openStream(STREAM_DIRECTION_BIDIRECTIONAL);
+    const validatedBody = validateBody(body);
+
+    const handle = this.#handle.createStream(direction, validatedBody);
     if (handle === undefined) {
       throw new ERR_QUIC_OPEN_STREAM_FAILED();
     }
 
     if (headers !== undefined) {
-      handle.sendHeaders(1, mapToHeaders(headers), 1);
+      // 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,
-                                  STREAM_DIRECTION_BIDIRECTIONAL);
+    const stream = new QuicStream(kPrivateConstructor, handle, this, direction);
     this.#streams.add(stream);
 
     if (onSessionOpenStreamChannel.hasSubscribers) {
       onSessionOpenStreamChannel.publish({
         stream,
         session: this,
-        options: { __proto__: null, ...options },
+        direction: dir,
       });
     }
     return stream;
   }
 
-  #validateBody(body) {
-    // TODO(@jasnell): Support streaming sources
-    if (body === undefined ||
-        isArrayBuffer(body) ||
-        isArrayBufferView(body) ||
-        isBlob(body)) {
-      return body;
-    }
-
-    throw new ERR_INVALID_ARG_TYPE('options.body', [
-      'ArrayBuffer',
-      'ArrayBufferView',
-      'Blob',
-    ], body);
+  /**
+   * @param {OpenStreamOptions} [options]
+   * @returns {Promise<QuicStream>}
+   */
+  async createBidirectionalStream(options = kEmptyObject) {
+    return await this.#createStream(STREAM_DIRECTION_BIDIRECTIONAL, options);
   }
 
   /**
-   * @returns {QuicStream}
+   * @param {OpenStreamOptions} [options]
+   * @returns {Promise<QuicStream>}
    */
-  openUnidirectionalStream(options = kEmptyObject) {
-    if (this.#isClosedOrClosing) {
-      throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.');
-    }
-    if (this.pending) {
-      debug('opening new pending unidirectional stream');
-    } else {
-      debug('opening new unidirectional stream');
-    }
-
-    validateObject(options, 'options');
-    const {
-      body,
-      headers,
-    } = options;
-    if (headers !== undefined) {
-      validateObject(headers, 'options.headers');
-    }
-    const validatedBody = this.#validateBody(body);
-
-    const handle = this.#handle.openStream(STREAM_DIRECTION_UNIDIRECTIONAL);
-    if (handle === undefined) {
-      throw new ERR_QUIC_OPEN_STREAM_FAILED();
-    }
-
-    if (headers !== undefined) {
-      handle.sendHeaders(1, mapToHeaders(headers), 1);
-    }
-    if (validatedBody !== undefined) {
-      headers.attachSource(validatedBody);
-    }
-
-    const stream = new QuicStream(kPrivateConstructor, handle, this,
-                                  STREAM_DIRECTION_UNIDIRECTIONAL);
-    this.#streams.add(stream);
-
-    if (onSessionOpenStreamChannel.hasSubscribers) {
-      onSessionOpenStreamChannel.publish({
-        stream,
-        session: this,
-        options: { __proto__: null, ...options },
-      });
-    }
-
-    return stream;
+  async createUnidirectionalStream(options = kEmptyObject) {
+    return await this.#createStream(STREAM_DIRECTION_UNIDIRECTIONAL, options);
   }
 
   /**
@@ -1123,9 +1038,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');
     }
@@ -1137,12 +1052,13 @@ 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', datagram.byteLength);
+    debug(`sending datagram with ${datagram.byteLength} bytes`);
 
     const id = this.#handle.sendDatagram(datagram);
 
@@ -1153,8 +1069,6 @@ class QuicSession {
         session: this,
       });
     }
-
-    return id;
   }
 
   /**
@@ -1201,6 +1115,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.
@@ -1218,7 +1135,6 @@ 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;
@@ -1250,23 +1166,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.#sessionticket = undefined;
 
     // Destroy the underlying C++ handle
     this.#handle.destroy();
@@ -1275,10 +1190,9 @@ class QuicSession {
     if (onSessionClosedChannel.hasSubscribers) {
       onSessionClosedChannel.publish({
         session: this,
+        error,
       });
     }
-
-    return this.closed;
   }
 
   /**
@@ -1333,11 +1247,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,
       });
@@ -1349,11 +1264,7 @@ class QuicSession {
    * @param {'lost'|'acknowledged'} status
    */
   [kDatagramStatus](id, status) {
-    if (this.destroyed || typeof this.ondatagramstatus !== 'function') 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 (this.destroyed) return;
     if (onSessionReceiveDatagramStatusChannel.hasSubscribers) {
       onSessionReceiveDatagramStatusChannel.publish({
         id,
@@ -1373,13 +1284,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,
@@ -1397,11 +1302,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,
@@ -1416,14 +1318,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.
-    assert(this.#onversionnegotiation, 'Unexpected version negotiation event');
     if (this.destroyed) return;
-    this.#onversionnegotiation(version, requestedVersions, supportedVersions);
     this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
-
     if (onSessionVersionNegotiationChannel.hasSubscribers) {
       onSessionVersionNegotiationChannel.publish({
         version,
@@ -1435,32 +1331,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,
       });
     }
   }
@@ -1510,7 +1414,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)}`;
@@ -1602,19 +1506,12 @@ class QuicEndpoint {
       maxStatelessResetsPerHost,
       addressLRUSize,
       maxRetries,
-      maxPayloadSize,
-      unacknowledgedPacketThreshold,
-      handshakeTimeout,
-      maxStreamWindow,
-      maxWindow,
       rxDiagnosticLoss,
       txDiagnosticLoss,
       udpReceiveBufferSize,
       udpSendBufferSize,
       udpTTL,
-      noUdpPayloadSizeShaping,
       validateAddress,
-      disableActiveMigration,
       ipv6Only,
       cc,
       resetTokenSecret,
@@ -1642,19 +1539,12 @@ class QuicEndpoint {
       maxStatelessResetsPerHost,
       addressLRUSize,
       maxRetries,
-      maxPayloadSize,
-      unacknowledgedPacketThreshold,
-      handshakeTimeout,
-      maxStreamWindow,
-      maxWindow,
       rxDiagnosticLoss,
       txDiagnosticLoss,
       udpReceiveBufferSize,
       udpSendBufferSize,
       udpTTL,
-      noUdpPayloadSizeShaping,
       validateAddress,
-      disableActiveMigration,
       ipv6Only,
       cc,
       resetTokenSecret,
@@ -1687,20 +1577,6 @@ class QuicEndpoint {
     debug('endpoint created');
   }
 
-  /**
-   * @type {OnSessionCallback|undefined}
-   */
-  get onsession() { return this.#onsession; }
-
-  set onsession(fn) {
-    if (fn === undefined) {
-      this.#onsession = undefined;
-    } else {
-      validateFunction(fn, 'onsession');
-      this.#onsession = fn.bind(this);
-    }
-  }
-
   /**
    * Statistics collected while the endpoint is operational.
    * @type {QuicEndpointStats}
@@ -1708,10 +1584,10 @@ class QuicEndpoint {
   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;
   }
 
   /**
@@ -1756,158 +1632,12 @@ 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(onsession, options = kEmptyObject) {
+  [kListen](onsession, options) {
     if (this.#isClosedOrClosing) {
       throw new ERR_INVALID_STATE('Endpoint is closed');
     }
@@ -1917,83 +1647,45 @@ class QuicEndpoint {
     if (this.#state.isBusy) {
       throw new ERR_INVALID_STATE('Endpoint is busy');
     }
-    if (onsession !== undefined && typeof onsession !== 'function') {
-      options = onsession;
-      onsession = undefined;
-    }
-    if (onsession !== undefined) {
-      validateFunction(onsession, 'onsession');
-      this.#onsession = onsession.bind(this);
-    }
-    if (typeof this.#onsession !== 'function') {
-      throw new ERR_INVALID_STATE('No onsession callback is provided');
-    }
+    validateObject(options, 'options');
+    this.#onsession = onsession.bind(this);
 
     debug('endpoint listening as a server');
-    this.#handle.listen(this.#processSessionOptions(options));
+    this.#handle.listen(options);
     this.#listening = true;
-
-    if (onEndpointListeningChannel.hasSubscribers) {
-      onEndpointListeningChannel.publish({
-        endpoint: this,
-        options,
-      });
-    }
   }
 
   /**
    * Initiates a session with a remote endpoint.
-   * @param {string|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 (this.#state.isBusy) {
       throw new ERR_INVALID_STATE('Endpoint is busy');
     }
-    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 processedOptions = this.#processSessionOptions(options);
-    const { sessionTicket } = processedOptions;
+    validateObject(options, 'options');
+    const { sessionTicket, ...rest } = options;
 
     debug('endpoint connecting as a client');
-    const handle = this.#handle.connect(address[kSocketAddressHandle],
-                                        processedOptions, sessionTicket);
-
+    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
    */
@@ -2022,17 +1714,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
@@ -2045,10 +1733,12 @@ class QuicEndpoint {
   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...
@@ -2177,7 +1867,7 @@ class QuicEndpoint {
       listening: this.#listening,
       sessions: this.#sessions,
       stats: this.stats,
-      state: this.state,
+      state: this.#state,
     }, opts)}`;
   }
 };
@@ -2205,16 +1895,304 @@ ObjectDefineProperties(QuicSession, {
   DEFAULT_GROUPS: readOnlyConstant(DEFAULT_GROUPS),
 });
 
+/**
+ * @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,
+    [kApplicationProvider]: provider,
+  } = options;
+
+  if (provider !== undefined) {
+    validateObject(provider, 'options[kApplicationProvider]');
+  }
+
+  const {
+    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,
+  };
+}
+
+// ============================================================================
+
+/**
+ * @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, {
+  Stats: {
+    __proto__: null,
+    writable: true,
+    configurable: true,
+    enumerable: true,
+    value: QuicEndpointStats,
+  },
+});
+ObjectDefineProperties(QuicSession, {
+  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,
 };
 
 /* c8 ignore stop */
diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js
index 053fd01ba922c9..da880501d8cd61 100644
--- a/lib/internal/quic/state.js
+++ b/lib/internal/quic/state.js
@@ -28,6 +28,8 @@ 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,
@@ -70,6 +71,7 @@ const {
   IDX_STATE_STREAM_WRITE_ENDED,
   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,
@@ -85,7 +87,6 @@ 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_DESTROYED !== undefined);
 assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined);
 assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined);
 assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined);
@@ -106,6 +107,7 @@ 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);
@@ -309,12 +311,6 @@ class QuicSessionState {
     return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET);
   }
 
-  /** @type {boolean} */
-  get isDestroyed() {
-    if (this.#handle.byteLength === 0) return undefined;
-    return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DESTROYED);
-  }
-
   /** @type {boolean} */
   get isHandshakeCompleted() {
     if (this.#handle.byteLength === 0) return undefined;
@@ -483,6 +479,12 @@ class QuicStreamState {
     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;
@@ -502,13 +504,13 @@ class QuicStreamState {
   }
 
   /** @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);
   }
@@ -526,13 +528,13 @@ class QuicStreamState {
   }
 
   /** @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);
   }
@@ -553,11 +555,10 @@ class QuicStreamState {
       writeEnded: this.writeEnded,
       paused: this.paused,
       reset: this.reset,
+      hasOutbound: this.hasOutbound,
       hasReader: this.hasReader,
       wantsBlock: this.wantsBlock,
-      wantsHeaders: this.wantsHeaders,
       wantsReset: this.wantsReset,
-      wantsTrailers: this.wantsTrailers,
     };
   }
 
@@ -583,11 +584,10 @@ class QuicStreamState {
       writeEnded: this.writeEnded,
       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 425b1c62841020..d12a85745bd79a 100644
--- a/lib/internal/quic/stats.js
+++ b/lib/internal/quic/stats.js
@@ -51,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,
@@ -104,17 +101,14 @@ 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_DESTROYED_AT !== undefined);
 assert(IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT !== undefined);
 assert(IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT !== undefined);
-assert(IDX_STATS_SESSION_GRACEFUL_CLOSING_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_LOSS_RETRANSMIT_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);
@@ -330,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];
@@ -345,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];
@@ -380,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];
@@ -472,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}`,
@@ -512,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,
diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js
index f6ddb7af1840e2..15f2339fc95504 100644
--- a/lib/internal/quic/symbols.js
+++ b/lib/internal/quic/symbols.js
@@ -16,43 +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 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,
   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
index 308a06d9eefa56..19d1039efdf355 100644
--- a/lib/quic.js
+++ b/lib/quic.js
@@ -1,25 +1,22 @@
 'use strict';
 
 const {
+  emitExperimentalWarning,
+} = require('internal/util');
+emitExperimentalWarning('quic');
+
+const {
+  connect,
+  listen,
   QuicEndpoint,
   QuicSession,
   QuicStream,
-  QuicSessionState,
-  QuicSessionStats,
-  QuicStreamState,
-  QuicStreamStats,
-  QuicEndpointState,
-  QuicEndpointStats,
 } = require('internal/quic/quic');
 
 module.exports = {
+  connect,
+  listen,
   QuicEndpoint,
   QuicSession,
   QuicStream,
-  QuicSessionState,
-  QuicSessionStats,
-  QuicStreamState,
-  QuicStreamStats,
-  QuicEndpointState,
-  QuicEndpointStats,
 };
diff --git a/src/node_builtins.cc b/src/node_builtins.cc
index 9aaf5626fcfe4a..33682cf4fc5ce2 100644
--- a/src/node_builtins.cc
+++ b/src/node_builtins.cc
@@ -136,6 +136,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/quic/application.cc b/src/quic/application.cc
index 01bba5090fea07..8ce1f2e712a579 100644
--- a/src/quic/application.cc
+++ b/src/quic/application.cc
@@ -135,27 +135,21 @@ bool Session::Application::Start() {
 
 bool Session::Application::AcknowledgeStreamData(int64_t stream_id,
                                                  size_t datalen) {
-  Debug(session_,
-        "Application acknowledging stream %" PRIi64 " data: %zu",
-        stream_id,
-        datalen);
-  auto stream = session().FindStream(stream_id);
-  if (!stream) return false;
-  stream->Acknowledge(datalen);
-  return true;
+  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;
 }
 
@@ -164,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.
 }
 
@@ -188,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.
 }
 
@@ -196,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
@@ -206,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.
 }
 
@@ -217,40 +204,34 @@ StreamPriority Session::Application::GetStreamPriority(const Stream& stream) {
 
 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;
@@ -258,9 +239,10 @@ void Session::Application::SendPendingData() {
 
   auto update_stats = OnScopeLeave([&] {
     auto& s = session();
-    s.UpdateDataStats();
-    if (!s.is_destroyed()) {
+    if (!s.is_destroyed()) [[likely]] {
+      s.UpdatePacketTxTime();
       s.UpdateTimer();
+      s.UpdateDataStats();
     }
   });
 
@@ -270,6 +252,7 @@ void Session::Application::SendPendingData() {
   // 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;
@@ -281,7 +264,8 @@ void Session::Application::SendPendingData() {
   auto ensure_packet = [&] {
     if (!packet) {
       packet = CreateStreamDataPacket();
-      if (!packet) return false;
+      if (!packet) [[unlikely]]
+        return false;
       pos = begin = ngtcp2_vec(*packet).base;
     }
     DCHECK(packet);
@@ -297,24 +281,26 @@ 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 =
@@ -322,11 +308,15 @@ void Session::Application::SendPendingData() {
 
     if (ndatalen > 0) {
       Debug(session_,
-            "Application accepted %zu bytes from stream into packet",
-            ndatalen);
-    } else {
+            "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 into packet");
+            "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
@@ -340,6 +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);
+          // 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;
         }
@@ -348,21 +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);
-          if (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;
@@ -374,40 +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(std::move(packet), path);
+        session_->Send(packet, path);
       } else {
         packet->Done(UV_ECANCELED);
       }
-      packet.reset();
-
-      // 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.
@@ -415,17 +406,15 @@ void Session::Application::SendPendingData() {
     size_t datalen = pos - begin;
     Debug(session_, "Sending packet with %zu bytes", datalen);
     packet->Truncate(datalen);
-    session_->Send(std::move(packet), path);
-    // TODO(@jasnell): Moving a BaseObjectPtr apparently does not fully
-    // invalidate it.
-    packet.reset();
+    session_->Send(packet, path);
 
     // 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.reset();
     pos = begin = nullptr;
   }
 }
@@ -438,7 +427,6 @@ 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;
-
   return ngtcp2_conn_writev_stream(*session_,
                                    &path->path,
                                    nullptr,
@@ -466,9 +454,7 @@ class DefaultApplication final : public Session::Application {
                          size_t datalen,
                          const Stream::ReceiveDataFlags& flags,
                          void* stream_user_data) override {
-    Debug(&session(), "Default application receiving stream data");
-
-    BaseObjectWeakPtr<Stream> stream;
+    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);
@@ -478,7 +464,7 @@ class DefaultApplication final : public Session::Application {
         return false;
       }
     } else {
-      stream = BaseObjectWeakPtr<Stream>(Stream::From(stream_user_data));
+      stream = BaseObjectPtr<Stream>(Stream::From(stream_user_data));
       if (!stream) {
         Debug(&session(),
               "Default application failed to get existing stream "
@@ -495,6 +481,12 @@ class DefaultApplication final : public Session::Application {
   }
 
   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
@@ -522,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) {
@@ -551,10 +554,7 @@ 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 = [](const ngtcp2_vec* vec, size_t cnt) {
@@ -566,10 +566,15 @@ class DefaultApplication final : public Session::Application {
     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);
-
     CHECK(stream_data->stream);
     stream_data->stream->Commit(datalen);
     return true;
@@ -582,14 +587,12 @@ class DefaultApplication final : public Session::Application {
  private:
   void ScheduleStream(int64_t id) {
     if (auto stream = session().FindStream(id)) [[likely]] {
-      Debug(&session(), "Default application scheduling stream %" PRIi64, id);
       stream->Schedule(&stream_queue_);
     }
   }
 
   void UnscheduleStream(int64_t id) {
     if (auto stream = session().FindStream(id)) [[likely]] {
-      Debug(&session(), "Default application unscheduling stream %" PRIi64, id);
       stream->Unschedule();
     }
   }
@@ -597,18 +600,14 @@ class DefaultApplication final : public Session::Application {
   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 682a8e0be9ec1b..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"
@@ -79,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
@@ -146,7 +147,7 @@ struct Session::Application::StreamData final {
   int64_t id = -1;
   int fin = 0;
   ngtcp2_vec data[kMaxVectorCount]{};
-  BaseObjectWeakPtr<Stream> stream;
+  BaseObjectPtr<Stream> stream;
 
   inline operator nghttp3_vec*() {
     return reinterpret_cast<nghttp3_vec*>(data);
diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h
index 3025800d089b28..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")                                                        \
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 da2441b2c4a9ab..46d637118db6a9 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);
@@ -557,7 +470,9 @@ SocketAddress Endpoint::UDP::local_address() const {
   return SocketAddress::FromSockName(impl_->handle_);
 }
 
-int Endpoint::UDP::Send(BaseObjectPtr<Packet> packet) {
+int Endpoint::UDP::Send(const BaseObjectPtr<Packet>& packet) {
+  DCHECK(packet);
+  DCHECK(!packet->IsDispatched());
   if (is_closed_or_closing()) return UV_EBADF;
   uv_buf_t buf = *packet;
 
@@ -621,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);
@@ -682,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",
@@ -737,64 +649,67 @@ 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);
   }
 }
 
-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 (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) {
@@ -827,8 +742,7 @@ void Endpoint::DisassociateStatelessResetToken(
   }
 }
 
-void Endpoint::Send(BaseObjectPtr<Packet>&& packet) {
-  CHECK(packet && !packet->IsDispatched());
+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
@@ -840,11 +754,13 @@ void Endpoint::Send(BaseObjectPtr<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);
@@ -1026,17 +942,14 @@ BaseObjectPtr<Session> Endpoint::Connect(
   // If starting fails, the endpoint will be destroyed.
   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) {
@@ -1047,18 +960,22 @@ BaseObjectPtr<Session> Endpoint::Connect(
   }
   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 {};
   }
-  if (!session) return {};
-  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;
 }
 
@@ -1148,8 +1065,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);
@@ -1166,21 +1083,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,
@@ -1189,26 +1116,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;
     }
 
@@ -1217,10 +1137,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;
     }
@@ -1256,23 +1179,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);
@@ -1301,6 +1228,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,
@@ -1314,8 +1254,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);
@@ -1396,7 +1336,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,
@@ -1493,12 +1436,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;
     }
 
@@ -1521,10 +1468,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
@@ -1537,7 +1481,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;
@@ -1556,7 +1500,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!
   }
@@ -1591,7 +1535,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:
@@ -1621,16 +1564,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?
@@ -1694,6 +1647,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) {
@@ -1744,7 +1702,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 ddc57d62d5443b..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(BaseObjectPtr<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(BaseObjectPtr<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 4ffbd26b8b1935..6160596be1867b 100644
--- a/src/quic/http3.cc
+++ b/src/quic/http3.cc
@@ -17,7 +17,99 @@
 #include "session.h"
 #include "sessionticket.h"
 
-namespace node::quic {
+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 {
   using nv_t = nghttp3_nv;
@@ -75,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),
@@ -261,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;
@@ -288,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;
     }
 
@@ -304,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,
@@ -434,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 {
@@ -448,8 +541,6 @@ class Http3Application final : public Session::Application {
            id == qpack_enc_stream_id_;
   }
 
-  bool is_destroyed() const { return session().is_destroyed(); }
-
   Http3ConnectionPointer InitializeConnection() {
     nghttp3_conn* conn = nullptr;
     nghttp3_settings settings = options_;
@@ -646,9 +737,9 @@ 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;
   }
@@ -669,9 +760,6 @@ class Http3Application final : public Session::Application {
   auto ptr = From(conn, conn_user_data);                                       \
   CHECK_NOT_NULL(ptr);                                                         \
   auto& name = *ptr;                                                           \
-  if (name.is_destroyed()) [[unlikely]] {                                      \
-    return NGHTTP3_ERR_CALLBACK_FAILURE;                                       \
-  }                                                                            \
   NgHttp3CallbackScope scope(name.env());
 
   static nghttp3_ssize on_read_data_callback(nghttp3_conn* conn,
@@ -897,11 +985,13 @@ class Http3Application final : public Session::Application {
                                                    on_receive_settings};
 };
 
-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/session.cc b/src/quic/session.cc
index b1f18f834db19c..9fa182a8db509e 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,18 +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;
@@ -58,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)                                                  \
@@ -108,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)                                \
@@ -117,9 +113,9 @@ namespace quic {
   V(SilentClose, silentClose, false)                                           \
   V(UpdateKey, updateKey, false)                                               \
   V(OpenStream, openStream, false)                                             \
-  V(DoSendDatagram, sendDatagram, false)
+  V(SendDatagram, sendDatagram, false)
 
-struct Session::State {
+struct Session::State final {
 #define V(_, name, type) type name;
   SESSION_STATE(V)
 #undef V
@@ -128,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 {
-
-constexpr 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";
@@ -196,6 +162,28 @@ constexpr 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
@@ -225,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) ||
@@ -240,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) ||
@@ -252,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,
@@ -307,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;
@@ -315,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,
@@ -380,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();
@@ -422,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>();
   }
 
@@ -438,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);
 }
 
@@ -448,1966 +483,2273 @@ 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 {};
-  }
+    // 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] { 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.");
-  // Double check that our timer has stopped.
-  CHECK(!timer_);
-  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());
   }
-}
 
-const 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());
 
-const 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");
-
-  // The DoClose() method should have already been called.
-  DCHECK(state_->closing);
+  static void SendDatagram(const FunctionCallbackInfo<Value>& args) {
+    auto env = Environment::GetCurrent(args);
+    Session* session;
+    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
 
-  // 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_);
+    if (session->is_destroyed()) {
+      THROW_ERR_INVALID_STATE(env, "Session is destroyed");
+    }
 
-  while (!pending_bidi_stream_queue_.IsEmpty()) {
-    pending_bidi_stream_queue_.PopFront()->reject();
-  }
-  while (!pending_uni_stream_queue_.IsEmpty()) {
-    pending_uni_stream_queue_.PopFront()->reject();
+    DCHECK(args[0]->IsArrayBufferView());
+    SendPendingDataScope send_scope(session);
+    args.GetReturnValue().Set(BigInt::New(
+        env->isolate(),
+        session->SendDatagram(Store(args[0].As<ArrayBufferView>()))));
   }
 
-  DCHECK(streams_.empty());
+  // Internal ngtcp2 callbacks
 
-  STAT_RECORD_TIMESTAMP(Stats, destroyed_at);
-  state_->closing = 0;
-  state_->graceful_close = 0;
+  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)
+    return session->application().AcknowledgeStreamData(stream_id, datalen)
+               ? NGTCP2_SUCCESS
+               : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-  timer_.Close();
+  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;
+  }
 
-  // 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_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, 10> cids(ngtcp2_conn_get_scid(*this, nullptr));
-  ngtcp2_conn_get_scid(*this, cids.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;
+  }
 
-  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_uni(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_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 < cids.length(); n++) {
-    endpoint_->DisassociateCID(CID(cids[n]));
+  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;
   }
 
-  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_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;
   }
 
-  state_->destroyed = 1;
+  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;
+  }
 
-  // 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_handshake_completed(ngtcp2_conn* conn, void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    return session->HandshakeCompleted() ? NGTCP2_SUCCESS
+                                         : NGTCP2_ERR_CALLBACK_FAILURE;
+  }
 
-bool Session::Receive(Store&& store,
-                      const SocketAddress& local_address,
-                      const SocketAddress& remote_address) {
-  if (is_destroyed()) return false;
+  static int on_handshake_confirmed(ngtcp2_conn* conn, void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session)
+    session->HandshakeConfirmed();
+    return NGTCP2_SUCCESS;
+  }
 
-  const auto receivePacket = [&](ngtcp2_path* path, ngtcp2_vec vec) {
-    DCHECK(!is_destroyed());
+  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;
+  }
 
-    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_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;
-  };
-
-  remote_address_ = remote_address;
-  Path path(local_address, remote_address_);
-  size_t len = store.length();
-  Debug(this,
-        "Session is receiving %" PRIu64 "-byte packet received along path %s",
-        len,
-        path);
-  STAT_INCREMENT_N(Stats, bytes_received, len);
-
-  // After every packet we receive and successfully process, we
-  // want to process and send any pending data.
-  if (receivePacket(&path, store)) application().SendPendingData();
-
-  Debug(this, "Session successfully received %" PRIu64 "-byte packet", len);
-
-  return true;
-}
+    session->EmitPathValidation(static_cast<PathValidationResult>(res),
+                                PathValidationFlags{flag_preferred_address},
+                                newValidatedPath,
+                                oldValidatedPath);
+    return NGTCP2_SUCCESS;
+  }
 
-void Session::Send(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).
-  DCHECK(!is_destroyed());
-  DCHECK(!is_in_draining_period());
+  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 (can_send_packets() && packet->length() > 0) {
-    Debug(this, "Session is sending %s", packet->ToString());
-    STAT_INCREMENT_N(Stats, bytes_sent, packet->length());
-    endpoint_->Send(std::move(packet));
-    packet.reset();
-    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;
   }
 
-  Debug(this, "Session could not send %s", packet->ToString());
-  packet->Done(packet->length() > 0 ? UV_ECANCELED : 0);
-}
+  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(BaseObjectPtr<Packet>&& packet, const PathStorage& path) {
-  UpdatePath(path);
-  Send(std::move(packet));
-  packet.reset();
-}
+    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
 
-void Session::UpdatePacketTxTime() {
-  ngtcp2_conn_update_pkt_tx_time(*this, uv_hrtime());
-}
+    Debug(session,
+          "Receiving RX key for level %s for dcid %s",
+          to_string(level),
+          session->config().dcid);
 
-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 session->application().Start() ? NGTCP2_SUCCESS
+                                          : NGTCP2_ERR_CALLBACK_FAILURE;
   }
 
-  Debug(this, "Session is sending datagram");
-  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 = state_->last_datagram_id + 1;
-
-  // Let's give it a max number of attempts to send the datagram
-  static const int kMaxAttempts = 16;
-  int attempts = 0;
+  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;
+  }
 
-  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_.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) {
-        last_error_ = QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL);
-        Close(CloseMethod::SILENT);
-        return 0;
-      }
-      pos = ngtcp2_vec(*packet).base;
+  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;
     }
 
-    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());
+    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_receive_tx_key(ngtcp2_conn* conn,
+                               ngtcp2_encryption_level level,
+                               void* user_data) {
+    NGTCP2_CALLBACK_SCOPE(session);
+    CHECK(session->is_server());
 
-    // 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));
-    packet.reset();
+    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) 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;
-    }
+    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;
+  }
 
-    // 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_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;
+  }
+
+  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;
+  }
+
+  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;
+  }
+
+  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;
   }
 
-  return 0;
+  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;
+  }
+
+  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();
+  }
 }
 
-BaseObjectWeakPtr<Stream> Session::FindStream(int64_t id) const {
-  auto it = streams_.find(id);
-  if (it == std::end(streams_)) return {};
-  return it->second;
+// ============================================================================
+bool Session::HasInstance(Environment* env, Local<Value> value) {
+  return GetConstructorTemplate(env)->HasInstance(value);
 }
 
-BaseObjectWeakPtr<Stream> Session::CreateStream(int64_t id,
-                                                CreateStreamOption option) {
-  if (!can_create_streams()) return {};
-  if (auto stream = Stream::Create(this, id)) {
-    AddStream(stream, option);
-    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 {};
+
+  return MakeDetachedBaseObject<Session>(
+      endpoint, obj, config, tls_context, ticket);
 }
 
-MaybeLocal<Object> Session::OpenStream(Direction direction) {
-  // 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()) return MaybeLocal<Object>();
+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.");
 
-  // 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)) [[likely]] {
-      return stream->object();
-    }
-    return MaybeLocal<Object>();
+  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());
   }
 
-  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);
-      }
-    }
-  };
+  if (config.options.tls_options.keylog) [[unlikely]] {
+    keylog_stream_ = LogStream::Create(env());
+    defineProperty(binding.keylog_string(), keylog_stream_->object());
+  }
 
-  switch (open()) {
-    case 0: {
-      // Woo! Our stream was created.
-      CHECK_GE(id, 0);
-      if (auto stream = CreateStream(id, CreateStreamOption::DO_NOT_NOTIFY)) {
-        return stream->object();
-      }
-      return MaybeLocal<Object>();
-    }
-    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)) {
-        return stream->object();
-      }
-      return MaybeLocal<Object>();
+  UpdateDataStats();
+}
+
+Session::~Session() {
+  // Double check that Destroy() was called first.
+  CHECK(is_destroyed());
+}
+
+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;
     }
-    default: {
-      // The stream could not be opened. Return nothing to signal error.
-      return MaybeLocal<Object>();
+    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;
     }
   }
-  UNREACHABLE();
+  return QuicConnectionPointer(conn);
 }
 
-void Session::AddStream(const BaseObjectPtr<Stream>& stream,
-                        CreateStreamOption option) {
-  CHECK(stream);
-
-  auto id = stream->id();
-  auto direction = stream->direction();
+Session::operator ngtcp2_conn*() const {
+  return connection_.get();
+}
 
-  // 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.
-  CHECK_EQ(streams_.find(id), streams_.end());
+bool Session::is_server() const {
+  return side_ == Side::SERVER;
+}
 
-  Debug(this, "Adding stream %" PRIi64 " to session", id);
+bool Session::is_destroyed() const {
+  return !impl_;
+}
 
-  // 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.
-  auto& inserted = streams_[id] = std::move(stream);
-  inserted->Detach();
+bool Session::is_destroyed_or_closing() const {
+  return !impl_ || impl_->state_->closing;
+}
 
-  ngtcp2_conn_set_stream_user_data(*this, id, inserted.get());
+void Session::Close(Session::CloseMethod method) {
+  if (is_destroyed()) return;
+  auto& stats_ = impl_->stats_;
 
-  if (option == CreateStreamOption::NOTIFY) {
-    EmitStream(inserted);
+  if (impl_->last_error_) {
+    Debug(this, "Closing with error: %s", impl_->last_error_);
   }
 
-  // Update tracking statistics for the number of streams associated with this
-  // session.
-  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;
-      }
+  STAT_RECORD_TIMESTAMP(Stats, closing_at);
+
+  // 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.
+
+  switch (method) {
+    case CloseMethod::DEFAULT: {
+      Debug(this, "Immediately closing session");
+      impl_->state_->silent_close = 0;
+      return FinishClose();
     }
-  } else {
-    switch (direction) {
-      case Direction::BIDIRECTIONAL: {
-        STAT_INCREMENT(Stats, bidi_in_stream_count);
-        break;
+    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();
       }
-      case Direction::UNIDIRECTIONAL: {
-        STAT_INCREMENT(Stats, uni_in_stream_count);
-        break;
+
+      // 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();
 }
 
-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);
-}
+void Session::FinishClose() {
+  // FinishClose() should be called only after, and as a result of, Close()
+  // being called first.
+  DCHECK(impl_);
+  DCHECK(impl_->state_->closing);
 
-void Session::ResumeStream(int64_t id) {
-  Debug(this, "Resuming stream %" PRIi64, id);
-  SendPendingDataScope send_scope(this);
-  application_->ResumeStream(id);
-}
+  // If impl_->Close() returns true, then the session can be destroyed
+  // immediately without round-tripping through JavaScript.
+  if (impl_->Close()) {
+    return Destroy();
+  }
 
-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);
+  // Otherwise, we emit a close callback so that the JavaScript side can
+  // clean up anything it needs to clean up before destroying.
+  EmitClose();
 }
 
-void Session::StreamDataBlocked(int64_t id) {
-  Debug(this, "Stream %" PRIi64 " is blocked", id);
-  STAT_INCREMENT(Stats, block_count);
-  application_->BlockStream(id);
+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();
 }
 
-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);
+PendingStream::PendingStreamQueue& Session::pending_bidi_stream_queue() const {
+  CHECK(!is_destroyed());
+  return impl_->pending_bidi_stream_queue_;
 }
 
-void Session::CollectSessionTicketAppData(
-    SessionTicket::AppData* app_data) const {
-  application_->CollectSessionTicketAppData(app_data);
+PendingStream::PendingStreamQueue& Session::pending_uni_stream_queue() const {
+  CHECK(!is_destroyed());
+  return impl_->pending_uni_stream_queue_;
 }
 
-SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
-    const SessionTicket::AppData& app_data,
-    SessionTicket::AppData::Source::Flag flag) {
-  return application_->ExtractSessionTicketAppData(app_data, flag);
+size_t Session::max_packet_size() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_max_tx_udp_payload_size(*this);
 }
 
-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_);
+uint32_t Session::version() const {
+  CHECK(!is_destroyed());
+  return impl_->config_.version;
 }
 
-bool Session::is_in_closing_period() const {
-  return ngtcp2_conn_in_closing_period(*this) != 0;
+Endpoint& Session::endpoint() const {
+  CHECK(!is_destroyed());
+  return *impl_->endpoint_;
 }
 
-bool Session::is_in_draining_period() const {
-  return ngtcp2_conn_in_draining_period(*this) != 0;
+TLSSession& Session::tls_session() const {
+  CHECK(!is_destroyed());
+  return *tls_session_;
 }
 
-bool Session::wants_session_ticket() const {
-  return state_->session_ticket == 1;
+Session::Application& Session::application() const {
+  CHECK(!is_destroyed());
+  return *impl_->application_;
 }
 
-void Session::SetStreamOpenAllowed() {
-  state_->stream_open_allowed = 1;
+const SocketAddress& Session::remote_address() const {
+  CHECK(!is_destroyed());
+  return impl_->remote_address_;
 }
 
-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_;
+const SocketAddress& Session::local_address() const {
+  CHECK(!is_destroyed());
+  return impl_->local_address_;
 }
 
-bool Session::can_create_streams() const {
-  return !state_->destroyed && !state_->graceful_close && !state_->closing &&
-         !is_in_closing_period() && !is_in_draining_period();
-}
+std::string Session::diagnostic_name() const {
+  const auto get_type = [&] { return is_server() ? "server" : "client"; };
 
-bool Session::can_open_streams() const {
-  return state_->stream_open_allowed;
+  return std::string("Session (") + get_type() + "," +
+         std::to_string(env()->thread_id()) + ":" +
+         std::to_string(static_cast<int64_t>(get_async_id())) + ")";
 }
 
-uint64_t Session::max_data_left() const {
-  return ngtcp2_conn_get_max_data_left(*this);
+const Session::Config& Session::config() const {
+  CHECK(!is_destroyed());
+  return impl_->config_;
 }
 
-uint64_t Session::max_local_streams_uni() const {
-  return ngtcp2_conn_get_streams_uni_left(*this);
+Session::Config& Session::config() {
+  CHECK(!is_destroyed());
+  return impl_->config_;
 }
 
-uint64_t Session::max_local_streams_bidi() const {
-  return ngtcp2_conn_get_local_transport_params(*this)
-      ->initial_max_streams_bidi;
+const Session::Options& Session::options() const {
+  CHECK(!is_destroyed());
+  return impl_->config_.options;
 }
 
-void Session::set_wrapped() {
-  state_->wrapped = 1;
+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::set_priority_supported(bool on) {
-  state_->priority_supported = on ? 1 : 0;
+const TransportParams Session::local_transport_params() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_local_transport_params(*this);
 }
 
-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);
-
-  // 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();
+const TransportParams Session::remote_transport_params() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_remote_transport_params(*this);
 }
 
-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);
+void Session::SetLastError(QuicError&& error) {
+  CHECK(!is_destroyed());
+  Debug(this, "Setting last error to %s", error);
+  impl_->last_error_ = std::move(error);
 }
 
-void Session::ExtendOffset(size_t amount) {
-  Debug(this, "Extending offset by %zu", amount);
-  ngtcp2_conn_extend_max_offset(*this, amount);
-}
+bool Session::Receive(Store&& store,
+                      const SocketAddress& local_address,
+                      const SocketAddress& remote_address) {
+  CHECK(!is_destroyed());
+  impl_->remote_address_ = remote_address;
 
-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));
-}
+  // When we are done processing thins packet, we arrange to send any
+  // pending data for this session.
+  SendPendingDataScope send_scope(this);
 
-void Session::SendConnectionClose() {
-  DCHECK(!NgTcp2CallbackScope::in_ngtcp2_callback(env()));
-  if (is_destroyed() || is_in_draining_period() || state_->silent_close) return;
+  ngtcp2_vec vec = store;
+  Path path(local_address, remote_address);
 
-  Debug(this, "Sending connection close");
-  auto on_exit = OnScopeLeave([this] { UpdateTimer(); });
+  Debug(this,
+        "Session is receiving %zu-byte packet received along path %s",
+        vec.len,
+        path);
 
-  switch (config_.side) {
-    case Side::SERVER: {
-      if (!is_in_closing_period() && !StartClosingPeriod()) {
+  // 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));
-        packet.reset();
       }
-      return;
+      return false;
     }
   }
-  UNREACHABLE();
+
+  // 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();
+  }
+  return false;
 }
 
-void Session::OnTimeout() {
-  HandleScope scope(env()->isolate());
-  if (is_destroyed()) return;
+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).
 
-  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;
+  // 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());
+
+  if (!can_send_packets()) [[unlikely]] {
+    return packet->Done(UV_ECANCELED);
   }
 
-  Debug(this, "Session timed out");
-  last_error_ = QuicError::ForNgtcp2Error(ret);
-  Close(CloseMethod::SILENT);
+  Debug(this, "Session is sending %s", packet->ToString());
+  auto& stats_ = impl_->stats_;
+  STAT_INCREMENT_N(Stats, bytes_sent, packet->length());
+  endpoint().Send(packet);
 }
 
-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,
+                   const PathStorage& path) {
+  UpdatePath(path);
+  Send(packet);
+}
 
-  if (expiry <= now) {
-    // The timer has already expired.
-    return OnTimeout();
+uint64_t Session::SendDatagram(Store&& data) {
+  CHECK(!is_destroyed());
+  if (!can_send_packets()) {
+    Debug(this, "Unable to send datagram");
+    return 0;
   }
 
-  auto timeout = (expiry - now) / NGTCP2_MILLISECONDS;
-  Debug(this, "Updating timeout to %zu milliseconds", timeout);
+  const ngtcp2_transport_params* tp = remote_transport_params();
+  uint64_t max_datagram_size = tp->max_datagram_frame_size;
 
-  // 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);
-}
+  if (max_datagram_size == 0) {
+    Debug(this, "Datagrams are disabled");
+    return 0;
+  }
 
-bool Session::StartClosingPeriod() {
-  if (is_in_closing_period()) return true;
-  if (is_destroyed()) return false;
+  if (data.length() > max_datagram_size) {
+    Debug(this, "Ignoring oversized datagram");
+    return 0;
+  }
+
+  if (data.length() == 0) {
+    Debug(this, "Ignoring empty datagram");
+    return 0;
+  }
 
-  Debug(this, "Session is entering closing period");
+  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;
 
-  conn_closebuf_ = Packet::CreateConnectionClosePacket(
-      env(), endpoint_.get(), remote_address_, *this, last_error_);
+  Debug(this, "Sending %zu-byte datagram %" PRIu64, data.length(), did);
 
-  // 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;
-  }
+  // Let's give it a max number of attempts to send the datagram.
+  static const int kMaxAttempts = 16;
+  int attempts = 0;
 
-  return true;
-}
+  auto on_exit = OnScopeLeave([&] {
+    UpdatePacketTxTime();
+    UpdateTimer();
+    UpdateDataStats();
+  });
 
-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;
+  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;
+    }
+
+    ssize_t nwrite = ngtcp2_conn_writev_datagram(*this,
+                                                 &path.path,
+                                                 nullptr,
+                                                 pos,
+                                                 packet->length(),
+                                                 &accepted,
+                                                 flags,
+                                                 did,
+                                                 &vec,
+                                                 1,
+                                                 uv_hrtime());
+
+    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::ForNgtcp2Error(nwrite));
+      Close(CloseMethod::SILENT);
+      return 0;
+    }
+
+    // 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();
+
+    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;
     }
-    case quic::DatagramStatus::LOST: {
-      Debug(this, "Datagram %" PRIu64 " was lost", datagramId);
-      STAT_INCREMENT(Stats, datagrams_lost);
+
+    // 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;
     }
+
+    // If we get here that means the datagram has not yet been sent.
+    // We're going to loop around to try again.
   }
-  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;
+  return 0;
+}
 
-  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);
+void Session::UpdatePacketTxTime() {
+  CHECK(!is_destroyed());
+  ngtcp2_conn_update_pkt_tx_time(*this, uv_hrtime());
 }
 
-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;
+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_);
 }
 
-bool Session::HandshakeCompleted() {
-  Debug(this, "Session handshake completed");
+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;
+}
 
-  if (state_->handshake_completed) return false;
-  state_->handshake_completed = 1;
-  SetStreamOpenAllowed();
+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 {};
+}
 
-  STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
+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 {};
 
-  // TODO(@jasnel): Not yet supporting early data...
-  // if (!tls_session().early_data_was_accepted())
-  //   ngtcp2_conn_tls_early_data_rejected(*this);
+  // 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 {};
+  }
 
-  // When in a server session, handshake completed == handshake confirmed.
-  if (is_server()) {
-    HandshakeConfirmed();
+  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);
+      }
+    }
+  };
 
-    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;
+  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 {};
+}
 
-  EmitHandshakeComplete();
+void Session::AddStream(BaseObjectPtr<Stream> stream,
+                        CreateStreamOption option) {
+  CHECK(!is_destroyed());
+  CHECK(stream);
 
-  return true;
-}
+  auto id = stream->id();
+  auto direction = stream->direction();
 
-void Session::HandshakeConfirmed() {
-  if (state_->handshake_confirmed) return;
+  // 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());
 
-  Debug(this, "Session handshake confirmed");
+  Debug(this, "Adding stream %" PRIi64 " to session", id);
 
-  state_->handshake_confirmed = true;
-  STAT_RECORD_TIMESTAMP(Stats, handshake_confirmed_at);
-}
+  // 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();
 
-void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) {
-  if (config_.options.preferred_address_strategy ==
-      PreferredAddress::Policy::IGNORE_PREFERRED) {
-    Debug(this, "Ignoring preferred address");
-    return;
-  }
+  ngtcp2_conn_set_stream_user_data(*this, id, stream.get());
 
-  auto local_address = endpoint_->local_address();
-  int family = local_address.family();
+  if (option == CreateStreamOption::NOTIFY) {
+    EmitStream(stream);
+  }
 
-  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());
+  // 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;
       }
-      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());
+  } 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;
       }
-      break;
     }
   }
 }
 
-CID Session::new_cid(size_t len) const {
-  return config_.options.cid_factory->Generate(len);
-}
-
-void Session::ProcessPendingBidiStreams() {
-  // It shouldn't be possible to get here if can_create_streams() is false.
-  CHECK(can_create_streams());
+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);
+    }
+  }
 
-  int64_t id;
+  ngtcp2_conn_set_stream_user_data(*this, id, nullptr);
 
-  while (!pending_bidi_stream_queue_.IsEmpty()) {
-    if (ngtcp2_conn_get_streams_bidi_left(*this) == 0) {
-      return;
-    }
+  // 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);
 
-    switch (ngtcp2_conn_open_bidi_stream(*this, &id, nullptr)) {
-      case 0: {
-        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.
-        pending_bidi_stream_queue_.PopFront()->reject(
-            QuicError::ForTransport(NGTCP2_STREAM_LIMIT_ERROR));
-        continue;
-      }
-    }
+  // 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::ProcessPendingUniStreams() {
-  // It shouldn't be possible to get here if can_create_streams() is false.
-  CHECK(can_create_streams());
+void Session::ResumeStream(int64_t id) {
+  CHECK(!is_destroyed());
+  SendPendingDataScope send_scope(this);
+  application().ResumeStream(id);
+}
 
-  int64_t id;
+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);
+}
 
-  while (!pending_uni_stream_queue_.IsEmpty()) {
-    if (ngtcp2_conn_get_streams_uni_left(*this) == 0) {
-      return;
-    }
+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);
+}
 
-    switch (ngtcp2_conn_open_uni_stream(*this, &id, nullptr)) {
-      case 0: {
-        pending_uni_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.
-        pending_uni_stream_queue_.PopFront()->reject(
-            QuicError::ForTransport(NGTCP2_STREAM_LIMIT_ERROR));
-        continue;
-      }
-    }
+void Session::StreamDataBlocked(int64_t id) {
+  CHECK(!is_destroyed());
+  auto& stats_ = impl_->stats_;
+  STAT_INCREMENT(Stats, block_count);
+  application().BlockStream(id);
+}
+
+void Session::CollectSessionTicketAppData(
+    SessionTicket::AppData* app_data) const {
+  CHECK(!is_destroyed());
+  application().CollectSessionTicketAppData(app_data);
+}
+
+SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
+    const SessionTicket::AppData& app_data,
+    SessionTicket::AppData::Source::Flag flag) {
+  CHECK(!is_destroyed());
+  return application().ExtractSessionTicketAppData(app_data, flag);
+}
+
+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_);
   }
 }
 
-// JavaScript callouts
+bool Session::is_in_closing_period() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_in_closing_period(*this) != 0;
+}
 
-void Session::EmitClose(const QuicError& error) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return Destroy();
+bool Session::is_in_draining_period() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_in_draining_period(*this) != 0;
+}
 
-  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);
+bool Session::wants_session_ticket() const {
+  return !is_destroyed() && impl_->state_->session_ticket == 1;
 }
 
-void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+void Session::SetStreamOpenAllowed() {
+  CHECK(!is_destroyed());
+  impl_->state_->stream_open_allowed = 1;
+}
 
-  CallbackScope<Session> cbv_scope(this);
+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();
+}
 
-  Local<Value> argv[] = {datagram.ToUint8Array(env()),
-                         v8::Boolean::New(env()->isolate(), flag.early)};
+bool Session::can_create_streams() const {
+  return !is_destroyed_or_closing() && !is_in_closing_period() &&
+         !is_in_draining_period();
+}
 
-  Debug(this, "Notifying JavaScript of datagram");
-  MakeCallback(BindingData::Get(env()).session_datagram_callback(),
-               arraysize(argv),
-               argv);
+bool Session::can_open_streams() const {
+  return !is_destroyed() && impl_->state_->stream_open_allowed;
 }
 
-void Session::EmitDatagramStatus(uint64_t id, quic::DatagramStatus status) {
-  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);
+}
 
-  CallbackScope<Session> cb_scope(this);
-  auto& state = BindingData::Get(env());
+uint64_t Session::max_local_streams_uni() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_streams_uni_left(*this);
+}
 
-  const auto status_to_string = ([&] {
-    switch (status) {
-      case quic::DatagramStatus::ACKNOWLEDGED:
-        return state.acknowledged_string();
-      case quic::DatagramStatus::LOST:
-        return state.lost_string();
-    }
-    UNREACHABLE();
-  })();
+uint64_t Session::max_local_streams_bidi() const {
+  CHECK(!is_destroyed());
+  return ngtcp2_conn_get_local_transport_params(*this)
+      ->initial_max_streams_bidi;
+}
 
-  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);
+void Session::set_wrapped() {
+  CHECK(!is_destroyed());
+  impl_->state_->wrapped = 1;
 }
 
-void Session::EmitHandshakeComplete() {
-  DCHECK(!is_destroyed());
-  if (!env()->can_call_into_js()) return;
+void Session::set_priority_supported(bool on) {
+  CHECK(!is_destroyed());
+  impl_->state_->priority_supported = on ? 1 : 0;
+}
 
-  CallbackScope<Session> cb_scope(this);
+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);
+}
 
-  auto isolate = env()->isolate();
+void Session::ExtendOffset(size_t amount) {
+  CHECK(!is_destroyed());
+  Debug(this, "Extending offset by %zu bytes", amount);
+  ngtcp2_conn_extend_max_offset(*this, amount);
+}
 
-  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::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));
+}
 
-  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::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;
 
-  auto& tls = tls_session();
-  auto peerVerifyError = tls.VerifyPeerIdentity(env());
-  if (peerVerifyError.has_value() &&
-      (!peerVerifyError->reason.ToLocal(&argv[kValidationErrorReason]) ||
-       !peerVerifyError->code.ToLocal(&argv[kValidationErrorCode]))) {
-    return;
-  }
+  Debug(this, "Sending connection close packet to peer");
 
-  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;
-  }
+  auto ErrorAndSilentClose = [&] {
+    Debug(this, "Failed to create connection close packet");
+    SetLastError(QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR));
+    Close(CloseMethod::SILENT);
+  };
 
-  Debug(this, "Notifying JavaScript of handshake complete");
-  MakeCallback(BindingData::Get(env()).session_handshake_callback(),
-               arraysize(argv),
-               argv);
-}
+  if (is_server()) {
+    if (auto packet = Packet::CreateConnectionClosePacket(
+            env(),
+            endpoint(),
+            impl_->remote_address_,
+            *this,
+            impl_->last_error_)) [[likely]] {
+      return Send(packet);
+    }
 
-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;
+    // 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();
   }
 
-  auto isolate = env()->isolate();
-  CallbackScope<Session> cb_scope(this);
-  auto& state = BindingData::Get(env());
-
-  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();
-  })();
+  auto packet = Packet::Create(env(),
+                               endpoint(),
+                               impl_->remote_address_,
+                               kDefaultMaxPacketLength,
+                               "immediate connection close (client)");
+  if (!packet) [[unlikely]] {
+    return ErrorAndSilentClose();
+  }
 
-  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)};
+  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());
 
-  if (oldPath.has_value()) {
-    argv[3] = SocketAddressBase::Create(env(), oldPath->local)->object();
-    argv[4] = SocketAddressBase::Create(env(), oldPath->remote)->object();
+  if (nwrite < 0) [[unlikely]] {
+    packet->Done(UV_ECANCELED);
+    return ErrorAndSilentClose();
   }
 
-  Debug(this, "Notifying JavaScript of path validation");
-  MakeCallback(state.session_path_validation_callback(), arraysize(argv), argv);
+  packet->Truncate(nwrite);
+  return Send(packet);
 }
 
-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 (!wants_session_ticket()) [[likely]] {
-    Debug(this, "Session ticket was discarded");
-    return;
+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();
   }
 
-  CallbackScope<Session> cb_scope(this);
+  Debug(this, "Session timed out");
+  SetLastError(QuicError::ForNgtcp2Error(ret));
+  Close(CloseMethod::SILENT);
+}
 
-  auto remote_transport_params = GetRemoteTransportParams();
-  Store transport_params;
-  if (remote_transport_params)
-    transport_params = remote_transport_params.Encode(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);
 
-  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);
+  if (expiry <= now) {
+    // The timer has already expired.
+    return OnTimeout();
   }
-}
 
-void Session::EmitStream(const 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())),
-  };
+  auto timeout = (expiry - now) / NGTCP2_MILLISECONDS;
+  Debug(this, "Updating timeout to %zu milliseconds", timeout);
 
-  Debug(this, "Notifying JavaScript of stream created");
-  MakeCallback(
-      BindingData::Get(env()).stream_created_callback(), arraysize(argv), argv);
+  // 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);
 }
 
-void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
-                                     const uint32_t* sv,
-                                     size_t nsv) {
+void Session::DatagramStatus(uint64_t datagramId, quic::DatagramStatus status) {
   DCHECK(!is_destroyed());
-  DCHECK(!is_server());
-  if (!env()->can_call_into_js()) return;
-
-  auto isolate = env()->isolate();
-  const auto to_integer = [&](uint32_t version) {
-    return Integer::NewFromUnsigned(isolate, version);
-  };
-
-  CallbackScope<Session> cb_scope(this);
+  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);
+}
 
-  // version() is the version that was actually configured for this session.
+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;
 
-  // 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]);
+  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);
+}
 
-  // 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)};
+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);
+}
 
-  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))};
+bool Session::HandshakeCompleted() {
+  DCHECK(!is_destroyed());
+  DCHECK(!impl_->state_->handshake_completed);
 
-  Debug(this, "Notifying JavaScript of version negotiation");
-  MakeCallback(BindingData::Get(env()).session_version_negotiation_callback(),
-               arraysize(argv),
-               argv);
-}
+  Debug(this, "Session handshake completed");
+  impl_->state_->handshake_completed = 1;
+  auto& stats_ = impl_->stats_;
+  STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
+  SetStreamOpenAllowed();
 
-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); });
-  }
-}
+  // TODO(@jasnel): Not yet supporting early data...
+  // if (!tls_session().early_data_was_accepted())
+  //   ngtcp2_conn_tls_early_data_rejected(*this);
 
-// ============================================================================
-// ngtcp2 static callback functions
+  // When in a server session, handshake completed == handshake confirmed.
+  if (is_server()) {
+    HandshakeConfirmed();
 
-#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());
+    auto& ep = endpoint();
 
-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 (!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 DoDestroy(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    session->Destroy();
-  }
+  EmitHandshakeComplete();
 
-  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());
-  }
+  return true;
+}
 
-  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::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);
+}
 
-  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::SelectPreferredAddress(PreferredAddress* preferredAddress) {
+  if (options().preferred_address_strategy ==
+      PreferredAddress::Policy::IGNORE_PREFERRED) {
+    Debug(this, "Ignoring preferred address");
+    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);
+  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 void GracefulClose(const FunctionCallbackInfo<Value>& args) {
-    Session* session;
-    ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
-    session->Close(Session::CloseMethod::GRACEFUL);
-  }
+CID Session::new_cid(size_t len) const {
+  return options().cid_factory->Generate(len);
+}
 
-  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);
-  }
+void Session::ProcessPendingBidiStreams() {
+  // It shouldn't be possible to get here if can_create_streams() is false.
+  DCHECK(can_create_streams());
 
-  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());
-  }
+  int64_t id;
 
-  static void OpenStream(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());
-    Local<Object> stream;
-    if (session->OpenStream(direction).ToLocal(&stream)) [[likely]] {
-      args.GetReturnValue().Set(stream);
+  while (!impl_->pending_bidi_stream_queue_.IsEmpty()) {
+    if (ngtcp2_conn_get_streams_bidi_left(*this) == 0) {
+      return;
     }
-  }
 
-  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>()))));
+    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_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)
-    return session->application().AcknowledgeStreamData(stream_id, datalen)
-               ? NGTCP2_SUCCESS
-               : NGTCP2_ERR_CALLBACK_FAILURE;
-  }
+void Session::ProcessPendingUniStreams() {
+  // It shouldn't be possible to get here if can_create_streams() is false.
+  DCHECK(can_create_streams());
 
-  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;
-  }
+  int64_t id;
 
-  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;
+  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)
-    // TODO(@jasnell): Do anything here?
-    return NGTCP2_SUCCESS;
+// JavaScript callouts
+
+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();
+
+  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;
   }
 
-  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;
-  }
+  MakeCallback(
+      BindingData::Get(env()).session_close_callback(), arraysize(argv), argv);
 
-  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;
-  }
+  // Importantly, the session instance itself should have been destroyed!
+  CHECK(is_destroyed());
+}
 
-  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;
-  }
+void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
+  DCHECK(!is_destroyed());
+  if (!env()->can_call_into_js()) 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;
-  }
+  CallbackScope<Session> cbv_scope(this);
 
-  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;
-  }
+  Local<Value> argv[] = {datagram.ToUint8Array(env()),
+                         Boolean::New(env()->isolate(), flag.early)};
 
-  static int on_handshake_completed(ngtcp2_conn* conn, void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    return session->HandshakeCompleted() ? NGTCP2_SUCCESS
-                                         : NGTCP2_ERR_CALLBACK_FAILURE;
-  }
+  MakeCallback(BindingData::Get(env()).session_datagram_callback(),
+               arraysize(argv),
+               argv);
+}
 
-  static int on_handshake_confirmed(ngtcp2_conn* conn, void* user_data) {
-    NGTCP2_CALLBACK_SCOPE(session)
-    session->HandshakeConfirmed();
-    return NGTCP2_SUCCESS;
-  }
+void Session::EmitDatagramStatus(uint64_t id, quic::DatagramStatus status) {
+  DCHECK(!is_destroyed());
 
-  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;
-  }
+  if (!env()->can_call_into_js()) return;
 
-  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)};
+  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();
+  })();
 
-  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;
-  }
+  Local<Value> argv[] = {BigInt::NewFromUnsigned(env()->isolate(), id),
+                         status_to_string};
 
-  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(state.session_datagram_status_callback(), arraysize(argv), argv);
+}
 
-  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());
+void Session::EmitHandshakeComplete() {
+  DCHECK(!is_destroyed());
 
-    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
+  if (!env()->can_call_into_js()) return;
 
-    Debug(session,
-          "Receiving RX key for level %d for dcid %s",
-          to_string(level),
-          session->config().dcid);
+  CallbackScope<Session> cb_scope(this);
 
-    return session->application().Start() ? NGTCP2_SUCCESS
-                                          : NGTCP2_ERR_CALLBACK_FAILURE;
+  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_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;
+  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_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,
-    };
+  MakeCallback(BindingData::Get(env()).session_handshake_callback(),
+               arraysize(argv),
+               argv);
+}
 
-    // 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;
+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;
+  }
+
+  auto isolate = env()->isolate();
+  CallbackScope<Session> cb_scope(this);
+  auto& state = BindingData::Get(env());
+
+  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();
+  })();
+
+  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)};
+
+  if (oldPath.has_value()) {
+    argv[3] = SocketAddressBase::Create(env(), oldPath->local)->object();
+    argv[4] = SocketAddressBase::Create(env(), oldPath->remote)->object();
+  }
+
+  Debug(this, "Notifying JavaScript of path validation");
+  MakeCallback(state.session_path_validation_callback(), arraysize(argv), argv);
+}
 
-    return NGTCP2_SUCCESS;
+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_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;
+  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);
+      }
     }
-    CHECK(session->is_server());
+  }
+}
 
-    if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS;
+void Session::EmitStream(const BaseObjectWeakPtr<Stream>& stream) {
+  DCHECK(!is_destroyed());
 
-    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;
-  }
+  if (!env()->can_call_into_js()) return;
+  CallbackScope<Session> cb_scope(this);
 
-  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;
-  }
+  auto isolate = env()->isolate();
+  Local<Value> argv[] = {
+      stream->object(),
+      Integer::NewFromUnsigned(isolate,
+                               static_cast<uint32_t>(stream->direction())),
+  };
 
-  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;
-  }
+  MakeCallback(
+      BindingData::Get(env()).stream_created_callback(), arraysize(argv), argv);
+}
 
-  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;
-  }
+void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
+                                     const uint32_t* sv,
+                                     size_t nsv) {
+  DCHECK(!is_destroyed());
+  DCHECK(!is_server());
 
-  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;
-  }
+  if (!env()->can_call_into_js()) return;
 
-  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;
-  }
+  CallbackScope<Session> cb_scope(this);
+  auto& opts = options();
 
-  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;
-  }
+  // 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 void on_rand(uint8_t* dest,
-                      size_t destlen,
-                      const ngtcp2_rand_ctx* rand_ctx) {
-    CHECK(ncrypto::CSPRNG(dest, destlen));
+  LocalVector<Value> versions(env()->isolate(), nsv);
+  for (size_t n = 0; n < nsv; n++) {
+    versions.push_back(Integer::NewFromUnsigned(env()->isolate(), sv[n]));
   }
 
-  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;
-  }
+  // 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 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};
+  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 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};
-};
+  MakeCallback(BindingData::Get(env()).session_version_negotiation_callback(),
+               arraysize(argv),
+               argv);
+}
 
-#undef NGTCP2_CALLBACK_SCOPE
+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); });
+  }
+}
+
+// ============================================================================
 
 Local<FunctionTemplate> Session::GetConstructorTemplate(Environment* env) {
   auto& state = BindingData::Get(env);
@@ -2439,54 +2781,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 f84a125321c9da..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,15 +120,15 @@ 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.
@@ -119,9 +137,46 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
     // so that it cannot be garbage collected.
     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,
@@ -219,144 +274,110 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
   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);
-
-  const TransportParams GetLocalTransportParams() const;
-  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;
 
-  // Note that we are returning a BaseObjectWeakPtr here. The Stream instance
-  // itself is owned strongly by the Session in a detached state. This means
-  // that the only reference to the Stream keeping it alive is held by the
-  // Session itself. That strong reference will be destroyed either when the
-  // stream is removed from session or the session is destroyed. All other
-  // references to the stream should be held weakly.
-  BaseObjectWeakPtr<Stream> FindStream(int64_t id) const;
-
-  enum class CreateStreamOption {
-    NOTIFY,
-    DO_NOT_NOTIFY,
-  };
-
-  // Creates the stream, adding it to the sessions stream map if successful.
-  // A weak reference to the stream is returned.
-  BaseObjectWeakPtr<Stream> CreateStream(
-      int64_t id, CreateStreamOption option = CreateStreamOption::NOTIFY);
-
-  // 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);
-
-  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)
   };
 
-  inline PendingStream::PendingStreamQueue& pending_bidi_stream_queue() {
-    return pending_bidi_stream_queue_;
-  }
+  struct State;
+  struct Stats;
 
-  inline PendingStream::PendingStreamQueue& pending_uni_stream_queue() {
-    return pending_uni_stream_queue_;
-  }
+  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(BaseObjectPtr<Packet>&& packet);
-  void Send(BaseObjectPtr<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,
-                 CreateStreamOption option);
+  // 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;
@@ -403,12 +424,43 @@ 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();
@@ -431,57 +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(const 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_;
-  BaseObjectPtr<Packet> conn_closebuf_;
   BaseObjectPtr<LogStream> qlog_stream_;
   BaseObjectPtr<LogStream> keylog_stream_;
-  PendingStream::PendingStreamQueue pending_bidi_stream_queue_;
-  PendingStream::PendingStreamQueue pending_uni_stream_queue_;
 
   friend class Application;
   friend class DefaultApplication;
-  friend class Http3Application;
+  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 15ee9e3101ed0e..f1ce7d2e562703 100644
--- a/src/quic/streams.cc
+++ b/src/quic/streams.cc
@@ -45,6 +45,7 @@ namespace quic {
   V(WRITE_ENDED, write_ended, 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)                                         \
@@ -103,7 +104,7 @@ PendingStream::PendingStream(Direction direction,
 
 PendingStream::~PendingStream() {
   pending_stream_queue_.Remove();
-  if (!waiting_) {
+  if (waiting_) {
     Debug(stream_, "A pending stream was canceled");
   }
 }
@@ -143,11 +144,10 @@ 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(1);
+  std::vector<std::unique_ptr<DataQueue::Entry>> entries;
   if (value->IsUndefined()) {
     return Just(std::shared_ptr<DataQueue>());
   } else if (value->IsArrayBuffer()) {
@@ -161,8 +161,13 @@ Maybe<std::shared_ptr<DataQueue>> GetDataQueueFromSource(Environment* env,
         buffer->GetBackingStore(), 0, buffer->ByteLength()));
     return Just(DataQueue::CreateIdempotent(std::move(entries)));
   } else if (value->IsArrayBufferView()) {
-    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;
@@ -174,7 +179,6 @@ 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.
@@ -405,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,
@@ -1022,8 +1026,10 @@ void Stream::set_final_size(uint64_t final_size) {
 
 void Stream::set_outbound(std::shared_ptr<DataQueue> source) {
   if (!source || !is_writable()) return;
+  Debug(this, "Setting the outbound data source");
   DCHECK_NULL(outbound_);
   outbound_ = std::make_unique<Outbound>(this, std::move(source));
+  state_->has_outbound = 1;
   if (!is_pending()) session_->ResumeStream(id());
 }
 
@@ -1094,6 +1100,8 @@ bool Stream::AddHeader(const Header& header) {
 void Stream::Acknowledge(size_t datalen) {
   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));
@@ -1104,6 +1112,7 @@ void Stream::Acknowledge(size_t datalen) {
 }
 
 void Stream::Commit(size_t datalen) {
+  Debug(this, "Commiting %zu bytes", datalen);
   STAT_RECORD_TIMESTAMP(Stats, acked_at);
   if (outbound_) outbound_->Commit(datalen);
 }
@@ -1114,7 +1123,7 @@ void Stream::EndWritable() {
   // 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;
 }
 
@@ -1182,6 +1191,9 @@ void Stream::ReceiveData(const uint8_t* data,
                          ReceiveDataFlags flags) {
   // 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;
@@ -1201,6 +1213,7 @@ 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 (state_->read_ended) return;
+  Debug(this, "Received stop sending with error %s", error);
   ngtcp2_conn_shutdown_stream_read(session(), 0, id(), error.code());
   EndReadable();
 }
@@ -1211,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);
 }
@@ -1220,6 +1237,7 @@ 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.
+  Debug(this, "Blocked");
   if (!env()->can_call_into_js() || !state_->wants_block) {
     return;
   }
@@ -1281,10 +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.
+  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 7461ef5b66f5fe..4c6f63a851cf03 100644
--- a/src/quic/streams.h
+++ b/src/quic/streams.h
@@ -129,12 +129,23 @@ class PendingStream final {
 // 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);
@@ -189,6 +200,9 @@ class Stream final : public AsyncWrap,
   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.
@@ -201,6 +215,7 @@ class Stream final : public AsyncWrap,
   // 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;
@@ -232,12 +247,15 @@ class Stream final : 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)
@@ -246,13 +264,6 @@ class Stream final : 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;
@@ -280,6 +291,13 @@ class Stream final : 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);
 
@@ -326,6 +344,8 @@ class Stream final : public AsyncWrap,
 
   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..c16120c2b2c1b7 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,9 +436,10 @@ 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) ||
+  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 +452,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 +499,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 +539,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 +628,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 +638,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/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..b4395c0abf4f46
--- /dev/null
+++ b/test/parallel/test-quic-handshake.js
@@ -0,0 +1,87 @@
+// Flags: --experimental-quic --no-warnings
+'use strict';
+
+const { hasQuic } = require('../common');
+const { Buffer } = require('node:buffer');
+
+const {
+  describe,
+  it,
+} = require('node:test');
+
+describe('quic basic server/client handshake works', { skip: !hasQuic }, 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.onhandshake = (...args) => {
+        strictEqual(args[0], 'localhost');
+        strictEqual(args[1], 'h3');
+        strictEqual(args[2], 'TLS_AES_128_GCM_SHA256');
+        strictEqual(args[3], 'TLSv1.3');
+        p1.resolve();
+      };
+
+      serverSession.onstream = (stream) => {
+        const chunks = [];
+
+        // The interface here is very low level, and is the same interface
+        // that underlies Blob to provided streamed data. We are just not
+        // wrapping it with a ReadableStream yet. It's a bit awkward to use
+        // as is so we will need to consider this carefully.
+
+        const i = new setInterval(() => {
+          stream.pull((status, chunk) => {
+            if (status === 0) {
+              clearInterval(i);
+              serverSession.endpoint.close();
+              serverSession.close();
+              p3.resolve(Buffer.concat(chunks));
+              return;
+            }
+            if (chunk !== undefined) chunks.push(new Uint8Array(chunk));
+          });
+        }, 1);
+      };
+    }, { keys, certs });
+
+    ok(serverEndpoint.address !== undefined);
+
+    const clientSession = await connect(serverEndpoint.address);
+    clientSession.onhandshake = (...args) => {
+      strictEqual(args[0], 'localhost');
+      strictEqual(args[1], 'h3');
+      strictEqual(args[2], 'TLS_AES_128_GCM_SHA256');
+      strictEqual(args[3], 'TLSv1.3');
+      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.endpoint.close();
+    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 5bf4d69d1e5786..a41bd79e454987 100644
--- a/test/parallel/test-quic-internal-endpoint-listen-defaults.js
+++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.js
@@ -15,39 +15,49 @@ describe('quic internal endpoint listen defaults', { skip: !hasQuic }, async ()
     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();
 
-    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), {
+    throws(() => listen(123, { keys, certs, endpoint }), {
       code: 'ERR_INVALID_STATE',
     });
 
-    throws(() => endpoint.listen(() => {}, 123), {
+    throws(() => listen(() => {}, 123), {
       code: 'ERR_INVALID_ARG_TYPE',
     });
 
-    endpoint.listen(() => {});
-    throws(() => endpoint.listen(() => {}), {
+    listen(() => {}, { keys, certs, endpoint });
+    throws(() => listen(() => {}), {
       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);
@@ -63,7 +73,7 @@ describe('quic internal endpoint listen defaults', { skip: !hasQuic }, async ()
     await endpoint.closed;
     ok(endpoint.destroyed);
 
-    throws(() => endpoint.listen(() => {}), {
+    throws(() => 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 2cce05bc4c0b3e..d8f878f088ab3b 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'],
diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.js b/test/parallel/test-quic-internal-endpoint-stats-state.js
index 62b42169909d64..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,24 +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]();
-    strictEqual(endpoint.state.isBound, undefined);
+    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'
     });
@@ -149,9 +156,7 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
     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);
@@ -161,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);
@@ -195,17 +199,14 @@ describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => {
 
     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 f4b1c8d3ce5d8c..02a0dfcbcda525 100644
--- a/tools/doc/type-parser.mjs
+++ b/tools/doc/type-parser.mjs
@@ -285,14 +285,11 @@ const customTypesMap = {
   'Disposable': 'https://tc39.es/proposal-explicit-resource-management/#sec-disposable-interface',
 
   'quic.QuicEndpoint': 'quic.html#class-quicendpoint',
-  'quic.QuicEndpointState': 'quic.html#class-quicendpointstate',
-  'quic.QuicEndpointStats': 'quic.html#class-quicendpointstats',
+  'quic.QuicEndpoint.Stats': 'quic.html#class-quicendpointstats',
   'quic.QuicSession': 'quic.html#class-quicsession',
-  'quic.QuicSessionState': 'quic.html#class-quicsessionstate',
-  'quic.QuicSessionStats': 'quic.html#class-quicsessionstats',
+  'quic.QuicSession.Stats': 'quic.html#class-quicsessionstats',
   'quic.QuicStream': 'quic.html#class-quicstream',
-  'quic.QuicStreamState': 'quic.html#class-quicstreamstate',
-  'quic.QuicStreamStats': 'quic.html#class-quicstreamstats',
+  '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',