diff --git a/cmp_test.go b/assert_test.go
similarity index 55%
rename from cmp_test.go
rename to assert_test.go
index ad4cd75a07fa2b61b49e101b938f00f1bcfa4a70..2f05337e5cc220d540a87be6aa8cad67d4c37c68 100644
--- a/cmp_test.go
+++ b/assert_test.go
@@ -1,9 +1,14 @@
 package websocket_test
 
 import (
+	"context"
+	"fmt"
 	"reflect"
 
 	"github.com/google/go-cmp/cmp"
+
+	"nhooyr.io/websocket"
+	"nhooyr.io/websocket/wsjson"
 )
 
 // https://github.com/google/go-cmp/issues/40#issuecomment-328615283
@@ -51,3 +56,44 @@ func structTypes(v reflect.Value, m map[reflect.Type]struct{}) {
 		}
 	}
 }
+
+func assertEqualf(exp, act interface{}, f string, v ...interface{}) error {
+	if diff := cmpDiff(exp, act); diff != "" {
+		return fmt.Errorf(f+": %v", append(v, diff)...)
+	}
+	return nil
+}
+
+func assertJSONEcho(ctx context.Context, c *websocket.Conn, n int) error {
+	exp := randString(n)
+	err := wsjson.Write(ctx, c, exp)
+	if err != nil {
+		return err
+	}
+
+	var act interface{}
+	err = wsjson.Read(ctx, c, &act)
+	if err != nil {
+		return err
+	}
+
+	return assertEqualf(exp, act, "unexpected JSON")
+}
+
+func assertJSONRead(ctx context.Context, c *websocket.Conn, exp interface{}) error {
+	var act interface{}
+	err := wsjson.Read(ctx, c, &act)
+	if err != nil {
+		return err
+	}
+
+	return assertEqualf(exp, act, "unexpected JSON")
+}
+
+func randBytes(n int) []byte {
+	return make([]byte, n)
+}
+
+func randString(n int) string {
+	return string(randBytes(n))
+}
diff --git a/ci/run.sh b/ci/run.sh
index 9e47d29162a605692a31a85c8e07a8836b1d9dd6..1e386ff139bc4c850b52fe7e7572a4d56c691b43 100755
--- a/ci/run.sh
+++ b/ci/run.sh
@@ -6,7 +6,14 @@ set -euo pipefail
 cd "$(dirname "${0}")"
 cd "$(git rev-parse --show-toplevel)"
 
+echo "--- fmt"
 ./ci/fmt.sh
+
+echo "--- lint"
 ./ci/lint.sh
+
+echo "--- test"
 ./ci/test.sh
+
+echo "--- wasm"
 ./ci/wasm.sh
diff --git a/ci/test.sh b/ci/test.sh
index 81d6f462a70e22aba61e55c291b91066fa8ab225..1f5b5102bda6001d322a9bbfc6b819ccfa84e72e 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -16,9 +16,13 @@ if [[ ${CI-} ]]; then
   )
 fi
 
