diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 4e3aec7f4e52a9c84da7bd1c741d52758cade4ac..0000000000000000000000000000000000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-name: Bug report
-about: Report problems and unexpected behaviour
-title: ''
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behaviour, ideally with source code:
-
-1. ...
-2. ...
-3. ...
-
-**Expected behaviour**
-A clear and concise description of what you expected to happen.
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 5d424b505f22e83501f293cd2602ddac5abc33fe..0000000000000000000000000000000000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Feature request
-about: Suggest an idea
-title: ''
-labels: feature
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context about the feature request here.
diff --git a/README.md b/README.md
index 921e8485195a83ca3dbc882c867d44ea94a1cd79..11309bc627652388f1630748763847b6edd9a0d8 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 
 websocket is a minimal and idiomatic WebSocket library for Go.
 
-At minimum Go 1.12 is required as websocket uses a new [feature](https://github.com/golang/go/issues/26937#issuecomment-415855861) in net/http
+Go 1.12 is required as it uses a new [feature](https://github.com/golang/go/issues/26937#issuecomment-415855861) in net/http
 to perform WebSocket handshakes.
 
 This library is not final and the API is subject to change.
@@ -19,13 +19,12 @@ go get nhooyr.io/websocket
 
 ## Features
 
-- Full support of the WebSocket protocol
-- Zero dependencies outside of the stdlib
-- Very minimal and carefully considered API
-- context.Context is first class
-- net/http is used for WebSocket dials and upgrades
+- Minimal yet pragmatic API
+- First class context.Context support
 - Thoroughly tested, fully passes the [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite)
-- All returned errors include detailed context
+- Concurrent writes
+- Zero dependencies outside of the stdlib for the core library
+- JSON and ProtoBuf helpers in the wsjson and wspb subpackages
 
 ## Roadmap
 
@@ -97,45 +96,45 @@ c.Close(websocket.StatusNormalClosure, "")
 
 ## Design considerations
 
-- Minimal API is easier to maintain and for others to learn
+- Minimal API is easier to maintain and learn
 - Context based cancellation is more ergonomic and robust than setting deadlines
-- No pings or pongs because TCP keep alives work fine for HTTP/1.1 and they do not make
+- No ping support because TCP keep alives work fine for HTTP/1.1 and they do not make
   sense with HTTP/2 (see #1)
 - net.Conn is never exposed as WebSocket's over HTTP/2 will not have a net.Conn.
-- Functional options make the API very clean and easy to extend
+- Structures are nicer than functional options, see [google/go-cloud#908](https://github.com/google/go-cloud/issues/908#issuecomment-445034143)
 - Using net/http's Client for dialing means we do not have to reinvent dialing hooks
-  and configurations. Just pass in a custom net/http client if you want custom dialing.
+  and configurations like other WebSocket libraries
 
 ## Comparison
 
 While I believe nhooyr/websocket has a better API than existing libraries, 
 both gorilla/websocket and gobwas/ws were extremely useful in implementing the
-WebSocket protocol correctly so big thanks to the authors of both. In particular,
+WebSocket protocol correctly so **big thanks** to the authors of both. In particular,
 I made sure to go through the issue tracker of gorilla/websocket to make sure
-I implemented details correctly.
+I implemented details correctly and understood how people were using the package
+in production.
 
 ### gorilla/websocket
 
 https://github.com/gorilla/websocket
 
-This package is the community standard but it is very old and over time
-has accumulated cruft. There are many ways to do the same thing and the API
-is not clear. Just compare the godoc of
+This package is the community standard but it is 6 years old and over time
+has accumulated cruft. There are many ways to do the same thing, usage is not clear
+and there are some rough edges. Just compare the godoc of
 [nhooyr/websocket](godoc.org/github.com/nhooyr/websocket) side by side with
 [gorilla/websocket](godoc.org/github.com/gorilla/websocket).
 
 The API for nhooyr/websocket has been designed such that there is only one way to do things
-which makes using it correctly and safely much easier.
-
-In terms of lines of code, this library is around 2000 whereas gorilla/websocket is
-at 7000. So while the API for nhooyr/websocket is simpler, the implementation is also
-significantly simpler and easier to test which reduces the surface are of bugs.
+which makes it easy to use correctly.
 
 Furthermore, nhooyr/websocket has support for newer Go idioms such as context.Context and
 also uses net/http's Client and ResponseWriter directly for WebSocket handshakes.
 gorilla/websocket writes its handshakes directly to a net.Conn which means
 it has to reinvent hooks for TLS and proxying and prevents support of HTTP/2.
 
+Another advantage of nhooyr/websocket is that it supports multiple concurrent writers out
+of the box.
+
 ### x/net/websocket
 
 https://godoc.org/golang.org/x/net/websocket
@@ -149,12 +148,12 @@ See https://github.com/golang/go/issues/18152
 https://github.com/gobwas/ws
 
 This library has an extremely flexible API but that comes at the cost of usability
-and clarity. Its not clear what the best way to do anything is.
+and clarity. 
 
 This library is fantastic in terms of performance. The author put in significant
 effort to ensure its speed and I have applied as many of its optimizations as
-I could into nhooyr/websocket.
+I could into nhooyr/websocket. Definitely check out his fantastic [blog post](https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb) about performant WebSocket servers.
 
 If you want a library that gives you absolute control over everything, this is the library,
-but for most users, the API provided by nhooyr/websocket will definitely fit better as it will
-be just as performant but much easier to use correctly.
+but for most users, the API provided by nhooyr/websocket will fit better as it is just as
+performant but much easier to use correctly and idiomatic.
diff --git a/accept.go b/accept.go
index 4fb98088bef691087e7e3bc42c39585585cea4bf..9cf546f65b1044b1ab21ce4680d874f45c529966 100644
--- a/accept.go
+++ b/accept.go
@@ -34,8 +34,8 @@ type AcceptOptions struct {
 	// The only time you need this is if your javascript is running on a different domain
 	// than your WebSocket server.
 	// Please think carefully about whether you really need this option before you use it.
-	// If you do, remember if you store secure data in cookies, you wil need to verify the
-	// Origin header.
+	// If you do, remember that if you store secure data in cookies, you wil need to verify the
+	// Origin header yourself otherwise you are exposing yourself to a CSRF attack.
 	InsecureSkipVerify bool
 }
 
diff --git a/bench_test.go b/bench_test.go
index e631c61782310d453a2f870e669cf052a8643520..a97341c1e53a746bb0071c4ea3ceca3ee28b0e60 100644
--- a/bench_test.go
+++ b/bench_test.go
@@ -85,14 +85,14 @@ func benchConn(b *testing.B, stream bool) {
 			})
 		}
 
-		// runN(32)
-		// runN(128)
-		// runN(512)
-		// runN(1024)
+		runN(32)
+		runN(128)
+		runN(512)
+		runN(1024)
 		runN(4096)
 		runN(16384)
-		// runN(65536)
-		// runN(131072)
+		runN(65536)
+		runN(131072)
 
 		c.Close(websocket.StatusNormalClosure, "")
 	})
