diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8e63eb17437587fd110c8d47420f96a607fd9fe3..4d889ab59f67c7156d3952d6a31c7eb293e4080e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,7 @@ on: [push]
 jobs:
   fmt:
     runs-on: ubuntu-latest
-    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75
     steps:
       - uses: actions/checkout@v1
         with:
@@ -12,7 +12,7 @@ jobs:
       - run: ./ci/fmt.sh
   lint:
     runs-on: ubuntu-latest
-    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75
     steps:
       - uses: actions/checkout@v1
         with:
@@ -20,7 +20,7 @@ jobs:
       - run: ./ci/lint.sh
   test:
     runs-on: ubuntu-latest
-    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75
     steps:
       - uses: actions/checkout@v1
         with:
@@ -28,3 +28,11 @@ jobs:
       - run: ./ci/test.sh
         env:
           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+  wasm:
+    runs-on: ubuntu-latest
+    container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75
+    steps:
+      - uses: actions/checkout@v1
+        with:
+          fetch-depth: 1
+      - run: ./ci/wasm.sh
diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile
index b59bc3af55dc1a5404870076fd548a55fa151710..8d79215943a8e63db1b8fed425e7eadcf539b62d 100644
--- a/ci/image/Dockerfile
+++ b/ci/image/Dockerfile
@@ -2,18 +2,23 @@ FROM golang:1
 
 ENV DEBIAN_FRONTEND=noninteractive
 ENV GOPATH=/root/gopath
+ENV PATH=$GOPATH/bin:$PATH
 ENV GOFLAGS="-mod=readonly"
 ENV PAGER=cat
 ENV CI=true
 
 RUN apt-get update && \
-  apt-get install -y shellcheck npm && \
+  apt-get install -y shellcheck npm chromium && \
   npm install -g prettier
 
+# https://github.com/golang/go/wiki/WebAssembly#running-tests-in-the-browser
+RUN go get github.com/agnivade/wasmbrowsertest && \
+  mv $GOPATH/bin/wasmbrowsertest $GOPATH/bin/go_js_wasm_exec
+
 RUN git config --global color.ui always
 
-# Cache go modules.
+# Cache go modules and build cache.
 COPY . /tmp/websocket
 RUN cd /tmp/websocket && \
-  go mod download && \
+  CI= ./ci/run.sh && \
   rm -rf /tmp/websocket
diff --git a/ci/run.sh b/ci/run.sh
index 8867b860e1525c20989b61685255d4d74b854b14..9e47d29162a605692a31a85c8e07a8836b1d9dd6 100755
--- a/ci/run.sh
+++ b/ci/run.sh
@@ -9,3 +9,4 @@ cd "$(git rev-parse --show-toplevel)"
 ./ci/fmt.sh
 ./ci/lint.sh
 ./ci/test.sh