-argv+=(
-  "$@"
-)
+if [[ $# -gt 0 ]]; then
+  argv+=(
+    "$@"
+  )
+else
+  argv+=(./...)
+fi
 
 mkdir -p ci/out/websocket
 "${argv[@]}"
diff --git a/ci/wasm.sh b/ci/wasm.sh
index 9894fca69f29ba5f9239a592cdcb2e91d90d2a64..2870365f63adaa311852252a63b18fb604fd3504 100755
--- a/ci/wasm.sh
+++ b/ci/wasm.sh
@@ -4,7 +4,13 @@ set -euo pipefail
 cd "$(dirname "${0}")"
 cd "$(git rev-parse --show-toplevel)"
 
+stdout="$(mktemp -d)/stdout"
+mkfifo "$stdout"
+go run ./internal/wsecho/cmd > "$stdout" &
+
+WS_ECHO_SERVER_URL="$(head -n 1 "$stdout")"
+
 GOOS=js GOARCH=wasm go vet ./...
 go install golang.org/x/lint/golint
 GOOS=js GOARCH=wasm golint -set_exit_status ./...
-GOOS=js GOARCH=wasm go test ./...
+GOOS=js GOARCH=wasm go test ./... -args "$WS_ECHO_SERVER_URL"
diff --git a/internal/echoserver/echoserver.go b/internal/echoserver/echoserver.go
deleted file mode 100644
index 905ede2b5f8d73622b7ad8df4ee05b79b5aa9d35..0000000000000000000000000000000000000000
--- a/internal/echoserver/echoserver.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package echoserver
-
-import (
-	"net/http"
-)
-
-// EchoServer provides a streaming WebSocket echo server
-// for use in tests.
-func EchoServer(w http.ResponseWriter, r *http.Request) {
-
-}
diff --git a/internal/wsecho/cmd/main.go b/internal/wsecho/cmd/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..9d9dc82bc6cbe64a60be4e6680f2374d6f85d5fb
--- /dev/null
+++ b/internal/wsecho/cmd/main.go
@@ -0,0 +1,21 @@
+// +build !js
+
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"runtime"
+	"strings"
+
+	"nhooyr.io/websocket/internal/wsecho"
+)
+
+func main() {
+	s := httptest.NewServer(http.HandlerFunc(wsecho.Serve))
+	wsURL := strings.Replace(s.URL, "http", "ws", 1)
+	fmt.Printf("%v\n", wsURL)
+
+	runtime.Goexit()
+}
diff --git a/internal/wsecho/wsecho.go b/internal/wsecho/wsecho.go
new file mode 100644
index 0000000000000000000000000000000000000000..1792d0e0b5bdead6e1905b3a1132fcade8f99c47
--- /dev/null
+++ b/internal/wsecho/wsecho.go
@@ -0,0 +1,73 @@
+// +build !js
+
+package wsecho
+
+import (
+	"context"
+	"io"
+	"log"
+	"net/http"
+	"time"
+
+	"nhooyr.io/websocket"
+)
+
+// Serve provides a streaming WebSocket echo server
+// for use in tests.
+func Serve(w http.ResponseWriter, r *http.Request) {
+	c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+		Subprotocols:       []string{"echo"},
+		InsecureSkipVerify: true,
+	})
+	if err != nil {
+		log.Printf("echo server: failed to accept: %+v", err)
+		return
+	}
+	defer c.Close(websocket.StatusInternalError, "")
+
+	Loop(r.Context(), c)
+}
+
+// Loop echos every msg received from c until an error
+// occurs or the context expires.
+// The read limit is set to 1 << 40.
+func Loop(ctx context.Context, c *websocket.Conn) {
+	defer c.Close(websocket.StatusInternalError, "")
+
+	c.SetReadLimit(1 << 40)
+
+	ctx, cancel := context.WithTimeout(ctx, time.Minute)
+	defer cancel()
+
+	b := make([]byte, 32768)
+	echo := func() error {
+		typ, r, err := c.Reader(ctx)
+		if err != nil {
+			return err
+		}
+
+		w, err := c.Writer(ctx, typ)
+		if err != nil {
+			return err
+		}
+
+		_, err = io.CopyBuffer(w, r, b)
+		if err != nil {
+			return err
+		}
+
+		err = w.Close()
+		if err != nil {
+			return err
+		}
+
+		return nil
+	}
+
+	for {
+		err := echo()
+		if err != nil {
+			return
+		}
+	}
+}
diff --git a/websocket_autobahn_python_test.go b/websocket_autobahn_python_test.go
index 4e8b588e6a567e3f31c8b1270accc3c30b6f342e..62aa3f8e1ee26446735faf6ea45d8fa5642b55ea 100644
--- a/websocket_autobahn_python_test.go
+++ b/websocket_autobahn_python_test.go
@@ -20,6 +20,8 @@ import (
 	"strings"
 	"testing"
 	"time"
+
+	"nhooyr.io/websocket/internal/wsecho"
 )
 
 // https://github.com/crossbario/autobahn-python/tree/master/wstest
