~
writing / Jul 15, 2025

WebSockets 101 — Rolling your own Websocket Server

Deep Dive into working of the websocket protocol, understanding the need, pros, cons and functionality of them, with a hands on tutorial on building your own websocket server from scratch

WebSockets 101 — Rolling your own Websocket Server

captionless image

Websockets, I’m sure you have heard of them before.

Websockets are very interesting, but most of the time, they are covered under abstractions provided by libraries, and ’cause of that, we can’t really appreciate their elegance. So, in this blog, we are gonna remove every abstraction, and understand websockets in pure form — what they are, why we need them, and lastly, how they work.

Part 1: Some yapping on what websockets are (dont skip plsplspls)

HTTP 1.0

Before diving into what websockets are, let’s establish why there is even a need for them. If you already know the crux, you can skip this section, but if not, stick around.

So, in ancient times, there was HTTP 1.0 protocol. (Sorry for the dramatic buildup), so, the idea of HTTP 1.0 protocol is simple, for each request, we create a new TCP Connection, pretty simple right ? .

That’s called ‘connectionmaxxing’. Yes, i made it up.

This is super inefficient and eats up the resources. Imagine you’re calling a friend just to say one sentence, then hanging up, and doing that for every sentence.

captionless image

HTTP 1.1

The Fix — persistence

HTTP 1.1 introduced a new HTTP header, Keep-Alive. Now, when you connect to a server, a TCP connection is opened, and this gets reused for any further requests made.

Also, HTTP 1.1 lays the foundation for WebSocket Protocol.

captionless image

Websockets


The core philosophy of WebSockets is bi-directional communication. Unlike traditional HTTP request/response cycles, where the client requests, server responds (boooring), websockets allow the client and server to talk freely. Once WebSocket connection is established, both parties can send data independently.

The Connection flow


Like everything, websocket connections starts out as a plain old HTTP request.

But there’s a twist, the ‘Upgrade’ header.

So here’s the flow:

  1. Client sends an HTTP request, indicating it would like to upgrade to WebSocket.
  2. Server responds with 101 Switching protocol, confirming the upgrade.
  3. Client and server start sending WebSocket frames.

captionless image

Let’s go more nerdy:

Here’s the actual Request to the server :

8 lines
1GET /chat HTTP/1.1
2Host: server.example.com
3Upgrade: websocket
4Connection: Upgrade
5Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
6Origin: http://example.com
7Sec-WebSocket-Protocol: chat, superchat
8Sec-WebSocket-Version: 13

And the Response :

5 lines
1HTTP/1.1 101 Switching Protocols
2Upgrade: websocket
3Connection: Upgrade
4Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
5Sec-WebSocket-Protocol: chat

About the Sec-Websocket-Key and Sec-Websocket-Accept headers :

The Sec-Websocket-Key is random string sent by the client while initiating the handshake. Server takes this key, appends GUID “258EAFA5-E914–47DA-95CA-C5AB0DC85B11” to it. Then it computes the SHA-1 hash of combined string, base64 encodes it and send it back in the Sec-WebSocket-Accept header. If it matches with client’s expected value, the handshake is accepted, rejected otherwise.

Where do you use websockets ?


  • Chat Apps
  • Gaming
  • Showing progress, logging

Let’s talk some cons (cause nothing’s perfect)


Statefulness

Websocket is a stateful protocol, meaning the server needs to keep track of each connection. So, if the server dies, the connection dies too. This makes horizontal scaling tricky. You can write more code, add a Database to store websockets, but that’s just extra work.

Load-balancing and Timeouts

Load-balancing with websockets is tricky, particularly at layer 7.

To get it to work, you have to maintain two connections:

  • client — LB
  • LB — server.

This is not ideal for Scalability.

Apart from that, you have to deal with timeouts.

Websocket connections can basically stay open forever, but firewalls, proxies, and load balancers have a limit for which a connection can stay open. Past that, the connection is terminated. Most implementations of websockets implement a ping/pong mechanism to keep the connection alive.

Part 2: Writing your own websocket server (from scratch)

Let’s write some code (yay).

If you wish to create a websocket server from scratch, you must first invent the universe.

Ok not that scratch maybe… but we will remove abstractions as much as we can and build the core ourselves.

Step 1: Building a bare bones HTTP Server

Since Websockets begin via an HTTP Request, we need a Basic HTTP server.