+./ci/wasm.sh
diff --git a/ci/wasm.sh b/ci/wasm.sh
new file mode 100755
index 0000000000000000000000000000000000000000..943d380626f6c0ec7d9969aab085236091a93556
--- /dev/null
+++ b/ci/wasm.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+cd "$(dirname "${0}")"
+cd "$(git rev-parse --show-toplevel)"
+
+GOOS=js GOARCH=wasm go vet ./...
+go install golang.org/x/lint/golint
+# Get passing later.
+#GOOS=js GOARCH=wasm golint -set_exit_status ./...
+GOOS=js GOARCH=wasm go test ./internal/wsjs
diff --git a/go.mod b/go.mod
index c247f54a110c33fb337653f13ab5bf1f4e105c46..34a7f872d72e550fd0f6945250462f979bf55100 100644
--- a/go.mod
+++ b/go.mod
@@ -17,11 +17,11 @@ require (
 	go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16
 	go.uber.org/atomic v1.4.0 // indirect
 	go.uber.org/multierr v1.1.0
-	golang.org/x/lint v0.0.0-20190409202823-959b441ac422
+	golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac
 	golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect
 	golang.org/x/sys v0.0.0-20190919044723-0c1ff786ef13 // indirect
 	golang.org/x/time v0.0.0-20190308202827-9d24e82272b4
-	golang.org/x/tools v0.0.0-20190903163617-be0da057c5e3
+	golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
 	gotest.tools/gotestsum v0.3.5
 	mvdan.cc/sh v2.6.4+incompatible
diff --git a/go.sum b/go.sum
index a6a86413d50efae321b6b9d261442519ccc77d52..97d6a8358df28bc7ea0ca370efcdd26e3cca2489 100644
--- a/go.sum
+++ b/go.sum
@@ -70,8 +70,8 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
 golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422 h1:QzoH/1pFpZguR8NrRHLcO6jKqfv2zpuSqZLgdm7ZmjI=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac h1:8R1esu+8QioDxo4E4mX6bFztO+dMTM49DNAaWfO5OeY=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -95,8 +95,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190903163617-be0da057c5e3 h1:1cLrGl9PL64Mzl9NATDCqFE57dVYwWOkoPXvppEnjO4=
-golang.org/x/tools v0.0.0-20190903163617-be0da057c5e3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72 h1:bw9doJza/SFBEweII/rHQh338oozWyiFsBRHtrflcws=
+golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
diff --git a/internal/wsjs/wsjs.go b/internal/wsjs/wsjs.go
new file mode 100644
index 0000000000000000000000000000000000000000..4adb71ad2072a3d0e27616d4d8906d99d4ee97ff
--- /dev/null
+++ b/internal/wsjs/wsjs.go
@@ -0,0 +1,155 @@
+// +build js
+
+// Package wsjs implements typed access to the browser javascript WebSocket API.
+// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
+package wsjs
+
+import (
+	"context"
+	"syscall/js"
+)
+
+func handleJSError(err *error, onErr func()) {
+	r := recover()
+
+	if jsErr, ok := r.(js.Error); ok {
+		*err = jsErr
+
+		if onErr != nil {
+			onErr()
+		}
+		return
+	}
+
+	if r != nil {
+		panic(r)
+	}
+}
+
+func New(ctx context.Context, url string, protocols []string) (c *WebSocket, err error) {
+	defer handleJSError(&err, func() {
+		c = nil
+	})
+
+	jsProtocols := make([]interface{}, len(protocols))
+	for i, p := range protocols {
+		jsProtocols[i] = p
+	}
+
+	c = &WebSocket{
+		v: js.Global().Get("WebSocket").New(url, jsProtocols),
+	}
+
+	c.setBinaryType("arrayBuffer")
+
+	c.Extensions = c.v.Get("extensions").String()
+	c.Protocol = c.v.Get("protocol").String()
+	c.URL = c.v.Get("url").String()
+
+	return c, nil
+}
+
+type WebSocket struct {
+	Extensions string
+	Protocol   string
+	URL        string
+
+	v js.Value
+}
+
+func (c *WebSocket) setBinaryType(typ string) {
+	c.v.Set("binaryType", string(typ))
+}
+
+func (c *WebSocket) BufferedAmount() uint32 {
+	return uint32(c.v.Get("bufferedAmount").Int())
+}
+
+func (c *WebSocket) addEventListener(eventType string, fn func(e js.Value)) {
+	c.v.Call("addEventListener", eventType, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+		fn(args[0])
+		return nil
+	}))
+}
+
+type CloseEvent struct {
+	Code     uint16
+	Reason   string
+	WasClean bool
+}
+
+func (c *WebSocket) OnClose(fn func(CloseEvent)) {
+	c.addEventListener("close", func(e js.Value) {
+		ce := CloseEvent{
+			Code:     uint16(e.Get("code").Int()),
+			Reason:   e.Get("reason").String(),
+			WasClean: e.Get("wasClean").Bool(),
+		}
+		fn(ce)
+	})
+}
+
+func (c *WebSocket) OnError(fn func(e js.Value)) {
+	c.addEventListener("error", fn)
+}
+
+type MessageEvent struct {
+	Data []byte
+	// There are more types to the interface but we don't use them.
+	// See https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent
+}
+
+func (c *WebSocket) OnMessage(fn func(m MessageEvent)) {
+	c.addEventListener("message", func(e js.Value) {
+		var data []byte
+
+		arrayBuffer := e.Get("data")
+		if arrayBuffer.Type() == js.TypeString {
+			data = []byte(arrayBuffer.String())
+		} else {
+			data = extractArrayBuffer(arrayBuffer)
+		}
+
+		me := MessageEvent{
+			Data: data,
+		}
+		fn(me)
+
+		return
+	})
+}
+
+func (c *WebSocket) OnOpen(fn func(e js.Value)) {
+	c.addEventListener("open", fn)
+}
+
+func (c *WebSocket) Close(code int, reason string) (err error) {
+	defer handleJSError(&err, nil)
+	c.v.Call("close", code, reason)
+	return err
+}
+
+func (c *WebSocket) SendText(v string) (err error) {
+	defer handleJSError(&err, nil)
+	c.v.Call("send", v)
+	return err
+}
+
+func (c *WebSocket) SendBytes(v []byte) (err error) {
+	defer handleJSError(&err, nil)
+	c.v.Call("send", uint8Array(v))
+	return err
+}
+
+func extractArrayBuffer(arrayBuffer js.Value) []byte {
+	uint8Array := js.Global().Get("Uint8Array").New(arrayBuffer)
+	dst := make([]byte, uint8Array.Length())
+	js.CopyBytesToGo(dst, uint8Array)
+	return dst
+}
+
+func uint8Array(src []byte) js.Value {
+	uint8Array := js.Global().Get("Uint8Array").New(len(src))
+	js.CopyBytesToJS(uint8Array, src)
+	return uint8Array
+}
diff --git a/internal/wsjs/wsjs_test.go b/internal/wsjs/wsjs_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..4f5f18789845d93665d16a72a8a87d8b4f8a3e02
--- /dev/null
+++ b/internal/wsjs/wsjs_test.go
@@ -0,0 +1,26 @@
+// +build js
+
+package wsjs
+
+import (
+	"context"
+	"syscall/js"
+	"testing"
+	"time"
+)
+
+func TestWebSocket(t *testing.T) {
+	t.Parallel()
+
+	c, err := New(context.Background(), "ws://localhost:8081", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	c.OnError(func(e js.Value) {
+		t.Log(js.Global().Get("JSON").Call("stringify", e))
+		t.Log(c.v.Get("readyState"))
+	})
+
+	time.Sleep(time.Second)
+}