How a WebSocket frame is built and sent

Every WebSocket message travels in frames. Follow one frame byte by byte, with C# code you can test.

On this page

What a frame is

RFC 6455 (opens in a new tab), a standard from the IETF (the Internet Engineering Task Force, which publishes internet standards), defines the WebSocket protocol. In it, the client starts a connection with an HTTP/1.1 request. If the server agrees, it answers with the status code 101 (Switching Protocols). This exchange is the opening handshake. After that, the same TCP connection stays open, and each side can send data at any time. RFC 8441 (opens in a new tab) can also carry WebSocket over HTTP/2. It starts in a different way and uses the same frames.

The data travels as messages, and each message is one or more frames. A frame is a short header followed by the payload, which is the data itself. The header says what the payload holds and how long it is. Figure 1 shows the parts in the order they are sent, with a real frame from the standard below them.

In .NET, the WebSocket class builds frames for you. Knowing the layout still helps when you read a packet capture, which is a recording of network traffic, or test a server byte by byte.

The parts of a WebSocket frameByte 0 holds FIN (1 bit), the three reserved bits RSV1 to RSV3, and the opcode (4 bits). Byte 1 holds the MASK bit and a 7-bit payload length. Then come an extended length of 0, 2 or 8 bytes, a masking key of 0 or 4 bytes, and the payload. Example from RFC 6455: the unmasked text frame "Hello" is 81 05 48 65 6c 6c 6f. The byte 0x81 holds FIN 1, RSV 000 and opcode 0001 (text). The byte 0x05 holds MASK 0 and length 5. There is no extended length and no masking key.byte 0byte 1bytes 2 and upFINRSVopcodeMASKlengthextended lengthmasking keypayload1 bit3 bits4 bits1 bit7 bits0, 2 or 8 bytes0 or 4 bytes0 or more bytesin every frameonly in some framesExample: the text "Hello" sent by a server (RFC 6455, section 5.7)1000000100000101not sentnot sent48 65 6c 6c 6f= 0x81= 0x05length is 5MASK is 0"Hello"
Figure 1 The parts of one frame, in the order they are sent. Below them are the bits of the unmasked "Hello" frame from RFC 6455.

The first two bytes

Every frame starts with two bytes that have the same layout. Figure 1 draws each byte with its most significant bit (the bit with the highest value) on the left, as the standard does.

FIN, RSV and the opcode

FIN is the first bit of byte 0. It is 1 when the frame is the last frame of its message. A message sent in a single frame has FIN set to 1.

The next three bits, RSV1 to RSV3, are reserved for extensions. They stay 0 unless the two sides agreed on an extension during the handshake. For example, the compression extension in RFC 7692 (opens in a new tab) sets RSV1 on the first frame of a compressed message. A receiver that gets a reserved bit set to 1, when no agreed extension uses it, must fail the connection. That means it must close the connection, and it should send a close frame with a status code first (RFC 6455, section 7.1.7 (opens in a new tab)).

The last four bits are the opcode, which says how to read the payload. Four bits allow 16 values. RFC 6455 defines six of them and keeps the other ten for later:

  • 0x0 continuation: the next part of a message that started in an earlier frame
  • 0x1 text: UTF-8 text, and the whole message must be valid UTF-8
  • 0x2 binary: any bytes, and the application decides what they mean
  • 0x8 close: the sender wants to close the connection
  • 0x9 ping and 0xA pong: a ping asks for a pong, and the pong carries back the same payload

An unknown opcode is also a reason to fail the connection. Close, ping and pong are control frames, and the most significant bit of their opcodes is set. A control frame carries 125 bytes of payload or less, and it is never split.

MASK and the payload length

The first bit of byte 1 is MASK. When it is 1, a 4-byte masking key follows the length. Every frame from a client has MASK set to 1, and every frame from a server has it set to 0.

The other seven bits hold the payload length, a number from 0 to 127. Values up to 125 are the real length. The values 126 and 127 mean that a longer length follows.

Longer payloads

When the 7-bit field holds 126, the next 2 bytes hold the length as an unsigned 16-bit number. When it holds 127, the next 8 bytes hold a 64-bit number, and its most significant bit must be 0. Both forms use network byte order, also called big endian. The most significant byte goes first, so a length of 256 is sent as 01 00.

The length counts only the payload, so the header and the masking key are not part of it. In the table, each row is one length form with its header size. The code later in this post prints the same sizes.

Payload sizeLength fieldExtra length bytesHeader from a serverHeader from a client
0 to 125 bytesthe size itself02 bytes6 bytes
126 to 65,535 bytes12624 bytes8 bytes
65,536 bytes or more127810 bytes14 bytes
Table 1 How the length field sets the header size. A client header also holds the 4-byte masking key.

The standard also asks for the shortest form that fits:

the minimal number of bytes MUST be used to encode the length

RFC 6455, section 5.2

So a payload of 124 bytes must use the 7-bit field. The bytes 126, 0, 124 are not a valid way to send that length.