The idea of an HTTP Server is simple:

  • One socket for listening for incoming connections.
  • When a connection comes, a new socket is created to handle it.
  • Once that’s ready, it’s used to read and write.

Let’s start by importing necessary header files:

8 lines
1#include <sys/socket.h>
2#include <stdlib.h>
3#include <arpa/inet.h>
4#include <stdio.h>
5#include <unistd.h>
6#include <pthread.h>
7#include <sys/types.h>
8#include <string.h>

Some constants

5 lines
1#define PORT 6969 // :)
2#define BACKLOG 10 // number of pending connections queue will store
3#define BUFFER_SIZE 10240 // 10mb buffer
4#define MAX_HEADERS 100
5const char *PLACEHOLDER_RESPONSE = "<HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\r\n<TITLE>ABCD</TITLE></HEAD><BODY>\r\n<H1>XYZ</H1>\r\n</BODY></HTML>\r\n\r\n";

Request and Header types

13 lines
1typedef struct
2{
3 char *key;
4 char *value;
5} header_t;
6typedef struct
7{
8 char method[16];
9 char target[1024];
10 char version[16];
11 header_t headers[MAX_HEADERS];
12 int header_count;
13} http_request_t;

The main() function

47 lines
1int main()
2{
3 int server_fd;
4 struct sockaddr_in server_adress;
5 if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
6 {
7 perror("socket");
8 exit(EXIT_FAILURE);
9 }
10 server_adress.sin_addr.s_addr = INADDR_ANY;
11 server_adress.sin_family = AF_INET;
12 server_adress.sin_port = htons(PORT);
13 int opt = 1;
14 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1)
15 {
16 perror("setsocketopt");
17 close(server_fd);
18 exit(EXIT_FAILURE);
19 }
20 if (bind(server_fd, (struct sockaddr *)&server_adress, sizeof(server_adress)) == -1)
21 {
22 perror("binding");
23 close(server_fd);
24 exit(EXIT_FAILURE);
25 }
26 if (listen(server_fd, BACKLOG) == -1)
27 {
28 perror("listen");
29 close(server_fd);
30 exit(EXIT_FAILURE);
31 }
32 printf("Listening on port : %d\n", PORT);
33 while (1)
34 {
35 struct sockaddr_in client_adress;
36 size_t client_addr_len = sizeof(client_adress);
37 int *client_fd = malloc(sizeof(int));
38 if ((*client_fd = accept(server_fd, (struct sockaddr *)&client_adress, (socklen_t *)&client_addr_len)) == -1)
39 {
40 perror("accept");
41 continue;
42 }
43 pthread_t tid;
44 pthread_create(&tid, NULL, handle_client, (void *)client_fd);
45 pthread_detach(tid);
46 }
47}

Client Handler

30 lines
1void *handle_client(void *arg)
2{
3 int client_fd = *((int *)arg); // type-cast void * to int * and dereference it
4 char *buffer = (char *)malloc(BUFFER_SIZE * sizeof(char));
5 ssize_t bytes_received = recv(client_fd, buffer, BUFFER_SIZE, 0);
6 if (bytes_received > 0)
7 {
8 buffer[bytes_received] = '\0';
9 http_request_t req;
10 parse_request(buffer, &req);
11 printf("Method: %s\n", req.method);
12 printf("Target: %s\n", req.target);
13 printf("Version: %s\n", req.version);
14 const char *http_headers =
15 "HTTP/1.1 200 OK\r\n"
16 "Content-Type: text/html\r\n"
17 "Content-Length: %zu\r\n"
18 "\r\n";
19 const char *body = PLACEHOLDER_RESPONSE;
20 char response[BUFFER_SIZE];
21 size_t body_len = strlen(body);
22 int header_len = snprintf(response, BUFFER_SIZE, http_headers, body_len);
23 strncat(response, body, BUFFER_SIZE - header_len - 1);
24 send(client_fd, response, strlen(response), 0);
25 }
26 close(client_fd);
27 free(arg);
28 free(buffer);
29 return NULL;
30}

Request Parser

28 lines
1void parse_request(const char *request, http_request_t *parsed)
2{
3 char *req_copy = strdup(request);
4 if (!req_copy)
5 {
6 perror("strdup");
7 return;
8 }
9 char *header_end = strstr(req_copy, "\r\n\r\n");
10 *header_end = '\0';
11 char *line = strtok(req_copy, "\r\n");
12 sscanf(line, "%15s %1023s %15s", parsed->method, parsed->target, parsed->version);
13 parsed->header_count = 0;
14 while ((line = strtok(NULL, "\r\n")) != NULL)
15 {
16 char *colon = strstr(line, ": ");
17 if (colon)
18 {
19 *colon = '\0';
20 char *key = line;
21 char *value = colon + 2;
22 parsed->headers[parsed->header_count].key = strdup(key);
23 parsed->headers[parsed->header_count].value = strdup(value);
24 parsed->header_count++;
25 }
26 }
27 free(req_copy);
28}

