diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js
index bb87d361e4b..d2baa9b5aab 100644
--- a/lib/web/websocket/connection.js
+++ b/lib/web/websocket/connection.js
@@ -1,21 +1,12 @@
 'use strict'
 
-const { uid, states, sentCloseFrameState, emptyBuffer, opcodes } = require('./constants')
-const {
-  kReadyState,
-  kSentClose,
-  kByteParser,
-  kReceivedClose,
-  kResponse
-} = require('./symbols')
-const { fireEvent, failWebsocketConnection, isClosing, isClosed, isEstablished, parseExtensions } = require('./util')
+const { uid } = require('./constants')
+const { failWebsocketConnection, parseExtensions } = require('./util')
 const { channels } = require('../../core/diagnostics')
-const { CloseEvent } = require('./events')
 const { makeRequest } = require('../fetch/request')
 const { fetching } = require('../fetch/index')
 const { Headers, getHeadersList } = require('../fetch/headers')
 const { getDecodeSplit } = require('../fetch/util')
-const { WebsocketFrameSend } = require('./frame')
 
 /** @type {import('crypto')} */
 let crypto
@@ -30,11 +21,10 @@ try {
  * @see https://websockets.spec.whatwg.org/#concept-websocket-establish
  * @param {URL} url
  * @param {string|string[]} protocols
- * @param {import('./websocket').WebSocket} ws
- * @param {(response: any, extensions: string[] | undefined) => void} onEstablish
+ * @param {import('./websocket').Handler} handler
  * @param {Partial<import('../../types/websocket').WebSocketInit>} options
  */
-function establishWebSocketConnection (url, protocols, client, ws, onEstablish, options) {
+function establishWebSocketConnection (url, protocols, client, handler, options) {
   // 1. Let requestURL be a copy of url, with its scheme set to "http", if url’s
   //    scheme is "ws", and to "https" otherwise.
   const requestURL = url
@@ -107,7 +97,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
       // 1. If response is a network error or its status is not 101,
       //    fail the WebSocket connection.
       if (response.type === 'error' || response.status !== 101) {
-        failWebsocketConnection(ws, 'Received network error or non-101 status code.')
+        failWebsocketConnection(handler, 'Received network error or non-101 status code.')
         return
       }
 
@@ -116,7 +106,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
       //    header list results in null, failure, or the empty byte
       //    sequence, then fail the WebSocket connection.
       if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) {
-        failWebsocketConnection(ws, 'Server did not respond with sent protocols.')
+        failWebsocketConnection(handler, 'Server did not respond with sent protocols.')
         return
       }
 
@@ -131,7 +121,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
       //    insensitive match for the value "websocket", the client MUST
       //    _Fail the WebSocket Connection_.
       if (response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') {
-        failWebsocketConnection(ws, 'Server did not set Upgrade header to "websocket".')
+        failWebsocketConnection(handler, 'Server did not set Upgrade header to "websocket".')
         return
       }
 
@@ -140,7 +130,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
       //    ASCII case-insensitive match for the value "Upgrade", the client
       //    MUST _Fail the WebSocket Connection_.
       if (response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') {
-        failWebsocketConnection(ws, 'Server did not set Connection header to "upgrade".')
+        failWebsocketConnection(handler, 'Server did not set Connection header to "upgrade".')
         return
       }
 
@@ -154,7 +144,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
       const secWSAccept = response.headersList.get('Sec-WebSocket-Accept')
       const digest = crypto.createHash('sha1').update(keyValue + uid).digest('base64')
       if (secWSAccept !== digest) {
-        failWebsocketConnection(ws, 'Incorrect hash received in Sec-WebSocket-Accept header.')
+        failWebsocketConnection(handler, 'Incorrect hash received in Sec-WebSocket-Accept header.')
         return
       }
 
@@ -172,7 +162,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
         extensions = parseExtensions(secExtension)
 
         if (!extensions.has('permessage-deflate')) {
-          failWebsocketConnection(ws, 'Sec-WebSocket-Extensions header does not match.')
+          failWebsocketConnection(handler, 'Sec-WebSocket-Extensions header does not match.')
           return
         }
       }
@@ -193,14 +183,14 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
         // the selected subprotocol values in its response for the connection to
         // be established.
         if (!requestProtocols.includes(secProtocol)) {
-          failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.')
+          failWebsocketConnection(handler, 'Protocol was not set in the opening handshake.')
           return
         }
       }
 
-      response.socket.on('data', onSocketData)
-      response.socket.on('close', onSocketClose)
-      response.socket.on('error', onSocketError)
+      response.socket.on('data', handler.onSocketData)
+      response.socket.on('close', handler.onSocketClose)
+      response.socket.on('error', handler.onSocketError)
 
       if (channels.open.hasSubscribers) {
         channels.open.publish({
@@ -210,159 +200,21 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
         })
       }
 
-      onEstablish(response, extensions)
+      handler.onConnectionEstablished(response, extensions)
     }
   })
 
   return controller
 }
 
