Quick answer: WebSocketPeer does nothing unless you call poll() every frame, and it is reference-counted: if it lives in a local variable, it is freed (and the connection closed) when the function returns. Store it in a member variable, poll it in _process(), and read get_close_code() / get_close_reason() when it closes.

Godot 4's WebSocketPeer is a low-level object. It does not run itself and it does not keep itself alive, and those two facts explain most connections that "close immediately".

A minimal client that works

extends Node

var socket := WebSocketPeer.new()     # member variable, not local

func _ready():
    var err := socket.connect_to_url("wss://example.com/ws")
    if err != OK:
        push_error("connect_to_url failed: %s" % err)

func _process(_delta):
    socket.poll()                     # required every frame
    match socket.get_ready_state():
        WebSocketPeer.STATE_OPEN:
            while socket.get_available_packet_count() > 0:
                print(socket.get_packet().get_string_from_utf8())
        WebSocketPeer.STATE_CLOSED:
            print("closed: %d %s" % [socket.get_close_code(), socket.get_close_reason()])
            set_process(false)

Cause 1: no poll()

Without poll(), the handshake never progresses, packets are never read, and the server eventually times the client out. Poll in _process() (or _physics_process()), also while connecting.

Cause 2: the peer is a local variable

func connect_server():
    var ws := WebSocketPeer.new()   # freed when this function returns
    ws.connect_to_url(url)

WebSocketPeer is RefCounted. When the last reference goes away, it is freed and the socket closes. Keep it in a member variable or an autoload.

Cause 3: sending before the connection is open

send_text() before STATE_OPEN fails. Queue messages and send them when the state first becomes open.

Cause 4: TLS problems with wss://

wss:// needs a certificate the client trusts. Self-signed certificates on a development server are rejected; for local testing pass TLSOptions.client_unsafe() to connect_to_url() (never in release builds), or use ws:// on localhost.

Cause 5: web exports and mixed content

A game served over https:// can only open wss:// sockets; the browser blocks ws:// from a secure page before Godot sees anything. Check the browser console. CORS does not apply to WebSockets, but the server may still reject unknown Origin headers.

Cause 6: the server closes it

Read the close code. 1000 is a normal close, 1006 means the connection dropped without a close frame (often a proxy or timeout), 1002 or 1003 point at a protocol mismatch. If the server requires a subprotocol, set socket.supported_protocols before connecting. Idle connections behind proxies are commonly cut after 60 seconds; send periodic pings, or set heartbeat_interval where your Godot version supports it.

Related fixes