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 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:
0x0continuation: the next part of a message that started in an earlier frame0x1text: UTF-8 text, and the whole message must be valid UTF-80x2binary: any bytes, and the application decides what they mean0x8close: the sender wants to close the connection0x9ping and0xApong: 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 size | Length field | Extra length bytes | Header from a server | Header from a client |
|---|---|---|---|---|
| 0 to 125 bytes | the size itself | 0 | 2 bytes | 6 bytes |
| 126 to 65,535 bytes | 126 | 2 | 4 bytes | 8 bytes |
| 65,536 bytes or more | 127 | 8 | 10 bytes | 14 bytes |
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
FINset to 0. - Each middle frame has the opcode
0x0(continuation) andFINset to 0. - The last frame has the opcode
0x0andFINset 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.
// 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:
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.
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:
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:
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:
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: AbortedThe 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:
- Check that the connection is open and that you have not sent a close frame yet.
- Choose the opcode: text or binary for the first frame of a message, continuation for the rest.
- Set
FINto 1 on the last frame. Leave the reserved bits at 0 unless an extension uses them. - Write the length in the shortest form that fits.
- On a client, pick a new random key and mask the payload.
- Keep a control frame in one piece, with 125 bytes of payload or less.
- Write the frame to the connection.
Read more
- RFC 6455 (opens in a new tab), the WebSocket protocol, mainly section 5 on data framing, with the examples in section 5.7, and section 6.1 on sending data
- RFC 7692 (opens in a new tab), compression extensions for WebSocket, with the RSV1 bit in section 6
- RFC 8441 (opens in a new tab), WebSocket over HTTP/2
- Errata for RFC 6455 (opens in a new tab), the list of reported errors
- BinaryPrimitives (opens in a new tab) on Microsoft Learn
- WebSocket.CreateFromStream (opens in a new tab) on Microsoft Learn
- File-based apps (opens in a new tab) on Microsoft Learn, to run one
.csfile withdotnet run - Why a WebSocket client masks its frames, the companion post