-function closeWebSocketConnection (ws, code, reason, reasonByteLength) {
-  if (isClosing(ws) || isClosed(ws)) {
-    // If this's ready state is CLOSING (2) or CLOSED (3)
-    // Do nothing.
-  } else if (!isEstablished(ws)) {
-    // If the WebSocket connection is not yet established
-    // Fail the WebSocket connection and set this's ready state
-    // to CLOSING (2).
-    failWebsocketConnection(ws, 'Connection was closed before it was established.')
-    ws[kReadyState] = states.CLOSING
-  } else if (ws[kSentClose] === sentCloseFrameState.NOT_SENT) {
-    // If the WebSocket closing handshake has not yet been started
-    // Start the WebSocket closing handshake and set this's ready
-    // state to CLOSING (2).
-    // - If neither code nor reason is present, the WebSocket Close
-    //   message must not have a body.
-    // - If code is present, then the status code to use in the
-    //   WebSocket Close message must be the integer given by code.
-    // - If reason is also present, then reasonBytes must be
-    //   provided in the Close message after the status code.
-
-    ws[kSentClose] = sentCloseFrameState.PROCESSING
-
-    const frame = new WebsocketFrameSend()
-
-    // If neither code nor reason is present, the WebSocket Close
-    // message must not have a body.
-
-    // If code is present, then the status code to use in the
-    // WebSocket Close message must be the integer given by code.
-    if (code !== undefined && reason === undefined) {
-      frame.frameData = Buffer.allocUnsafe(2)
-      frame.frameData.writeUInt16BE(code, 0)
-    } else if (code !== undefined && reason !== undefined) {
-      // If reason is also present, then reasonBytes must be
-      // provided in the Close message after the status code.
-      frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength)
-      frame.frameData.writeUInt16BE(code, 0)
-      // the body MAY contain UTF-8-encoded data with value /reason/
-      frame.frameData.write(reason, 2, 'utf-8')
-    } else {
-      frame.frameData = emptyBuffer
-    }
-
-    /** @type {import('stream').Duplex} */
-    const socket = ws[kResponse].socket
-
-    socket.write(frame.createFrame(opcodes.CLOSE))
-
-    ws[kSentClose] = sentCloseFrameState.SENT
-
-    // Upon either sending or receiving a Close control frame, it is said
-    // that _The WebSocket Closing Handshake is Started_ and that the
-    // WebSocket connection is in the CLOSING state.
-    ws[kReadyState] = states.CLOSING
-  } else {
-    // Otherwise
-    // Set this's ready state to CLOSING (2).
-    ws[kReadyState] = states.CLOSING
-  }
-}
-
-/**
- * @param {Buffer} chunk
- */
-function onSocketData (chunk) {
-  if (!this.ws[kByteParser].write(chunk)) {
-    this.pause()
-  }
-}
-
 /**
- * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
- * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4
+ * @param {import('./websocket').Handler} handler
+ * @param {number} code
+ * @param {any} reason
+ * @param {number} reasonByteLength
  */