@@ -34,7 +36,7 @@ func TestPythonAutobahnServer(t *testing.T) {
 			t.Logf("server handshake failed: %+v", err)
 			return
 		}
-		echoLoop(r.Context(), c)
+		wsecho.Loop(r.Context(), c)
 	}))
 	defer s.Close()
 
@@ -186,7 +188,7 @@ func TestPythonAutobahnClientOld(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			echoLoop(ctx, c)
+			wsecho.Loop(ctx, c)
 		}()
 	}
 
diff --git a/websocket_bench_test.go b/websocket_bench_test.go
index 9598e87339af996106355cbeb34dd6bc78eadb66..ff2fd70416da5243cf90fb68678e914e2b645836 100644
--- a/websocket_bench_test.go
+++ b/websocket_bench_test.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"nhooyr.io/websocket"
+	"nhooyr.io/websocket/internal/wsecho"
 )
 
 func BenchmarkConn(b *testing.B) {
@@ -54,7 +55,7 @@ func benchConn(b *testing.B, echo, stream bool, size int) {
 			return err
 		}
 		if echo {
-			echoLoop(r.Context(), c)
+			wsecho.Loop(r.Context(), c)
 		} else {
 			discardLoop(r.Context(), c)
 		}
diff --git a/websocket_js.go b/websocket_js.go
index aab104945d2b4d4e9de7bd0bc5f75a0a4b45abe5..a83dc87219336f451d4e584e2cca71eb65cbefa3 100644
--- a/websocket_js.go
+++ b/websocket_js.go
@@ -59,7 +59,7 @@ func (c *Conn) init() {
 	})
 
 	runtime.SetFinalizer(c, func(c *Conn) {
-		c.ws.Close(int(StatusInternalError), "internal error")
+		c.ws.Close(int(StatusInternalError), "")
 		c.close(errors.New("connection garbage collected"))
 	})
 }
@@ -133,12 +133,10 @@ func (c *Conn) Close(code StatusCode, reason string) error {
 		return fmt.Errorf("already closed: %w", c.closeErr)
 	}
 