Here’s the whole code : server.c

Let’s see it in action.

From client side,

16 lines
1curl -v 127.0.0.1:6969
2* Trying 127.0.0.1:6969...
3* Connected to 127.0.0.1 (127.0.0.1) port 6969
4> GET / HTTP/1.1
5> Host: 127.0.0.1:6969
6> User-Agent: curl/8.5.0
7> Accept: */*
8>
9< HTTP/1.1 200 OK
10< Content-Type: text/html
11< Content-Length: 149
12<
13<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
14<TITLE>ABCD</TITLE></HEAD><BODY>
15<H1>XYZ</H1>
16</BODY></HTML>

From Server side,

4 lines
1Listening on port : 6969
2Method: GET
3Target: /
4Version: HTTP/1.1

Step 2: Handling the Websocket Handshake

A typical Websocket handshake looks like this,

4 lines
1curl -v 127.0.0.1:6969/websocket
2-H "Upgrade: websocket" \
3-H "connection: Upgrade" \
4-H "sec-websocket-key: dGhlIHNhbXBsZSBub25jZQ=="

We just need to parse the request, validate the headers, and send back the base64 hashed key, along with status code 101.

Validating the Handshake

23 lines
1bool is_valid_ws_handshake(http_request_t request)
2{
3 // check method
4 bool is_get = strcmp(request.method, "GET") == 0;
5 char *version_dup;
6 if ((version_dup = strdup(request.version)) == NULL)
7 {
8 perror("strdup version_dup");
9 return false;
10 }
11 // check http version
12 char *protocol = strtok(version_dup, "/");
13 char *version_number = strtok(NULL, "\0");
14 free(version_dup);
15 double version_num = atof(version_number);
16 bool version_compat = version_num >= 1.1;
17 // check headers
18 bool upgrade_header = header_exists(request.headers, request.header_count, "Upgrade", "websocket");
19 bool connection_header = header_exists(request.headers, request.header_count, "connection", "Upgrade");
20 bool sec_websocket_key_header = header_exists(request.headers, request.header_count, "sec-websocket-key", NULL);
21 bool headers_valid = upgrade_header && connection_header && sec_websocket_key_header;
22 return is_get && headers_valid;
23}

after validating the handshake, we need to complete the handshake. For that, we will take the sec-websocket-key and do the magic. And send it along 101 status code.

Generating the sec-websocket-accept key

19 lines
1char *generate_sec_websocket_accept_key(char *sec_websocket_key)
2{
3 size_t key_len = strlen(sec_websocket_key);
4 size_t GUID_len = strlen(GUID);
5 size_t combined_len = key_len + GUID_len;
6 char *combined = malloc(combined_len + 1);
7 if (!combined)
8 {
9 perror("combined malloc");
10 return NULL;
11 }
12 strcpy(combined, sec_websocket_key);
13 strcat(combined, GUID);
14 unsigned char hashed[SHA_DIGEST_LENGTH];
15 size_t len = strlen(combined);
16 SHA1(combined, len, hashed);
17 char *encoded = b64_encode(hashed);
18 return encoded;
19}

Finally, we will complete the handshake.

14 lines
1void handle_ws_handshake(http_request_t req, int client_fd)
2{
3 const char *ws_headers =
4 "HTTP/1.1 101 Switching Protocols\r\n"
5 "Upgrade: websocket\r\n"
6 "Connection: Upgrade\r\n"
7 "Sec-WebSocket-Accept: %s\r\n"
8 "\r\n";
9 char *client_key = get_sec_websocket_key(req.headers, req.header_count);
10 char *transformed_key = generate_sec_websocket_accept_key(client_key);
11 char response[BUFFER_SIZE];
12 int header_len = snprintf(response, BUFFER_SIZE, ws_headers, transformed_key);
13 send(client_fd, response, strlen(response), 0);
14}

Here’s the updated handle_client() function.

45 lines
1void *handle_client(void *arg)
2{
3 int client_fd = *((int *)arg); // type-cast void * to int * and dereference it
4 char *buffer = (char *)malloc(BUFFER_SIZE * sizeof(char));
5 ssize_t bytes_received = recv(client_fd, buffer, BUFFER_SIZE, 0);
6 if (bytes_received > 0)
7 {
8 buffer[bytes_received] = '\0';
9 http_request_t req;
10 parse_request(buffer, &req);
11 printf("Method: %s\n", req.method);
12 printf("Target: %s\n", req.target);
13 printf("Version: %s\n", req.version);
14 for (int i = 0; i < req.header_count; i++)
15 {
16 printf("%s : %s\n", req.headers[i].key, req.headers[i].value);
17 }
18 const char *http_headers =
19 "HTTP/1.1 200 OK\r\n"
20 "Content-Type: text/html\r\n"
21 "Content-Length: %zu\r\n"
22 "\r\n";
23 if (strcmp(req.target, "/websocket") == 0)
24 {
25 if (is_valid_ws_handshake(req))
26 {
27 handle_ws_handshake(req, client_fd);
28 handle_ws_request(client_fd);
29 }
30 }
31 else
32 {
33 const char *body = PLACEHOLDER_RESPONSE;
34 char response[BUFFER_SIZE];
35 size_t body_len = strlen(body);
36 int header_len = snprintf(response, BUFFER_SIZE, http_headers, body_len);
37 strncat(response, body, BUFFER_SIZE - header_len - 1);
38 send(client_fd, response, strlen(response), 0);
39 }
40 }
41 close(client_fd);
42 free(arg);
43 free(buffer);
44 return NULL;
45}

We are ready to receive websocket requests on /websocket path. Atleast till the Handshake. Let’s try it out

Let’s try with a browser now,

captionless image

On The Server,

16 lines
1Listening on port : 6969
2Method: GET
3Target: /websocket
4Version: HTTP/1.1
5Host : localhost:6969
6Connection : Upgrade
7Pragma : no-cache
8Cache-Control : no-cache
9User-Agent : Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
10Upgrade : websocket
11Origin : https://duckduckgo.com
12Sec-WebSocket-Version : 13
13Accept-Encoding : gzip, deflate, br, zstd
14Accept-Language : en-US,en;q=0.9
15Sec-WebSocket-Key : wB8akPQhn4vst/tD+RwLmA==
16Sec-WebSocket-Extensions : permessage-deflate; client_max_window_bits

Yayyy. Our Handshake is working.

Now comes the interesting bit, Websocket Frames.

Step 3: Parsing the incoming Websocket Frames

Here’s what we are talking about.

18 lines
1 0 1 2 3
2 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
3 +-+-+-+-+-------+-+-------------+-------------------------------+
4 |F|R|R|R| opcode|M| Payload len | Extended payload length |
5 |I|S|S|S| (4) |A| (7) | (16/64) |
6 |N|V|V|V| |S| | (if payload len==126/127) |
7 | |1|2|3| |K| | |
8 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
9 | Extended payload length continued, if payload len == 127 |
10 + - - - - - - - - - - - - - - - +-------------------------------+
11 | |Masking-key, if MASK set to 1 |
12 +-------------------------------+-------------------------------+
13 | Masking-key (continued) | Payload Data |
14 +-------------------------------- - - - - - - - - - - - - - - - +
15 : Payload Data continued ... :
16 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
17 | Payload Data continued ... |
18 +---------------------------------------------------------------+

Prolly looks scary, but its quite simple actually.

A Websocket frame is made up of 4 parts:

  • Flags
  • Payload Length
  • Masking Key
  • and, The Payload

Checkout Section 5.2 of RFC 6455 for more details.

I would like to go over Masking a bit.

Masking

Masking means obfuscating the data. A client always masks the data before sending, this may not be always true for the server.

The purpose of masking isn’t to add any encryption. Rather, it’s used to differentiate it from an HTTP Request. It’s also to prevent proxies and caches to cache the Request.

How Masking works ?

The client takes each byte of the payload and XORs it with one byte from a randomly generated 4-byte masking key. The key bytes repeat in a cycle. Applying the same XOR operation again on the masked data reverses it back to the original payload.

Let’s understand how are we handling a Websocket Request:

51 lines
1void handle_ws_request(int client_fd)
2{
3 while (1)
4 {
5 unsigned char meta[2]; // for reading frame metadata
6 ssize_t n = recv(client_fd, meta, 2, 0);
7 if (n <= 0)
8 break;
9 unsigned char fin = (meta[0] & 0x80) >> 7;
10 unsigned char opcode = meta[0] & 0x0F;
11 unsigned char masked = (meta[1] & 0x80) >> 7;
12 uint64_t payload_len = meta[1] & 0x7F;
13 if (payload_len < 126)
14 {
15 }
16 else if (payload_len == 126)
17 {
18 char real_payload_len[2];
19 recv(client_fd, real_payload_len, 2, 0);
20 payload_len = (real_payload_len[0] << 8) | real_payload_len[1];
21 }
22 else if (payload_len == 127)
23 {
24 char real_payload_len[8];
25 recv(client_fd, real_payload_len, 8, 0);
26 uint64_t real_len = 0;
27 for (int i = 0; i < 8; i++)
28 {
29 real_len = (real_len << 8) | real_payload_len[i];
30 }
31 payload_len = real_len;
32 }
33 char *pl_buffer = malloc(payload_len);
34 if (masked)
35 {
36 unsigned char masking_key[4];
37 ssize_t key_size = recv(client_fd, masking_key, 4, 0);
38 recv(client_fd, pl_buffer, payload_len, 0);
39 for (int i = 0; i < payload_len; i++)
40 {
41 pl_buffer[i] = pl_buffer[i] ^ masking_key[i % 4];
42 }
43 printf("Payload: %.*s\n", (int)payload_len, pl_buffer);
44 }
45 else
46 {
47 recv(client_fd, pl_buffer, payload_len, 0);
48 }
49 }
50 return;
51}

Firstly, we read the first 2 bytes of the frame, to get information about the Frame.

8 lines
1unsigned char meta[2]; // for reading frame metadata
2ssize_t n = recv(client_fd, meta, 2, 0);
3if (n <= 0)
4 break;
5unsigned char fin = (meta[0] & 0x80) >> 7;
6unsigned char opcode = meta[0] & 0x0F;
7unsigned char masked = (meta[1] & 0x80) >> 7;
8uint64_t payload_len = meta[1] & 0x7F;

First byte containes the FIN bit, which tells if its a final frame, next 3 bits are reserve bits, which are used for indicating extension. Next 4 bits are the opcode, which basically tells the interpretation of the data. Next 1 bit is the masked bit, if its 1, it means the data is masked. Next 7 bits tell the payload length.

The last 7 bits are interesting, as, it can only hold upto 127, so, for larger payload, there is a trick.

20 lines
1 if (payload_len < 126)
2 {
3 }
4 else if (payload_len == 126)
5 {
6 char real_payload_len[2];
7 recv(client_fd, real_payload_len, 2, 0);
8 payload_len = (real_payload_len[0] << 8) | real_payload_len[1];
9 }
10 else if (payload_len == 127)
11 {
12 char real_payload_len[8];
13 recv(client_fd, real_payload_len, 8, 0);
14 uint64_t real_len = 0;
15 for (int i = 0; i < 8; i++)
16 {
17 real_len = (real_len << 8) | real_payload_len[i];
18 }
19 payload_len = real_len;
20 }

Extended Payload Length:

  • payload_len < 126 : no extra length bytes
  • payload_len = 126 : next 2 bytes represent actual length
  • payload_len > 126 : next 8 bytes represent actual length

Next we can just put everything together, and read the payload data.

Let’s put everything to test :

Client Side :

captionless image

Server side:

20 lines
1Listening on port : 6969
2Method: GET
3Target: /websocket
4Version: HTTP/1.1
5Host : localhost:6969
6Connection : Upgrade
7Pragma : no-cache
8Cache-Control : no-cache
9User-Agent : Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
10Upgrade : websocket
11Origin : https://duckduckgo.com
12Sec-WebSocket-Version : 13
13Accept-Encoding : gzip, deflate, br, zstd
14Accept-Language : en-US,en;q=0.9
15Sec-WebSocket-Key : E5AO9X0FkPCACiKsdpmDMw==
16Sec-WebSocket-Extensions : permessage-deflate; client_max_window_bits
17payload len : 5
18Payload: hello
19payload len : 126
20Payload: helloooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo

Everything’s working perfectly . yayyyy.

Grab the full code from here : websockets

TL;DR

So, websocket is a full duplex, stateful, binary protol, that upgrades an HTTP connection to a persistent channel, that allows client and server to talk freely, without initiating new connection each time.

Thank you for reading till the end <3.

Wanna connect?, follow me on X.