diff --git a/ci/bench/entrypoint.sh b/ci/bench/entrypoint.sh
index 0e32cd4b5ebf1c307e4cd26e3a4ca692741d5c2a..5f7dcf73c1dcc136cbf40b304d0b97c1e8872774 100755
--- a/ci/bench/entrypoint.sh
+++ b/ci/bench/entrypoint.sh
@@ -9,7 +9,7 @@ go test --vet=off --run=^$ -bench=. \
 	-memprofile=profs/mem \
 	-blockprofile=profs/block \
 	-mutexprofile=profs/mutex \
-	./...
+	.
 
 set +x
 echo
diff --git a/ci/test/entrypoint.sh b/ci/test/entrypoint.sh
index d076fe06000c2383c51e591d1d2d8b8574da4385..2a39593fa426ac6c18cb859a0236764dc9e0aec6 100755
--- a/ci/test/entrypoint.sh
+++ b/ci/test/entrypoint.sh
@@ -5,9 +5,11 @@ source ci/lib.sh || exit 1
 mkdir -p profs
 
 set +x
+echo
 echo "this step includes benchmarks for race detection and coverage purposes
 but the numbers will be misleading. please see the bench step for more
 accurate numbers"
+echo
 set -x
 
 go test -race -coverprofile=profs/coverage --vet=off -bench=. ./...
