Skip to content

Networking in the browser

A game's connection to its server on the web: the browser's own WebSocket, run in a dedicated Web Worker so a busy frame can't delay it, with the arrival lag and buffered bytes a client needs for ping timing and backpressure.

module: awake:net:client-browser module: awake:net:api Platform: Web (wasmJs)

awake:net:client-browser opens the browser's own WebSocket, with no HTTP or socket library. It sends binary messages, one per packet, so it speaks to the Ktor server transport (awake:net:server-ktor) unchanged. What it hands back is a DirectTransport from awake:net:api: a Transport whose sends go to the socket on the caller's call stack, and which says how many bytes the socket still holds and how long each packet waited to be handed on.

Connect

Kotlin (wasmJs)
// Connects to the game server, its socket in a Web Worker where the page can run one.
suspend fun connectToServer(): DirectTransport {
    val transport = connectBrowserSocket("wss://play.example.com/net")
    // False when the browser has no Worker, or the page doesn't serve awake-net/socket-worker.js.
    println("socket in a worker: ${transport.runsInWorker}")
    return transport
}

connectBrowserSocket returns once the socket has opened. It fails with BrowserSocketFailed, which carries the close code, when the socket closes before it opens; cancelling it closes the socket.

Then read packets with listen, which hands each one on from the socket's own event, or with incoming(), the Transport channel. Use one or the other.

Serve the worker script

The socket's worker runs awake-net/socket-worker.js, which the page must serve beside index.html. Apply Core's Gradle plugin to your web module and it puts the script there, for the development server and the production build:

build.gradle.kts
plugins {
    id("com.awakekt.awake.plugin.socket-worker") version "<your Core version>"
}

It takes the script from the client-browser-wasm-js klib your app already depends on; keep the plugin on the same version as Core. A module that doesn't depend on the client gets nothing. If you can't apply the plugin, copy the file from the klib by hand:

unzip client-browser-wasm-js-<version>.klib 'awake-net/socket-worker.js' -d src/wasmJsMain/resources

To serve it from somewhere else, pass its URL, relative to the page, as workerScript.

What runs where

Work Socket in a worker (default) Socket on the page
The WebSocket: open, send, receive, close The worker's thread The page's thread
Stamping when a message arrived The worker, as the message event runs Nothing: the packet is handed on from its own event
Reading bufferedAmount The worker, after each send and every 50 ms while it isn't 0 The page, when bufferedBytes is read
Decoding packets, your game's code, the frame The page's thread The page's thread
Copying a packet between Kotlin and JS Once each way, through wasm linear memory; the ArrayBuffer then moves to or from the worker without another copy Once each way, through wasm linear memory

On the page, a socket's events are tasks on the page's one thread, so they run only between frames: while a frame takes 100 ms, a reply that arrived at its start is seen 100 ms late, and the browser lowers bufferedAmount late too. In a worker those events run on the worker's own thread at once, whatever the frame is doing. Your code still runs on the page's thread, so a game keeps decoding there and can still drain its packets once per frame.

Time a ping

A packet from a worker comes with when it reached the socket. While listen hands it on, arrivalLag is how long it then waited for the page's thread, in seconds; take it off now to time the reply's arrival:

Kotlin
// Times a ping on any DirectTransport; clock is the page's clock, in milliseconds.
class Ping(private val transport: DirectTransport, private val clock: () -> Double) {
    private var sentAtMillis = 0.0

    /** The last round trip, in milliseconds. */
    var lastMillis = 0.0
        private set

    fun start() = transport.listen(
        onPacket = { packet ->
            if (packet.contentEquals(PONG)) {
                // When the reply reached the socket: now, less how long it then waited for this thread.
                val arrivedAtMillis = clock() - transport.arrivalLag * 1000.0
                lastMillis = arrivedAtMillis - sentAtMillis
            }
        },
        onClosed = {},
    )

    fun send() {
        sentAtMillis = clock()
        transport.sendNow(PING)
    }
}

arrivalLag is 0 outside listen's callback, and always 0 for a socket on the page, where nothing measures the wait. The worker stamps messages on performance.timeOrigin + performance.now(), and the page reads its clock the same way, because a dedicated worker's performance.now() counts from the worker's own start, not the page's.

Hold back sends on a full socket

bufferedBytes is never less than the socket holds: in a worker, the worker's last report plus the bytes posted to it that it hasn't taken yet. Send-side backpressure can rely on it:

Kotlin
// Sends movement only while the socket isn't holding much, and tries again a frame later.
class Movement(private val transport: DirectTransport) {
    fun send(packet: ByteArray) {
        if (transport.bufferedBytes > MAX_BUFFERED_BYTES) {
            transport.schedule(millis = 16) { send(packet) }
        } else {
            transport.sendNow(packet)
        }
    }

    private companion object {
        const val MAX_BUFFERED_BYTES = 1_024L
    }
}

schedule runs the retry on the page's thread, with setTimeout.

Fallback and opt-out

mode picks where the socket runs:

BrowserSocketMode Where the socket runs
Auto (default) In a worker; on the page when the browser has no Worker or the script doesn't run, after logging why once
Worker In a worker, or fails with SocketWorkerFailed
Page On the page's thread
Kotlin (wasmJs)
// Runs the socket where a launch option says: "page" keeps it on the page's thread.
suspend fun connectWith(option: String?): BrowserSocketTransport = connectBrowserSocket(
    url = "wss://play.example.com/net",
    mode = if (option == "page") BrowserSocketMode.Page else BrowserSocketMode.Auto,
)

runsInWorker says which one a connection got.

Properties

Property Type What it does
bufferedBytes Long Bytes the socket holds that it hasn't sent; 0 once closed.
arrivalLag Double Seconds the packet listen is handing on waited for the page's thread; 0 outside that call.
runsInWorker Boolean Whether the socket runs in a dedicated worker.
closeCode Int? The close event's code once closed; null while open.
closeReason String The close event's reason once closed; empty otherwise.
state StateFlow<ConnectionState> Connected while open, Disconnected once closed.

How it works

The page and the worker exchange messages. The page posts {t: 'open', url} once, then each packet as a bare ArrayBuffer it transfers, then {t: 'close'}. The worker posts {t: 'open'} when the socket opens, {t: 'm', data, at} for each binary message with its buffer transferred, {t: 'b', buffered, taken} for bufferedAmount and the bytes it has taken, and {t: 'x', code, reason, error} when the socket closes. Every packet that came before a close is handed on before it. A text message is not a packet and is ignored.

A connection doesn't reconnect: when it closes, connect again. Answering the server's pings is your protocol's business, so it stays in your code on the page, timed with arrivalLag.

Serve the script as JavaScript

A page that answers a missing file with an HTML page and status 200 still fails the worker, since the browser won't run HTML as a worker script. Auto then falls back to the page and logs "The socket worker awake-net/socket-worker.js could not run". Open the script's URL from the running page to check it: it must be JavaScript.

See also