-	cerr := CloseError{
+	err := fmt.Errorf("sent close frame: %v", CloseError{
 		Code:   code,
 		Reason: reason,
-	}
-
-	err := fmt.Errorf("sent close frame: %v", cerr)
+	})
 
 	err2 := c.ws.Close(int(code), reason)
 	if err2 != nil {
@@ -146,7 +144,7 @@ func (c *Conn) Close(code StatusCode, reason string) error {
 	}
 	c.close(err)
 
-	if !xerrors.Is(c.closeErr, cerr) {
+	if !xerrors.Is(c.closeErr, err) {
 		return xerrors.Errorf("failed to close websocket: %w", err)
 	}
 
diff --git a/websocket_js_test.go b/websocket_js_test.go
index 332c962815ff5c23850a4df4c5631a362e53ea7d..56058cee49d6e12e55a3586087cde5a1742e5aa8 100644
--- a/websocket_js_test.go
+++ b/websocket_js_test.go
@@ -2,19 +2,40 @@ package websocket_test
 
 import (
 	"context"
+	"net/http"
+	"os"
 	"testing"
 	"time"
 
 	"nhooyr.io/websocket"
 )
 
+var wsEchoServerURL = os.Args[1]
+
 func TestWebSocket(t *testing.T) {
 	t.Parallel()
 
-	_, _, err := websocket.Dial(context.Background(), "ws://localhost:8081", nil)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+	defer cancel()
+
+	c, resp, err := websocket.Dial(ctx, wsEchoServerURL, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
+	defer c.Close(websocket.StatusInternalError, "")
 
-	time.Sleep(time.Second)
+	err = assertEqualf(&http.Response{}, resp, "unexpected http response")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = assertJSONEcho(ctx, c, 4096)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = c.Close(websocket.StatusNormalClosure, "")
+	if err != nil {
+		t.Fatal(err)
+	}
 }
diff --git a/websocket_test.go b/websocket_test.go
index eedef845d3ca9b12e065522fd0f5b11865df07d4..838eb8e7c64c4750519609913e5de8c66a8c0ebb 100644
--- a/websocket_test.go
+++ b/websocket_test.go
@@ -29,6 +29,7 @@ import (
 	"go.uber.org/multierr"
 
 	"nhooyr.io/websocket"
+	"nhooyr.io/websocket/internal/wsecho"
 	"nhooyr.io/websocket/wsjson"
 	"nhooyr.io/websocket/wspb"
 )
@@ -966,7 +967,7 @@ func TestAutobahn(t *testing.T) {
 
 				ctx := r.Context()
 				if testingClient {
-					echoLoop(r.Context(), c)
+					wsecho.Loop(r.Context(), c)
 					return nil
 				}
 
@@ -1007,7 +1008,7 @@ func TestAutobahn(t *testing.T) {
 				return
 			}
 
-			echoLoop(ctx, c)
+			wsecho.Loop(ctx, c)
 		}
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
@@ -1849,47 +1850,6 @@ func TestAutobahn(t *testing.T) {
 	})
 }
 
-func echoLoop(ctx context.Context, c *websocket.Conn) {
-	defer c.Close(websocket.StatusInternalError, "")
-
-	c.SetReadLimit(1 << 40)
-
-	ctx, cancel := context.WithTimeout(ctx, time.Minute)
-	defer cancel()
-
-	b := make([]byte, 32768)
-	echo := func() error {
-		typ, r, err := c.Reader(ctx)
-		if err != nil {
-			return err
-		}
-
-		w, err := c.Writer(ctx, typ)
-		if err != nil {
-			return err
-		}
-
-		_, err = io.CopyBuffer(w, r, b)
-		if err != nil {
-			return err
-		}
-
-		err = w.Close()
-		if err != nil {
-			return err
-		}
-
-		return nil
-	}
-
-	for {
-		err := echo()
-		if err != nil {
-			return
-		}
-	}
-}
-
 func assertCloseStatus(err error, code websocket.StatusCode) error {
 	var cerr websocket.CloseError
 	if !errors.As(err, &cerr) {
@@ -1898,24 +1858,6 @@ func assertCloseStatus(err error, code websocket.StatusCode) error {
 	return assertEqualf(code, cerr.Code, "unexpected status code")
 }
 
-func assertJSONRead(ctx context.Context, c *websocket.Conn, exp interface{}) (err error) {
-	var act interface{}
-	err = wsjson.Read(ctx, c, &act)
-	if err != nil {
-		return err
-	}
-
-	return assertEqualf(exp, act, "unexpected JSON")
-}
-
-func randBytes(n int) []byte {
-	return make([]byte, n)
-}
-
-func randString(n int) string {
-	return string(randBytes(n))
-}
-
 func assertEcho(ctx context.Context, c *websocket.Conn, typ websocket.MessageType, n int) error {
 	p := randBytes(n)
 	err := c.Write(ctx, typ, p)
@@ -1949,13 +1891,6 @@ func assertSubprotocol(c *websocket.Conn, exp string) error {
 	return assertEqualf(exp, c.Subprotocol(), "unexpected subprotocol")
 }
 
-func assertEqualf(exp, act interface{}, f string, v ...interface{}) error {
-	if diff := cmpDiff(exp, act); diff != "" {
-		return fmt.Errorf(f+": %v", append(v, diff)...)
-	}
-	return nil
-}
-
 func assertNetConnRead(r io.Reader, exp string) error {
 	act := make([]byte, len(exp))
 	_, err := r.Read(act)
diff --git a/wsjson/wsjson_js.go b/wsjson/wsjson_js.go
index 2e6074ad5270935773d2f0b6a83d04595dbf40cd..5b88ce3ba5e58113e56e19f3967f3319b7c042a4 100644
--- a/wsjson/wsjson_js.go
+++ b/wsjson/wsjson_js.go
@@ -54,5 +54,5 @@ func write(ctx context.Context, c *websocket.Conn, v interface{}) error {
 		return err
 	}
 
-	return c.Write(ctx, websocket.MessageBinary, b)
+	return c.Write(ctx, websocket.MessageText, b)
 }