-function onSocketClose () {
-  const { ws } = this
-  const { [kResponse]: response } = ws
-
-  response.socket.off('data', onSocketData)
-  response.socket.off('close', onSocketClose)
-  response.socket.off('error', onSocketError)
-
-  // If the TCP connection was closed after the
-  // WebSocket closing handshake was completed, the WebSocket connection
-  // is said to have been closed _cleanly_.
-  const wasClean = ws[kSentClose] === sentCloseFrameState.SENT && ws[kReceivedClose]
-
-  let code = 1005
-  let reason = ''
-
-  const result = ws[kByteParser].closingInfo
-
-  if (result && !result.error) {
-    code = result.code ?? 1005
-    reason = result.reason
-  } else if (!ws[kReceivedClose]) {
-    // If _The WebSocket
-    // Connection is Closed_ and no Close control frame was received by the
-    // endpoint (such as could occur if the underlying transport connection
-    // is lost), _The WebSocket Connection Close Code_ is considered to be
-    // 1006.
-    code = 1006
-  }
-
-  // 1. Change the ready state to CLOSED (3).
-  ws[kReadyState] = states.CLOSED
-
-  // 2. If the user agent was required to fail the WebSocket
-  //    connection, or if the WebSocket connection was closed
-  //    after being flagged as full, fire an event named error
-  //    at the WebSocket object.
-  // TODO
-
-  // 3. Fire an event named close at the WebSocket object,
-  //    using CloseEvent, with the wasClean attribute
-  //    initialized to true if the connection closed cleanly
-  //    and false otherwise, the code attribute initialized to
-  //    the WebSocket connection close code, and the reason
-  //    attribute initialized to the result of applying UTF-8
-  //    decode without BOM to the WebSocket connection close
-  //    reason.
-  // TODO: process.nextTick
-  fireEvent('close', ws, (type, init) => new CloseEvent(type, init), {
-    wasClean, code, reason
-  })
-
-  if (channels.close.hasSubscribers) {
-    channels.close.publish({
-      websocket: ws,
-      code,
-      reason
-    })
-  }
-}
-
-function onSocketError (error) {
-  const { ws } = this
-
-  ws[kReadyState] = states.CLOSING
-
-  if (channels.socketError.hasSubscribers) {
-    channels.socketError.publish(error)
-  }
-
-  this.destroy()
+function closeWebSocketConnection (handler, code, reason, reasonByteLength) {
+  handler.onClose(code, reason, reasonByteLength)
 }
 
 module.exports = {
diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js
index 581c251074c..04236ecc90b 100644
--- a/lib/web/websocket/receiver.js
+++ b/lib/web/websocket/receiver.js
@@ -3,7 +3,6 @@
 const { Writable } = require('node:stream')
 const assert = require('node:assert')
 const { parserStates, opcodes, states, emptyBuffer, sentCloseFrameState } = require('./constants')
-const { kReadyState, kSentClose, kResponse, kReceivedClose } = require('./symbols')
 const { channels } = require('../../core/diagnostics')
 const {
   isValidStatusCode,
@@ -37,10 +36,13 @@ class ByteParser extends Writable {
   /** @type {Map<string, PerMessageDeflate>} */
   #extensions
 
-  constructor (ws, extensions) {
+  /** @type {import('./websocket').Handler} */
+  #handler
+
+  constructor (handler, extensions) {
     super()
 
-    this.ws = ws
+    this.#handler = handler
     this.#extensions = extensions == null ? new Map() : extensions
 
     if (this.#extensions.has('permessage-deflate')) {
@@ -86,12 +88,12 @@ class ByteParser extends Writable {
         const rsv3 = buffer[0] & 0x10
 
         if (!isValidOpcode(opcode)) {
-          failWebsocketConnection(this.ws, 'Invalid opcode received')
+          failWebsocketConnection(this.#handler, 'Invalid opcode received')
           return callback()
         }
 
         if (masked) {
-          failWebsocketConnection(this.ws, 'Frame cannot be masked')
+          failWebsocketConnection(this.#handler, 'Frame cannot be masked')
           return callback()
         }
 
@@ -105,43 +107,43 @@ class ByteParser extends Writable {
         // WebSocket connection where a PMCE is in use, this bit indicates
         // whether a message is compressed or not.
         if (rsv1 !== 0 && !this.#extensions.has('permessage-deflate')) {
-          failWebsocketConnection(this.ws, 'Expected RSV1 to be clear.')
+          failWebsocketConnection(this.#handler, 'Expected RSV1 to be clear.')
           return
         }
 
         if (rsv2 !== 0 || rsv3 !== 0) {
-          failWebsocketConnection(this.ws, 'RSV1, RSV2, RSV3 must be clear')
+          failWebsocketConnection(this.#handler, 'RSV1, RSV2, RSV3 must be clear')
           return
         }
 
         if (fragmented && !isTextBinaryFrame(opcode)) {
           // Only text and binary frames can be fragmented
-          failWebsocketConnection(this.ws, 'Invalid frame type was fragmented.')
+          failWebsocketConnection(this.#handler, 'Invalid frame type was fragmented.')
           return
         }
 
         // If we are already parsing a text/binary frame and do not receive either
         // a continuation frame or close frame, fail the connection.
         if (isTextBinaryFrame(opcode) && this.#fragments.length > 0) {
-          failWebsocketConnection(this.ws, 'Expected continuation frame')
+          failWebsocketConnection(this.#handler, 'Expected continuation frame')
           return
         }
 
         if (this.#info.fragmented && fragmented) {
           // A fragmented frame can't be fragmented itself
-          failWebsocketConnection(this.ws, 'Fragmented frame exceeded 125 bytes.')
+          failWebsocketConnection(this.#handler, 'Fragmented frame exceeded 125 bytes.')
           return
         }
 
         // "All control frames MUST have a payload length of 125 bytes or less
         // and MUST NOT be fragmented."
         if ((payloadLength > 125 || fragmented) && isControlFrame(opcode)) {
-          failWebsocketConnection(this.ws, 'Control frame either too large or fragmented')
+          failWebsocketConnection(this.#handler, 'Control frame either too large or fragmented')
           return
         }
 
         if (isContinuationFrame(opcode) && this.#fragments.length === 0 && !this.#info.compressed) {
-          failWebsocketConnection(this.ws, 'Unexpected continuation frame')
+          failWebsocketConnection(this.#handler, 'Unexpected continuation frame')
           return
         }
 
@@ -187,7 +189,7 @@ class ByteParser extends Writable {
         // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275
         // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e
         if (upper > 2 ** 31 - 1) {
-          failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.')
+          failWebsocketConnection(this.#handler, 'Received payload length > 2^31 bytes.')
           return
         }
 
@@ -215,7 +217,7 @@ class ByteParser extends Writable {
             // parsing continuation frames, not here.
             if (!this.#info.fragmented && this.#info.fin) {
               const fullMessage = Buffer.concat(this.#fragments)
-              websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
+              websocketMessageReceived(this.#handler, this.#info.binaryType, fullMessage)
               this.#fragments.length = 0
             }
 
@@ -223,7 +225,7 @@ class ByteParser extends Writable {
           } else {
             this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
               if (error) {
-                closeWebSocketConnection(this.ws, 1007, error.message, error.message.length)
+                closeWebSocketConnection(this.#handler, 1007, error.message, error.message.length)
                 return
               }
 
@@ -236,7 +238,7 @@ class ByteParser extends Writable {
                 return
               }
 
-              websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
+              websocketMessageReceived(this.#handler, this.#info.binaryType, Buffer.concat(this.#fragments))
 
               this.#loop = true
               this.#state = parserStates.INFO
@@ -339,7 +341,7 @@ class ByteParser extends Writable {
 
     if (opcode === opcodes.CLOSE) {
       if (payloadLength === 1) {
-        failWebsocketConnection(this.ws, 'Received close frame with a 1-byte body.')
+        failWebsocketConnection(this.#handler, 'Received close frame with a 1-byte body.')
         return false
       }
 
@@ -348,12 +350,12 @@ class ByteParser extends Writable {
       if (this.#info.closeInfo.error) {
         const { code, reason } = this.#info.closeInfo
 
-        closeWebSocketConnection(this.ws, code, reason, reason.length)
-        failWebsocketConnection(this.ws, reason)
+        closeWebSocketConnection(this.#handler, code, reason, reason.length)
+        failWebsocketConnection(this.#handler, reason)
         return false
       }
 
-      if (this.ws[kSentClose] !== sentCloseFrameState.SENT) {
+      if (this.#handler.closeState !== sentCloseFrameState.SENT) {
         // If an endpoint receives a Close frame and did not previously send a
         // Close frame, the endpoint MUST send a Close frame in response.  (When
         // sending a Close frame in response, the endpoint typically echos the
@@ -365,11 +367,11 @@ class ByteParser extends Writable {
         }
         const closeFrame = new WebsocketFrameSend(body)
 
-        this.ws[kResponse].socket.write(
+        this.#handler.socket.write(
           closeFrame.createFrame(opcodes.CLOSE),
           (err) => {
             if (!err) {
-              this.ws[kSentClose] = sentCloseFrameState.SENT
+              this.#handler.closeState = sentCloseFrameState.SENT
             }
           }
         )
@@ -378,8 +380,8 @@ class ByteParser extends Writable {
       // Upon either sending or receiving a Close control frame, it is said
       // that _The WebSocket Closing Handshake is Started_ and that the
       // WebSocket connection is in the CLOSING state.
-      this.ws[kReadyState] = states.CLOSING
-      this.ws[kReceivedClose] = true
+      this.#handler.readyState = states.CLOSING
+      this.#handler.receivedClose = true
 
       return false
     } else if (opcode === opcodes.PING) {
@@ -388,10 +390,10 @@ class ByteParser extends Writable {
       // A Pong frame sent in response to a Ping frame must have identical
       // "Application data"
 
-      if (!this.ws[kReceivedClose]) {
+      if (!this.#handler.receivedClose) {
         const frame = new WebsocketFrameSend(body)
 
-        this.ws[kResponse].socket.write(frame.createFrame(opcodes.PONG))
+        this.#handler.socket.write(frame.createFrame(opcodes.PONG))
 
         if (channels.ping.hasSubscribers) {
           channels.ping.publish({
diff --git a/lib/web/websocket/symbols.js b/lib/web/websocket/symbols.js
deleted file mode 100644
index 11d03e38a86..00000000000
--- a/lib/web/websocket/symbols.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict'
-
-module.exports = {
-  kWebSocketURL: Symbol('url'),
-  kReadyState: Symbol('ready state'),
-  kController: Symbol('controller'),
-  kResponse: Symbol('response'),
-  kBinaryType: Symbol('binary type'),
-  kSentClose: Symbol('sent close'),
-  kReceivedClose: Symbol('received close'),
-  kByteParser: Symbol('byte parser')
-}
diff --git a/lib/web/websocket/util.js b/lib/web/websocket/util.js
index e5ce7899752..9d3890c1eb0 100644
--- a/lib/web/websocket/util.js
+++ b/lib/web/websocket/util.js
@@ -1,51 +1,47 @@
 'use strict'
 
-const { kReadyState, kController, kResponse, kBinaryType, kWebSocketURL } = require('./symbols')
 const { states, opcodes } = require('./constants')
-const { ErrorEvent, createFastMessageEvent } = require('./events')
 const { isUtf8 } = require('node:buffer')
 const { collectASequenceOfCodePointsFast, removeHTTPWhitespace } = require('../fetch/data-url')
 
-/* globals Blob */
-
 /**
- * @param {import('./websocket').WebSocket} ws
+ * @param {number} readyState
  * @returns {boolean}
  */
-function isConnecting (ws) {
+function isConnecting (readyState) {
   // If the WebSocket connection is not yet established, and the connection
   // is not yet closed, then the WebSocket connection is in the CONNECTING state.
-  return ws[kReadyState] === states.CONNECTING
+  return readyState === states.CONNECTING
 }
 
 /**
- * @param {import('./websocket').WebSocket} ws
+ * @param {number} readyState
  * @returns {boolean}
  */
-function isEstablished (ws) {
+function isEstablished (readyState) {
   // If the server's response is validated as provided for above, it is
   // said that _The WebSocket Connection is Established_ and that the
   // WebSocket Connection is in the OPEN state.
-  return ws[kReadyState] === states.OPEN
+  return readyState === states.OPEN
 }
 
 /**
- * @param {import('./websocket').WebSocket} ws
+ * @param {number} readyState
  * @returns {boolean}
  */
-function isClosing (ws) {
+function isClosing (readyState) {
   // Upon either sending or receiving a Close control frame, it is said
   // that _The WebSocket Closing Handshake is Started_ and that the
   // WebSocket connection is in the CLOSING state.
-  return ws[kReadyState] === states.CLOSING
+  return readyState === states.CLOSING
 }
 
 /**
- * @param {import('./websocket').WebSocket} ws
+ * @param {number} readyState
  * @returns {boolean}
  */
-function isClosed (ws) {
-  return ws[kReadyState] === states.CLOSED
+function isClosed (readyState) {
+  return readyState === states.CLOSED
 }
 
 /**
@@ -73,49 +69,12 @@ function fireEvent (e, target, eventFactory = (type, init) => new Event(type, in
 
 /**
  * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
- * @param {import('./websocket').WebSocket} ws
+ * @param {import('./websocket').Handler} handler
  * @param {number} type Opcode
  * @param {Buffer} data application data
  */
-function websocketMessageReceived (ws, type, data) {
-  // 1. If ready state is not OPEN (1), then return.
-  if (ws[kReadyState] !== states.OPEN) {
-    return
-  }
-
-  // 2. Let dataForEvent be determined by switching on type and binary type:
-  let dataForEvent
-
-  if (type === opcodes.TEXT) {
-    // -> type indicates that the data is Text
-    //      a new DOMString containing data
-    try {
-      dataForEvent = utf8Decode(data)
-    } catch {
-      failWebsocketConnection(ws, 'Received invalid UTF-8 in text frame.')
-      return
-    }
-  } else if (type === opcodes.BINARY) {
-    if (ws[kBinaryType] === 'blob') {
-      // -> type indicates that the data is Binary and binary type is "blob"
-      //      a new Blob object, created in the relevant Realm of the WebSocket
-      //      object, that represents data as its raw data
-      dataForEvent = new Blob([data])
-    } else {
-      // -> type indicates that the data is Binary and binary type is "arraybuffer"
-      //      a new ArrayBuffer object, created in the relevant Realm of the
-      //      WebSocket object, whose contents are data
-      dataForEvent = toArrayBuffer(data)
-    }
-  }
-
-  // 3. Fire an event named message at the WebSocket object, using MessageEvent,
-  //    with the origin attribute initialized to the serialization of the WebSocket
-  //    object’s url's origin, and the data attribute initialized to dataForEvent.
-  fireEvent('message', ws, createFastMessageEvent, {
-    origin: ws[kWebSocketURL].origin,
-    data: dataForEvent
-  })
+function websocketMessageReceived (handler, type, data) {
+  handler.onMessage(type, data)
 }
 
 function toArrayBuffer (buffer) {
@@ -190,25 +149,11 @@ function isValidStatusCode (code) {
 }
 
 /**
- * @param {import('./websocket').WebSocket} ws
+ * @param {import('./websocket').Handler} handler
  * @param {string|undefined} reason
  */
-function failWebsocketConnection (ws, reason) {
-  const { [kController]: controller, [kResponse]: response } = ws
-
-  controller.abort()
-
-  if (response?.socket && !response.socket.destroyed) {
-    response.socket.destroy()
-  }
-
-  if (reason) {
-    // TODO: process.nextTick
-    fireEvent('error', ws, (type, init) => new ErrorEvent(type, init), {
-      error: new Error(reason),
-      message: reason
-    })
-  }
+function failWebsocketConnection (handler, reason) {
+  handler.onFail(reason)
 }
 
 /**
@@ -310,5 +255,6 @@ module.exports = {
   isTextBinaryFrame,
   isValidOpcode,
   parseExtensions,
-  isValidClientWindowBits
+  isValidClientWindowBits,
+  toArrayBuffer
 }
diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js
index 59de73bfac9..d8230c36ab3 100644
--- a/lib/web/websocket/websocket.js
+++ b/lib/web/websocket/websocket.js
@@ -3,30 +3,45 @@
 const { webidl } = require('../fetch/webidl')
 const { URLSerializer } = require('../fetch/data-url')
 const { environmentSettingsObject } = require('../fetch/util')
-const { staticPropertyDescriptors, states, sentCloseFrameState, sendHints } = require('./constants')
-const {
-  kWebSocketURL,
-  kReadyState,
-  kController,
-  kBinaryType,
-  kResponse,
-  kSentClose,
-  kByteParser
-} = require('./symbols')
+const { staticPropertyDescriptors, states, sentCloseFrameState, sendHints, opcodes, emptyBuffer } = require('./constants')
 const {
   isConnecting,
   isEstablished,
   isClosing,
   isValidSubprotocol,
-  fireEvent
+  fireEvent,
+  failWebsocketConnection,
+  utf8Decode,
+  toArrayBuffer,
+  isClosed
 } = require('./util')
 const { establishWebSocketConnection, closeWebSocketConnection } = require('./connection')
 const { ByteParser } = require('./receiver')
 const { kEnumerableProperty, isBlobLike } = require('../../core/util')
 const { getGlobalDispatcher } = require('../../global')
 const { types } = require('node:util')
-const { ErrorEvent, CloseEvent } = require('./events')
+const { ErrorEvent, CloseEvent, createFastMessageEvent } = require('./events')
 const { SendQueue } = require('./sender')
+const { WebsocketFrameSend } = require('./frame')
+const { channels } = require('../../core/diagnostics')
+
+/**
+ * @typedef {object} Handler
+ * @property {(response: any, extensions?: string[]) => void} onConnectionEstablished
+ * @property {(reason: any) => void} onFail
+ * @property {(opcode: number, data: Buffer) => void} onMessage
+ * @property {(code: number, reason: any, reasonByteLength: number) => void} onClose
+ * @property {(error: Error) => void} onParserError
+ * @property {() => void} onParserDrain
+ * @property {(chunk: Buffer) => void} onSocketData
+ * @property {(err: Error) => void} onSocketError
+ * @property {() => void} onSocketClose
+ *
+ * @property {number} readyState
+ * @property {import('stream').Duplex} socket
+ * @property {number} closeState
+ * @property {boolean} receivedClose
+ */
 
 // https://websockets.spec.whatwg.org/#interface-definition
 class WebSocket extends EventTarget {
@@ -44,6 +59,42 @@ class WebSocket extends EventTarget {
   /** @type {SendQueue} */
   #sendQueue
 
+  /** @type {Handler} */
+  #handler = {
+    onConnectionEstablished: (response, extensions) => this.#onConnectionEstablished(response, extensions),
+    onFail: (reason) => this.#onFail(reason),
+    onMessage: (opcode, data) => this.#onMessage(opcode, data),
+    onClose: (code, reason, reasonByteLength) => this.#onClose(code, reason, reasonByteLength),
+    onParserError: (err) => this.#onParserError(err),
+    onParserDrain: () => this.#onParserDrain(),
+    onSocketData: (chunk) => {
+      if (!this.#parser.write(chunk)) {
+        this.#handler.socket.pause()
+      }
+    },
+    onSocketError: (err) => {
+      this.#handler.readyState = states.CLOSING
+
+      if (channels.socketError.hasSubscribers) {
+        channels.socketError.publish(err)
+      }
+
+      this.#handler.socket.destroy()
+    },
+    onSocketClose: () => this.#onSocketClose(),
+
+    readyState: states.CONNECTING,
+    socket: null,
+    closeState: sentCloseFrameState.NOT_SENT,
+    receivedClose: false
+  }
+
+  #url
+  #controller
+  #binaryType
+  /** @type {import('./receiver').ByteParser} */
+  #parser
+
   /**
    * @param {string} url
    * @param {string|string[]} protocols
@@ -113,7 +164,7 @@ class WebSocket extends EventTarget {
     }
 
     // 10. Set this's url to urlRecord.
-    this[kWebSocketURL] = new URL(urlRecord.href)
+    this.#url = new URL(urlRecord.href)
 
     // 11. Let client be this's relevant settings object.
     const client = environmentSettingsObject.settingsObject
@@ -122,21 +173,20 @@ class WebSocket extends EventTarget {
 
     //    1. Establish a WebSocket connection given urlRecord, protocols,
     //       and client.
-    this[kController] = establishWebSocketConnection(
+    this.#controller = establishWebSocketConnection(
       urlRecord,
       protocols,
       client,
-      this,
-      (response, extensions) => this.#onConnectionEstablished(response, extensions),
+      this.#handler,
       options
     )
 
     // Each WebSocket object has an associated ready state, which is a
     // number representing the state of the connection. Initially it must
     // be CONNECTING (0).
-    this[kReadyState] = WebSocket.CONNECTING
+    this.#handler.readyState = WebSocket.CONNECTING
 
-    this[kSentClose] = sentCloseFrameState.NOT_SENT
+    this.#handler.closeState = sentCloseFrameState.NOT_SENT
 
     // The extensions attribute must initially return the empty string.
 
@@ -144,7 +194,7 @@ class WebSocket extends EventTarget {
 
     // Each WebSocket object has an associated binary type, which is a
     // BinaryType. Initially it must be "blob".
-    this[kBinaryType] = 'blob'
+    this.#binaryType = 'blob'
   }
 
   /**
@@ -192,7 +242,7 @@ class WebSocket extends EventTarget {
     }
 
     // 3. Run the first matching steps from the following list:
-    closeWebSocketConnection(this, code, reason, reasonByteLength)
+    closeWebSocketConnection(this.#handler, code, reason, reasonByteLength)
   }
 
   /**
@@ -209,7 +259,7 @@ class WebSocket extends EventTarget {
 
     // 1. If this's ready state is CONNECTING, then throw an
     //    "InvalidStateError" DOMException.
-    if (isConnecting(this)) {
+    if (isConnecting(this.#handler.readyState)) {
       throw new DOMException('Sent before connected.', 'InvalidStateError')
     }
 
@@ -217,7 +267,7 @@ class WebSocket extends EventTarget {
     // https://datatracker.ietf.org/doc/html/rfc6455#section-6.1
     // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2
 
-    if (!isEstablished(this) || isClosing(this)) {
+    if (!isEstablished(this.#handler.readyState) || isClosing(this.#handler.readyState)) {
       return
     }
 
@@ -297,7 +347,7 @@ class WebSocket extends EventTarget {
     webidl.brandCheck(this, WebSocket)
 
     // The readyState getter steps are to return this's ready state.
-    return this[kReadyState]
+    return this.#handler.readyState
   }
 
   get bufferedAmount () {
@@ -310,7 +360,7 @@ class WebSocket extends EventTarget {
     webidl.brandCheck(this, WebSocket)
 
     // The url getter steps are to return this's url, serialized.
-    return URLSerializer(this[kWebSocketURL])
+    return URLSerializer(this.#url)
   }
 
   get extensions () {
@@ -412,16 +462,16 @@ class WebSocket extends EventTarget {
   get binaryType () {
     webidl.brandCheck(this, WebSocket)
 
-    return this[kBinaryType]
+    return this.#binaryType
   }
 
   set binaryType (type) {
     webidl.brandCheck(this, WebSocket)
 
     if (type !== 'blob' && type !== 'arraybuffer') {
-      this[kBinaryType] = 'blob'
+      this.#binaryType = 'blob'
     } else {
-      this[kBinaryType] = type
+      this.#binaryType = type
     }
   }
 
@@ -431,19 +481,17 @@ class WebSocket extends EventTarget {
   #onConnectionEstablished (response, parsedExtensions) {
     // processResponse is called when the "response’s header list has been received and initialized."
     // once this happens, the connection is open
-    this[kResponse] = response
-
-    const parser = new ByteParser(this, parsedExtensions)
-    parser.on('drain', onParserDrain)
-    parser.on('error', onParserError.bind(this))
+    this.#handler.socket = response.socket
 
-    response.socket.ws = this
-    this[kByteParser] = parser
+    const parser = new ByteParser(this.#handler, parsedExtensions)
+    parser.on('drain', () => this.#handler.onParserDrain())
+    parser.on('error', (err) => this.#handler.onParserError(err))
 
+    this.#parser = parser
     this.#sendQueue = new SendQueue(response.socket)
 
     // 1. Change the ready state to OPEN (1).
-    this[kReadyState] = states.OPEN
+    this.#handler.readyState = states.OPEN
 
     // 2. Change the extensions attribute’s value to the extensions in use, if
     //    it is not the null value.
@@ -466,6 +514,200 @@ class WebSocket extends EventTarget {
     // 4. Fire an event named open at the WebSocket object.
     fireEvent('open', this)
   }
+
+  #onFail (reason) {
+    this.#controller.abort()
+
+    if (this.#handler.socket && !this.#handler.socket.destroyed) {
+      this.#handler.socket.destroy()
+    }
+
+    if (reason) {
+      // TODO: process.nextTick
+      fireEvent('error', this, (type, init) => new ErrorEvent(type, init), {
+        error: new Error(reason),
+        message: reason
+      })
+    }
+  }
+
+  #onMessage (type, data) {
+    // 1. If ready state is not OPEN (1), then return.
+    if (this.#handler.readyState !== states.OPEN) {
+      return
+    }
+
+    // 2. Let dataForEvent be determined by switching on type and binary type:
+    let dataForEvent
+
+    if (type === opcodes.TEXT) {
+      // -> type indicates that the data is Text
+      //      a new DOMString containing data
+      try {
+        dataForEvent = utf8Decode(data)
+      } catch {
+        failWebsocketConnection(this.#handler, 'Received invalid UTF-8 in text frame.')
+        return
+      }
+    } else if (type === opcodes.BINARY) {
+      if (this.#binaryType === 'blob') {
+        // -> type indicates that the data is Binary and binary type is "blob"
+        //      a new Blob object, created in the relevant Realm of the WebSocket
+        //      object, that represents data as its raw data
+        dataForEvent = new Blob([data])
+      } else {
+        // -> type indicates that the data is Binary and binary type is "arraybuffer"
+        //      a new ArrayBuffer object, created in the relevant Realm of the
+        //      WebSocket object, whose contents are data
+        dataForEvent = toArrayBuffer(data)
+      }
+    }
+
+    // 3. Fire an event named message at the WebSocket object, using MessageEvent,
+    //    with the origin attribute initialized to the serialization of the WebSocket
+    //    object’s url's origin, and the data attribute initialized to dataForEvent.
+    fireEvent('message', this, createFastMessageEvent, {
+      origin: this.#url.origin,
+      data: dataForEvent
+    })
+  }
+
+  #onClose (code, reason, reasonByteLength) {
+    if (isClosing(this.#handler.readyState) || isClosed(this.#handler.readyState)) {
+      // If this's ready state is CLOSING (2) or CLOSED (3)
+      // Do nothing.
+    } else if (!isEstablished(this.#handler.readyState)) {
+      // If the WebSocket connection is not yet established
+      // Fail the WebSocket connection and set this's ready state
+      // to CLOSING (2).
+      failWebsocketConnection(this.#handler, 'Connection was closed before it was established.')
+      this.#handler.readyState = states.CLOSING
+    } else if (this.#handler.closeState === sentCloseFrameState.NOT_SENT) {
+      // If the WebSocket closing handshake has not yet been started
+      // Start the WebSocket closing handshake and set this's ready
+      // state to CLOSING (2).
+      // - If neither code nor reason is present, the WebSocket Close
+      //   message must not have a body.
+      // - If code is present, then the status code to use in the
+      //   WebSocket Close message must be the integer given by code.
+      // - If reason is also present, then reasonBytes must be
+      //   provided in the Close message after the status code.
+
+      this.#handler.closeState = sentCloseFrameState.PROCESSING
+
+      const frame = new WebsocketFrameSend()
+
+      // If neither code nor reason is present, the WebSocket Close
+      // message must not have a body.
+
+      // If code is present, then the status code to use in the
+      // WebSocket Close message must be the integer given by code.
+      if (code !== undefined && reason === undefined) {
+        frame.frameData = Buffer.allocUnsafe(2)
+        frame.frameData.writeUInt16BE(code, 0)
+      } else if (code !== undefined && reason !== undefined) {
+        // If reason is also present, then reasonBytes must be
+        // provided in the Close message after the status code.
+        frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength)
+        frame.frameData.writeUInt16BE(code, 0)
+        // the body MAY contain UTF-8-encoded data with value /reason/
+        frame.frameData.write(reason, 2, 'utf-8')
+      } else {
+        frame.frameData = emptyBuffer
+      }
+
+      this.#handler.socket.write(frame.createFrame(opcodes.CLOSE))
+
+      this.#handler.closeState = sentCloseFrameState.SENT
+
+      // Upon either sending or receiving a Close control frame, it is said
+      // that _The WebSocket Closing Handshake is Started_ and that the
+      // WebSocket connection is in the CLOSING state.
+      this.#handler.readyState = states.CLOSING
+    } else {
+      // Otherwise
+      // Set this's ready state to CLOSING (2).
+      this.#handler.readyState = states.CLOSING
+    }
+  }
+
+  #onParserError (err) {
+    let message
+    let code
+
+    if (err instanceof CloseEvent) {
+      message = err.reason
+      code = err.code
+    } else {
+      message = err.message
+    }
+
+    fireEvent('error', this, () => new ErrorEvent('error', { error: err, message }))
+
+    closeWebSocketConnection(this.#handler, code)
+  }
+
+  #onParserDrain () {
+    this.#handler.socket.resume()
+  }
+
+  /**
+   * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol
+   * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4
+   */
+  #onSocketClose () {
+    // If the TCP connection was closed after the
+    // WebSocket closing handshake was completed, the WebSocket connection
+    // is said to have been closed _cleanly_.
+    const wasClean = this.#handler.closeState === sentCloseFrameState.SENT && this.#handler.receivedClose
+
+    let code = 1005
+    let reason = ''
+
+    const result = this.#parser.closingInfo
+
+    if (result && !result.error) {
+      code = result.code ?? 1005
+      reason = result.reason
+    } else if (!this.#handler.receivedClose) {
+      // If _The WebSocket
+      // Connection is Closed_ and no Close control frame was received by the
+      // endpoint (such as could occur if the underlying transport connection
+      // is lost), _The WebSocket Connection Close Code_ is considered to be
+      // 1006.
+      code = 1006
+    }
+
+    // 1. Change the ready state to CLOSED (3).
+    this.#handler.readyState = states.CLOSED
+
+    // 2. If the user agent was required to fail the WebSocket
+    //    connection, or if the WebSocket connection was closed
+    //    after being flagged as full, fire an event named error
+    //    at the WebSocket object.
+    // TODO
+
+    // 3. Fire an event named close at the WebSocket object,
+    //    using CloseEvent, with the wasClean attribute
+    //    initialized to true if the connection closed cleanly
+    //    and false otherwise, the code attribute initialized to
+    //    the WebSocket connection close code, and the reason
+    //    attribute initialized to the result of applying UTF-8
+    //    decode without BOM to the WebSocket connection close
+    //    reason.
+    // TODO: process.nextTick
+    fireEvent('close', this, (type, init) => new CloseEvent(type, init), {
+      wasClean, code, reason
+    })
+
+    if (channels.close.hasSubscribers) {
+      channels.close.publish({
+        websocket: this,
+        code,
+        reason
+      })
+    }
+  }
 }
 
 // https://websockets.spec.whatwg.org/#dom-websocket-connecting
@@ -561,26 +803,6 @@ webidl.converters.WebSocketSendData = function (V) {
   return webidl.converters.USVString(V)
 }
 
-function onParserDrain () {
-  this.ws[kResponse].socket.resume()
-}
-
-function onParserError (err) {
-  let message
-  let code
-
-  if (err instanceof CloseEvent) {
-    message = err.reason
-    code = err.code
-  } else {
-    message = err.message
-  }
-
-  fireEvent('error', this, () => new ErrorEvent('error', { error: err, message }))
-
-  closeWebSocketConnection(this, code)
-}
-
 module.exports = {
   WebSocket
 }