diff --git a/dial.go b/dial.go
index 909990c697aebfc8ca7f476cb9406a04ae73ab63..eee40dd4ee0f6dd90a2c0b39ef97fe226c17f2af 100644
--- a/dial.go
+++ b/dial.go
@@ -22,8 +22,8 @@ type DialOptions struct {
 	// http.Transport does this correctly.
 	HTTPClient *http.Client
 
-	// Header specifies the HTTP headers included in the handshake request.
-	Header http.Header
+	// HTTPHeader specifies the HTTP headers included in the handshake request.
+	HTTPHeader http.Header
 
 	// Subprotocols lists the subprotocols to negotiate with the server.
 	Subprotocols []string
@@ -47,8 +47,11 @@ func dial(ctx context.Context, u string, opts DialOptions) (_ *Conn, _ *http.Res
 	if opts.HTTPClient == nil {
 		opts.HTTPClient = http.DefaultClient
 	}
-	if opts.Header == nil {
-		opts.Header = http.Header{}
+	if opts.HTTPClient.Timeout > 0 {
+		return nil, nil, xerrors.Errorf("please use context for cancellation instead of http.Client.Timeout; see issue nhooyr.io/websocket#67")
+	}
+	if opts.HTTPHeader == nil {
+		opts.HTTPHeader = http.Header{}
 	}
 
 	parsedURL, err := url.Parse(u)
@@ -67,7 +70,7 @@ func dial(ctx context.Context, u string, opts DialOptions) (_ *Conn, _ *http.Res
 
 	req, _ := http.NewRequest("GET", parsedURL.String(), nil)
 	req = req.WithContext(ctx)
-	req.Header = opts.Header
+	req.Header = opts.HTTPHeader
 	req.Header.Set("Connection", "Upgrade")
 	req.Header.Set("Upgrade", "websocket")
 	req.Header.Set("Sec-WebSocket-Version", "13")
diff --git a/doc.go b/doc.go
index 22e6327378945db92ef16c2057a0fabdcebb90ab..9c5a077d513531637af252419eb5f3f250146b3a 100644
--- a/doc.go
+++ b/doc.go
@@ -2,8 +2,14 @@
 //
 // See https://tools.ietf.org/html/rfc6455
 //
+// Please see https://nhooyr.io/websocket for overview docs and a
+// comparison with existing implementations.
+//
+// Conn, Dial, and Accept are the main entrypoints into this package. Use Dial to dial
+// a WebSocket server, Accept to accept a WebSocket client dial and then Conn to interact
+// with the resulting WebSocket connections.
+//
 // The echo example is the best way to understand how to correctly use the library.
 //
-// Please see https://nhooyr.io/websocket for detailed design docs and a comparison with existing
-// libraries.
+// The wsjson and wspb packages contain helpers for JSON and ProtoBuf messages.
 package websocket
diff --git a/docs/CONTRIBUTING.md b/docs/contributing.md
similarity index 84%
rename from docs/CONTRIBUTING.md
rename to docs/contributing.md
index b33e722d2767ee517d5be755c2b66991f1c05a5f..3f267ef2985f2ed4fe333a460057db556dcde8aa 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/contributing.md
@@ -1,4 +1,10 @@
-# Contributing Guidelines
+# Contributing
+
+## Issues
+
+Please be as descriptive as possible with your description.
+
+## Pull requests
 
 Please split up changes into several small descriptive commits.
 
diff --git a/example_echo_test.go b/example_echo_test.go
index a196affb7d94de346813fa19fc023943a4d79717..f424eef3d23beacd375de1ae0c96e4e4fb447fb5 100644
--- a/example_echo_test.go
+++ b/example_echo_test.go
@@ -2,10 +2,8 @@ package websocket_test
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"log"
 	"net"
 	"net/http"
@@ -15,9 +13,16 @@ import (
 	"golang.org/x/xerrors"
 
 	"nhooyr.io/websocket"
+	"nhooyr.io/websocket/wsjson"
 )
 
+// Example_echo starts a WebSocket echo server and
+// then dials the server and sends 5 different messages
+// and prints out the server's responses.
 func Example_echo() {
+	// First we listen on port 0, that means the OS will
+	// assign us a random free port. This is the listener
+	// the server will serve on and the client will connect to.
 	l, err := net.Listen("tcp", "localhost:0")
 	if err != nil {
 		log.Fatalf("failed to listen: %v", err)
@@ -37,6 +42,7 @@ func Example_echo() {
 	}
 	defer s.Close()
 
+	// This starts the echo server on the listener.
 	go func() {
 		err := s.Serve(l)
 		if err != http.ErrServerClosed {
@@ -44,19 +50,23 @@ func Example_echo() {
 		}
 	}()
 
+	// Now we dial the server and send the messages.
 	err = client("ws://" + l.Addr().String())
 	if err != nil {
 		log.Fatalf("client failed: %v", err)
 	}
 
 	// Output:
-	// {"i":0}
-	// {"i":1}
-	// {"i":2}
-	// {"i":3}
-	// {"i":4}
+	// 0
+	// 1
+	// 2
+	// 3
+	// 4
 }
 
+// echoServer is the WebSocket echo server implementation.
+// It ensures the client speaks the echo subprotocol and
+// only allows one message every 100ms with a 10 message burst.
 func echoServer(w http.ResponseWriter, r *http.Request) error {
 	c, err := websocket.Accept(w, r, websocket.AcceptOptions{
 		Subprotocols: []string{"echo"},
@@ -82,6 +92,10 @@ func echoServer(w http.ResponseWriter, r *http.Request) error {
 	}
 }
 
+// echo reads from the websocket connection and then writes
+// the received message back to it.
+// It only waits 1 minute to read and write the message and
+// limits the received message to 32768 bytes.
 func echo(ctx context.Context, c *websocket.Conn, l *rate.Limiter) error {
 	ctx, cancel := context.WithTimeout(ctx, time.Minute)
 	defer cancel()
@@ -111,6 +125,9 @@ func echo(ctx context.Context, c *websocket.Conn, l *rate.Limiter) error {
 	return err
 }
 
+// client dials the WebSocket echo server at the given url.
+// It then sends it 5 different messages and echo's the server's
+// response to each.
 func client(url string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
 	defer cancel()
@@ -124,39 +141,20 @@ func client(url string) error {
 	defer c.Close(websocket.StatusInternalError, "")
 
 	for i := 0; i < 5; i++ {
-		w, err := c.Writer(ctx, websocket.MessageText)
-		if err != nil {
-			return err
-		}
-
-		e := json.NewEncoder(w)
-		err = e.Encode(map[string]int{
+		err = wsjson.Write(ctx, c, map[string]int{
 			"i": i,
 		})
 		if err != nil {
 			return err
 		}
 
-		err = w.Close()
-		if err != nil {
-			return err
-		}
-
-		typ, r, err := c.Reader(ctx)
-		if err != nil {
-			return err
-		}
-
-		if typ != websocket.MessageText {
-			return xerrors.Errorf("expected text message but got %v", typ)
-		}
-
-		msg2, err := ioutil.ReadAll(r)
+		v := map[string]int{}
+		err = wsjson.Read(ctx, c, &v)
 		if err != nil {
 			return err
 		}
 
-		fmt.Printf("%s", msg2)
+		fmt.Printf("%v\n", v["i"])
 	}
 
 	c.Close(websocket.StatusNormalClosure, "")
diff --git a/go.mod b/go.mod
index 928137e48e52634b1a94aa1ddb0e056a55ac72fb..f39fe6fddf8b152f2922be76899afba941b51adc 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module nhooyr.io/websocket
 go 1.12
 
 require (
+	github.com/golang/protobuf v1.3.1
 	github.com/google/go-cmp v0.2.0
 	github.com/kr/pretty v0.1.0 // indirect
 	go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16
diff --git a/go.sum b/go.sum
index 0e10a2c0e7ee601636cba6f970ed173318b21656..3d455a2c4b57920e6462c2a7a423955127e2cf1c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
diff --git a/mask.go b/mask.go
deleted file mode 100644
index 6c67f1c0a4c384f14c0d7746be82c8b8a35523fe..0000000000000000000000000000000000000000
--- a/mask.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package websocket
-
-// mask applies the WebSocket masking algorithm to p
-// with the given key where the first 3 bits of pos
-// are the starting position in the key.
-// See https://tools.ietf.org/html/rfc6455#section-5.3
-//
-// The returned value is the position of the next byte
-// to be used for masking in the key. This is so that
-// unmasking can be performed without the entire frame.
-func mask(key [4]byte, pos int, p []byte) int {
-	for i := range p {
-		p[i] ^= key[pos&3]
-		pos++
-	}
-	return pos & 3
-}
diff --git a/websocket.go b/websocket.go
index 0c3c3eef9f68e15ea1dbf14d6d3444505495c43d..2f324d3acb3cbe8cc67528981729aa6379050524 100644
--- a/websocket.go
+++ b/websocket.go
@@ -231,7 +231,7 @@ func (c *Conn) handleControl(h header) {
 	}
 
 	if h.masked {
-		mask(h.maskKey, 0, b)
+		xor(h.maskKey, 0, b)
 	}
 
 	switch h.opcode {
@@ -325,7 +325,7 @@ func (c *Conn) dataReadLoop(h header) (err error) {
 			left -= int64(len(b))
 
 			if h.masked {
-				maskPos = mask(h.maskKey, maskPos, b)
+				maskPos = xor(h.maskKey, maskPos, b)
 			}
 
 			// Must set this before we signal the read is done.
@@ -429,6 +429,8 @@ func (c *Conn) writeControl(ctx context.Context, opcode opcode, p []byte) error
 // a WebSocket message of type dataType to the connection.
 // Ensure you close the writer once you have written the entire message.
 // Concurrent calls to Writer are ok.
+// Writer will block if there is another goroutine with an open writer
+// until writer is closed.
 func (c *Conn) Writer(ctx context.Context, typ MessageType) (io.WriteCloser, error) {
 	select {
 	case <-c.closed:
@@ -487,9 +489,12 @@ func (w messageWriter) Close() error {
 // Reader will wait until there is a WebSocket data message to read from the connection.
 // It returns the type of the message and a reader to read it.
 // The passed context will also bound the reader.
+//
 // Your application must keep reading messages for the Conn to automatically respond to ping
 // and close frames and not become stuck waiting for a data message to be read.
-// Please ensure to read the full message from io.Reader.
+// Please ensure to read the full message from io.Reader. If you do not read till
+// io.EOF, the connection will break unless the next read would have yielded io.EOF.
+//
 // You can only read a single message at a time so do not call this method
 // concurrently.
 func (c *Conn) Reader(ctx context.Context) (MessageType, io.Reader, error) {
diff --git a/websocket_test.go b/websocket_test.go
index be03cb01a3e897e6268395ab00cde95b4350b439..2df8c946de05beff7fbc2f2e41d4b34d09a2c679 100644
--- a/websocket_test.go
+++ b/websocket_test.go
@@ -12,16 +12,21 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"reflect"
 	"strconv"
 	"strings"
 	"sync/atomic"
 	"testing"
 	"time"
 
+	"github.com/golang/protobuf/ptypes"
+	"github.com/golang/protobuf/ptypes/duration"
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/xerrors"
 
 	"nhooyr.io/websocket"
+	"nhooyr.io/websocket/wsjson"
+	"nhooyr.io/websocket/wspb"
 )
 
 func TestHandshake(t *testing.T) {
@@ -144,7 +149,7 @@ func TestHandshake(t *testing.T) {
 				h := http.Header{}
 				h.Set("Origin", "http://unauthorized.com")
 				c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{
-					Header: h,
+					HTTPHeader: h,
 				})
 				if err == nil {
 					c.Close(websocket.StatusInternalError, "")
@@ -167,7 +172,7 @@ func TestHandshake(t *testing.T) {
 				h := http.Header{}
 				h.Set("Origin", u)
 				c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{
-					Header: h,
+					HTTPHeader: h,
 				})
 				if err != nil {
 					return err
@@ -192,7 +197,7 @@ func TestHandshake(t *testing.T) {
 				h := http.Header{}
 				h.Set("Origin", "https://example.com")
 				c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{
-					Header: h,
+					HTTPHeader: h,
 				})
 				if err != nil {
 					return err
@@ -201,84 +206,139 @@ func TestHandshake(t *testing.T) {
 				return nil
 			},
 		},
-		// {
-		// 	name: "echo",
-		// 	server: func(w http.ResponseWriter, r *http.Request) error {
-		// 		c, err := websocket.Accept(w, r, websocket.AcceptOptions{})
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		// 		defer c.Close(websocket.StatusInternalError, "")
-		//
-		// 		ctx, cancel := context.WithTimeout(r.Context(), time.Second*5)
-		// 		defer cancel()
-		//
-		// 		write := func() error {
-		// 			jc := websocket.JSONConn{
-		// 				C: c,
-		// 			}
-		//
-		// 			v := map[string]interface{}{
-		// 				"anmol": "wowow",
-		// 			}
-		// 			err = jc.Write(ctx, v)
-		// 			if err != nil {
-		// 				return err
-		// 			}
-		// 			return nil
-		// 		}
-		// 		err = write()
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		// 		err = write()
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		//
-		// 		c.Close(websocket.StatusNormalClosure, "")
-		// 		return nil
-		// 	},
-		// 	client: func(ctx context.Context, u string) error {
-		// 		c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{})
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		// 		defer c.Close(websocket.StatusInternalError, "")
-		//
-		// 		jc := websocket.JSONConn{
-		// 			C: c,
-		// 		}
-		//
-		// 		read := func() error {
-		// 			var v interface{}
-		// 			err = jc.Read(ctx, &v)
-		// 			if err != nil {
-		// 				return err
-		// 			}
-		//
-		// 			exp := map[string]interface{}{
-		// 				"anmol": "wowow",
-		// 			}
-		// 			if !reflect.DeepEqual(exp, v) {
-		// 				return xerrors.Errorf("expected %v but got %v", exp, v)
-		// 			}
-		// 			return nil
-		// 		}
-		// 		err = read()
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		// 		// Read twice to ensure the un EOFed previous reader works correctly.
-		// 		err = read()
-		// 		if err != nil {
-		// 			return err
-		// 		}
-		//
-		// 		c.Close(websocket.StatusNormalClosure, "")
-		// 		return nil
-		// 	},
-		// },
+		{
+			name: "jsonEcho",
+			server: func(w http.ResponseWriter, r *http.Request) error {
+				c, err := websocket.Accept(w, r, websocket.AcceptOptions{})
+				if err != nil {
+					return err
+				}
+				defer c.Close(websocket.StatusInternalError, "")
+
+				ctx, cancel := context.WithTimeout(r.Context(), time.Second*5)
+				defer cancel()
+
+				write := func() error {
+					v := map[string]interface{}{
+						"anmol": "wowow",
+					}
+					err := wsjson.Write(ctx, c, v)
+					return err
+				}
+				err = write()
+				if err != nil {
+					return err
+				}
+				err = write()
+				if err != nil {
+					return err
+				}
+
+				c.Close(websocket.StatusNormalClosure, "")
+				return nil
+			},
+			client: func(ctx context.Context, u string) error {
+				c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{})
+				if err != nil {
+					return err
+				}
+				defer c.Close(websocket.StatusInternalError, "")
+
+				read := func() error {
+					var v interface{}
+					err := wsjson.Read(ctx, c, &v)
+					if err != nil {
+						return err
+					}
+
+					exp := map[string]interface{}{
+						"anmol": "wowow",
+					}
+					if !reflect.DeepEqual(exp, v) {
+						return xerrors.Errorf("expected %v but got %v", exp, v)
+					}
+					return nil
+				}
+				err = read()
+				if err != nil {
+					return err
+				}
+				// Read twice to ensure the un EOFed previous reader works correctly.
+				err = read()
+				if err != nil {
+					return err
+				}
+
+				c.Close(websocket.StatusNormalClosure, "")
+				return nil
+			},
+		},
+		{
+			name: "protobufEcho",
+			server: func(w http.ResponseWriter, r *http.Request) error {
+				c, err := websocket.Accept(w, r, websocket.AcceptOptions{})
+				if err != nil {
+					return err
+				}
+				defer c.Close(websocket.StatusInternalError, "")
+
+				ctx, cancel := context.WithTimeout(r.Context(), time.Second*5)
+				defer cancel()
+
+				write := func() error {
+					err := wspb.Write(ctx, c, ptypes.DurationProto(100))
+					return err
+				}
+				err = write()
+				if err != nil {
+					return err
+				}
+				err = write()
+				if err != nil {
+					return err
+				}
+
+				c.Close(websocket.StatusNormalClosure, "")
+				return nil
+			},
+			client: func(ctx context.Context, u string) error {
+				c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{})
+				if err != nil {
+					return err
+				}
+				defer c.Close(websocket.StatusInternalError, "")
+
+				read := func() error {
+					var v duration.Duration
+					err := wspb.Read(ctx, c, &v)
+					if err != nil {
+						return err
+					}
+
+					d, err := ptypes.Duration(&v)
+					if err != nil {
+						return xerrors.Errorf("failed to convert duration.Duration to time.Duration: %w", err)
+					}
+					const exp = time.Duration(100)
+					if !reflect.DeepEqual(exp, d) {
+						return xerrors.Errorf("expected %v but got %v", exp, d)
+					}
+					return nil
+				}
+				err = read()
+				if err != nil {
+					return err
+				}
+				// Read twice to ensure the un EOFed previous reader works correctly.
+				err = read()
+				if err != nil {
+					return err
+				}
+
+				c.Close(websocket.StatusNormalClosure, "")
+				return nil
+			},
+		},
 		{
 			name: "cookies",
 			server: func(w http.ResponseWriter, r *http.Request) error {
diff --git a/wsjson/wsjson.go b/wsjson/wsjson.go
new file mode 100644
index 0000000000000000000000000000000000000000..df67cf9b3441903e3618a43978bffdc9d857ba99
--- /dev/null
+++ b/wsjson/wsjson.go
@@ -0,0 +1,71 @@
+// Package wsjson provides helpers for JSON messages.
+package wsjson
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+
+	"golang.org/x/xerrors"
+
+	"nhooyr.io/websocket"
+)
+
+// Read reads a json message from c into v.
+// It will read a message up to 32768 bytes in length.
+func Read(ctx context.Context, c *websocket.Conn, v interface{}) error {
+	err := read(ctx, c, v)
+	if err != nil {
+		return xerrors.Errorf("failed to read json: %w", err)
+	}
+	return nil
+}
+
+func read(ctx context.Context, c *websocket.Conn, v interface{}) error {
+	typ, r, err := c.Reader(ctx)
+	if err != nil {
+		return err
+	}
+
+	if typ != websocket.MessageText {
+		return xerrors.Errorf("unexpected frame type for json (expected %v): %v", websocket.MessageText, typ)
+	}
+
+	r = io.LimitReader(r, 32768)
+
+	d := json.NewDecoder(r)
+	err = d.Decode(v)
+	if err != nil {
+		return xerrors.Errorf("failed to decode json: %w", err)
+	}
+
+	return nil
+}
+
+// Write writes the json message v to c.
+func Write(ctx context.Context, c *websocket.Conn, v interface{}) error {
+	err := write(ctx, c, v)
+	if err != nil {
+		return xerrors.Errorf("failed to write json: %w", err)
+	}
+	return nil
+}
+
+func write(ctx context.Context, c *websocket.Conn, v interface{}) error {
+	w, err := c.Writer(ctx, websocket.MessageText)
+	if err != nil {
+		return err
+	}
+
+	e := json.NewEncoder(w)
+	err = e.Encode(v)
+	if err != nil {
+		return xerrors.Errorf("failed to encode json: %w", err)
+	}
+
+	err = w.Close()
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/wspb/wspb.go b/wspb/wspb.go
new file mode 100644
index 0000000000000000000000000000000000000000..159e92d12fac9aa453780b60fb78c24f77dbbf94
--- /dev/null
+++ b/wspb/wspb.go
@@ -0,0 +1,80 @@
+// Package wspb provides helpers for protobuf messages.
+package wspb
+
+import (
+	"context"
+	"io"
+	"io/ioutil"
+
+	"github.com/golang/protobuf/proto"
+	"golang.org/x/xerrors"
+
+	"nhooyr.io/websocket"
+)
+
+// Read reads a protobuf message from c into v.
+// It will read a message up to 32768 bytes in length.
+func Read(ctx context.Context, c *websocket.Conn, v proto.Message) error {
+	err := read(ctx, c, v)
+	if err != nil {
+		return xerrors.Errorf("failed to read protobuf: %w", err)
+	}
+	return nil
+}
+
+func read(ctx context.Context, c *websocket.Conn, v proto.Message) error {
+	typ, r, err := c.Reader(ctx)
+	if err != nil {
+		return err
+	}
+
+	if typ != websocket.MessageBinary {
+		return xerrors.Errorf("unexpected frame type for protobuf (expected %v): %v", websocket.MessageBinary, typ)
+	}
+
+	r = io.LimitReader(r, 32768)
+
+	b, err := ioutil.ReadAll(r)
+	if err != nil {
+		return xerrors.Errorf("failed to read message: %w", err)
+	}
+
+	err = proto.Unmarshal(b, v)
+	if err != nil {
+		return xerrors.Errorf("failed to unmarshal protobuf: %w", err)
+	}
+
+	return nil
+}
+
+// Write writes the protobuf message v to c.
+func Write(ctx context.Context, c *websocket.Conn, v proto.Message) error {
+	err := write(ctx, c, v)
+	if err != nil {
+		return xerrors.Errorf("failed to write protobuf: %w", err)
+	}
+	return nil
+}
+
+func write(ctx context.Context, c *websocket.Conn, v proto.Message) error {
+	b, err := proto.Marshal(v)
+	if err != nil {
+		return xerrors.Errorf("failed to marshal protobuf: %w", err)
+	}
+
+	w, err := c.Writer(ctx, websocket.MessageBinary)
+	if err != nil {
+		return err
+	}
+
+	_, err = w.Write(b)
+	if err != nil {
+		return err
+	}
+
+	err = w.Close()
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/xor.go b/xor.go
new file mode 100644
index 0000000000000000000000000000000000000000..1422f847d737248b82d8247a932af23f708575fc
--- /dev/null
+++ b/xor.go
@@ -0,0 +1,42 @@
+package websocket
+
+import (
+	"encoding/binary"
+)
+
+// xor applies the WebSocket masking algorithm to p
+// with the given key where the first 3 bits of pos
+// are the starting position in the key.
+// See https://tools.ietf.org/html/rfc6455#section-5.3
+//
+// The returned value is the position of the next byte
+// to be used for masking in the key. This is so that
+// unmasking can be performed without the entire frame.
+func xor(key [4]byte, keyPos int, b []byte) int {
+	// If the payload is greater than 16 bytes, then it's worth
+	// masking 8 bytes at a time.
+	// Optimization from https://github.com/golang/go/issues/31586#issuecomment-485530859
+	if len(b) > 16 {
+		// We first create a key that is 8 bytes long
+		// and is aligned on the position correctly.
+		var alignedKey [8]byte
+		for i := range alignedKey {
+			alignedKey[i] = key[(i+keyPos)&3]
+		}
+		k := binary.LittleEndian.Uint64(alignedKey[:])
+
+		// Then we xor until b is less than 8 bytes.
+		for len(b) >= 8 {
+			v := binary.LittleEndian.Uint64(b)
+			binary.LittleEndian.PutUint64(b, v^k)
+			b = b[8:]
+		}
+	}
+
+	// xor remaining bytes.
+	for i := range b {
+		b[i] ^= key[keyPos&3]
+		keyPos++
+	}
+	return keyPos & 3
+}
diff --git a/mask_test.go b/xor_test.go
similarity index 87%
rename from mask_test.go
rename to xor_test.go
index 4a7b8c73c3783bfc074630dd27a47ce64be72c17..f715eda14c3e15d24c5e2992d13e2e9c41ea91ce 100644
--- a/mask_test.go
+++ b/xor_test.go
@@ -6,13 +6,13 @@ import (
 	"github.com/google/go-cmp/cmp"
 )
 
-func Test_mask(t *testing.T) {
+func Test_xor(t *testing.T) {
 	t.Parallel()
 
 	key := [4]byte{0xa, 0xb, 0xc, 0xff}
 	p := []byte{0xa, 0xb, 0xc, 0xf2, 0xc}
 	pos := 0
-	pos = mask(key, pos, p)
+	pos = xor(key, pos, p)
 
 	if exp := []byte{0, 0, 0, 0x0d, 0x6}; !cmp.Equal(exp, p) {
 		t.Fatalf("unexpected mask: %v", cmp.Diff(exp, p))