Messages in several frames

A sender may split one message into several frames. This is called fragmentation. It helps when you do not know the full size of a message at the start. You fill a buffer, send it as a frame, and go on.

  • The first frame has the real opcode, text or binary, and FIN set to 0.
  • Each middle frame has the opcode 0x0 (continuation) and FIN set to 0.
  • The last frame has the opcode 0x0 and FIN set to 1.

RFC 6455 shows "Hello" sent as two frames. The frame 01 03 48 65 6c carries "Hel", and 80 02 6c 6f carries "lo". In 0x01, FIN is 0 and the opcode is text. In 0x80, FIN is 1 and the opcode is continuation.

Control frames may come between the frames of a split message, so a ping does not wait behind a long message. When no extension is in use, a proxy or another intermediary on the way may also join or split frames. So the receiver must treat the whole message as the unit of data and must not depend on where a frame ends.

Write a frame in C#

The method below writes one frame into a buffer and returns its size. The buffer is a Span<byte>, a view over memory that copies nothing, and I explain spans in Fewer allocations with Span<T> in .NET. It sets FIN and the opcode, then writes the length in the shortest form. With a key, it also masks the payload. BinaryPrimitives, in the System.Buffers.Binary namespace, writes the long lengths in big endian order.

C#
// Writes one frame into dest and returns the number of bytes written.
// A client passes a fresh 4 byte maskKey. A server passes no key.
static int WriteFrame(Span<byte> dest, byte opcode, bool fin,
    ReadOnlySpan<byte> payload, ReadOnlySpan<byte> maskKey = default)
{
    if (opcode > 0xF)
        throw new ArgumentException("The opcode has only 4 bits.");
    if (opcode >= 0x8 && (!fin || payload.Length > 125))
        throw new ArgumentException("A control frame is one frame of 125 bytes or less.");
    if (maskKey.Length is not (0 or 4))
        throw new ArgumentException("The masking key is 4 bytes.");

    int pos = 0;
    dest[pos++] = (byte)((fin ? 0x80 : 0x00) | opcode); // RSV1, RSV2 and RSV3 stay 0

    byte mask = maskKey.Length == 4 ? (byte)0x80 : (byte)0x00;
    if (payload.Length <= 125)
    {
        dest[pos++] = (byte)(mask | payload.Length);
    }
    else if (payload.Length <= ushort.MaxValue)
    {
        dest[pos++] = (byte)(mask | 126);
        BinaryPrimitives.WriteUInt16BigEndian(dest[pos..], (ushort)payload.Length);
        pos += 2;
    }
    else
    {
        dest[pos++] = (byte)(mask | 127);
        BinaryPrimitives.WriteUInt64BigEndian(dest[pos..], (ulong)payload.Length);
        pos += 8;
    }

    if (mask == 0)
    {
        payload.CopyTo(dest[pos..]);
        return pos + payload.Length;
    }

    maskKey.CopyTo(dest[pos..]);
    pos += 4;
    for (int i = 0; i < payload.Length; i++)
    {
        dest[pos + i] = (byte)(payload[i] ^ maskKey[i % 4]);
    }
    return pos + payload.Length;
}

The checks at the top refuse a control frame that is split or too long. A span length in .NET is an int, so the top bit of the 64-bit length is always 0. The buffer needs room for the payload plus 14 bytes, the largest header. A server that sends many messages can rent it from ArrayPool<byte>.Shared.

Masking on the client

A client picks a new 4-byte masking key for every frame, and nobody must be able to guess it. In .NET, RandomNumberGenerator.Fill fills a buffer with bytes that are safe to use for secrets. Then the key goes to WriteFrame:

C#
byte[] key = new byte[4];
RandomNumberGenerator.Fill(key); // a new random key for every frame
byte[] frame = new byte["Hello"u8.Length + 14];
int frameSize = WriteFrame(frame, 0x1, true, "Hello"u8, key);
var fromClient = new MemoryStream(frame, 0, frameSize);

The mask uses XOR (exclusive or), a bit operation that flips each bit where the key has a 1. Each payload byte at position i is combined with key byte i % 4, where % gives the remainder of a division by 4. The receiver runs the same step with the same key to get the data back. A server must close the connection when a client frame has no mask. A client must close it when a server frame has one.

The key travels in the frame, so masking keeps nothing secret. For private data, use a wss:// address, which runs WebSocket over TLS, the encryption behind HTTPS. Masking is there to protect proxies from an attack, and I explain that attack in Why a WebSocket client masks its frames.

Test with the examples from the RFC

Section 5.7 of RFC 6455 (opens in a new tab) lists example frames with their exact bytes, so they make good unit tests. The masked examples use the key 37 fa 21 3d, and a test can pass the same key. Only a test should ever use a fixed key.

The file WriteFrame.cs builds each example and compares it with the RFC. Frame is a small helper that makes a buffer and calls WriteFrame.

