Does it have something to do with the ws object having event listeners attached to it?
Yes, precisely. As long as the socket connection is open, the runtime (browser engine) should call those event listeners when an event happens. So it needs to hold onto this event listener (or the entire WebSocket instance, as that'll technically become the .target of the event), and this reference is what keeps it from getting garbage-collected. Only when there are no listeners, or no more events (because the socket was closed), the runtime can stop holding onto the instance, since it can be certain it won't be needed any longer.
This works pretty much the same for all asynchronous web APIs, whether it's network (WebSocket, XMLHttpRequest, fetch, RTCPeerConnection, …), timers (setTimeout, setInterval, requestAnimationFrame, …), file system (FileReader, …), or the whole DOM (document) itself. The runtime has a list of "active" objects that serve as the garbage collection roots, to keep your application alive after its initial setup code has been executed.