C#
byte[] hello = Encoding.UTF8.GetBytes("Hello");
byte[] rfcKey = [0x37, 0xfa, 0x21, 0x3d]; // the masking key used in RFC 6455, section 5.7

Console.WriteLine("RFC 6455 section 5.7 examples");
Check("unmasked text \"Hello\"", Frame(0x1, true, hello), "81 05 48 65 6c 6c 6f");
Check("masked text \"Hello\"", Frame(0x1, true, hello, rfcKey), "81 85 37 fa 21 3d 7f 9f 4d 51 58");
Check("first fragment \"Hel\"", Frame(0x1, false, hello[..3]), "01 03 48 65 6c");
Check("last fragment \"lo\"", Frame(0x0, true, hello[3..]), "80 02 6c 6f");
Check("unmasked ping \"Hello\"", Frame(0x9, true, hello), "89 05 48 65 6c 6c 6f");
Check("masked pong \"Hello\"", Frame(0xA, true, hello, rfcKey), "8a 85 37 fa 21 3d 7f 9f 4d 51 58");
CheckHeader("256 bytes binary", Frame(0x2, true, new byte[256]), "82 7e 01 00", 256);
CheckHeader("64 KiB binary", Frame(0x2, true, new byte[65536]), "82 7f 00 00 00 00 00 01 00 00", 65536);

This is the output of dotnet run WriteFrame.cs with the .NET 10 SDK:

Output
RFC 6455 section 5.7 examples
PASS  unmasked text "Hello"    81 05 48 65 6c 6c 6f
PASS  masked text "Hello"      81 85 37 fa 21 3d 7f 9f 4d 51 58
PASS  first fragment "Hel"     01 03 48 65 6c
PASS  last fragment "lo"       80 02 6c 6f
PASS  unmasked ping "Hello"    89 05 48 65 6c 6c 6f
PASS  masked pong "Hello"      8a 85 37 fa 21 3d 7f 9f 4d 51 58
PASS  256 bytes binary         82 7e 01 00 + 256 bytes
PASS  64 KiB binary            82 7f 00 00 00 00 00 01 00 00 + 65536 bytes

Header size in bytes (payload size: server frame, client frame)
       0:  2,  6
     125:  2,  6
     126:  4,  8
   65535:  4,  8
   65536: 10, 14

PASS  a ping with 126 bytes is refused: A control frame is one frame of 125 bytes or less.

Every example matches. The RFC calls the masked pong a "Ping response", but its first byte 0x8a holds the pong opcode. The header sizes in the middle of the output are the numbers in the table above.

A second check with .NET

A second test is to give the frames to a real WebSocket reader. In the file ReadBack.cs, a small TwoWayStream class reads from one MemoryStream and writes to another, like the two directions of a connection. So no network is needed. Send is a small helper that calls WriteFrame and writes the frame to a stream. First the file writes what a server might send:

C#
var fromServer = new MemoryStream();
Send(fromServer, 0x1, false, "Hel"u8);
Send(fromServer, 0x9, true, "are you there?"u8);
Send(fromServer, 0x0, true, "lo"u8);
Send(fromServer, 0x2, true, new byte[65536]);
Send(fromServer, 0x8, true, [0x03, 0xe8]); // close with status code 1000, in network byte order
fromServer.Position = 0;

A .NET client reads these frames. The program creates it with WebSocket.CreateFromStream and the option IsServer = false. That method works on a stream that already carries a connection, so there is no handshake. Then a .NET server reads a masked "Hello", and after that the same frame without a mask:

Output
The .NET client read:
  text message, "Hello", in 2 parts
  binary message, 65536 bytes, in 16 parts
  close, status 1000 (NormalClosure)
The .NET client wrote back:
  pong frame, first byte 8a, MASK 1, 14 bytes, "are you there?"
  close frame, first byte 88, MASK 1, 2 bytes, status 1000
The .NET server read a masked frame: text "Hello"
The .NET server refused an unmasked frame. State: Aborted

The client gave back "Hel" and "lo" as two parts of one text message. ReceiveAsync returns one part at a time and sets EndOfMessage to true on the last part. The helper ReadMessage calls it in a loop and joins the parts. The binary message came in 16 parts, because the helper reads at most 4096 bytes at a time. While it read the text message, the client answered the ping with a pong by itself. After the close frame, the program called CloseOutputAsync, and the client sent its own close frame. Both of these frames have MASK set to 1, as the rule for clients says. The server read the masked frame and refused the unmasked one.

Before you send

These steps follow section 6.1 of RFC 6455 (opens in a new tab) and the frame rules above:

  1. Check that the connection is open and that you have not sent a close frame yet.
  2. Choose the opcode: text or binary for the first frame of a message, continuation for the rest.
  3. Set FIN to 1 on the last frame. Leave the reserved bits at 0 unless an extension uses them.
  4. Write the length in the shortest form that fits.
  5. On a client, pick a new random key and mask the payload.
  6. Keep a control frame in one piece, with 125 bytes of payload or less.
  7. Write the frame to the connection.

Read more

More posts