The SignalR Trap, why it's not just WebSockets

2 AugGitHub
.NETC#TestsCommunications

If you work in the .NET ecosystem and need to build real-time communication applications, SignalR is usually the first choice. But I often see developers confusing SignalR with WebSocket, and it's worth understanding why SignalR is not just WebSocket.

WebSockets vs. SignalR

What is a WebSocket?

WebSocket is a communication protocol, a persistent full-duplex connection between client and server over TCP. It is "just" transport: you send bytes, you receive bytes. It does not define a message format or any application logic.

After the HTTP Upgrade:

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade

The connection stops being HTTP and becomes just a stream of bytes. The WebSocket protocol knows nothing about:

  • methods
  • events
  • JSON
  • users
  • groups

All of that is the application's responsibility.

You can picture the WebSocket as a pipe that carries bytes between client and server, with no knowledge of what those bytes mean.

flowchart LR
  Client <== Bytes ==> Server

What is SignalR?

SignalR is an abstraction library on top of several transports (WebSocket, Server-Sent Events, Long Polling). On the server side, it adds concepts like hubs, remote method invocation, client groups, and user management. On the client side, it provides libraries that handle message serialization and automatic reconnection. And, importantly for this article, it defines its own message protocol on top of the transport.

Quick comparison

WebSocketsSignalR
ProtocolFramework
RFC 6455ASP.NET Core library
Sends bytes or textSends serialized messages (JSON or MessagePack)
You have to build your own protocolSignalR protocol already defined
No automatic reconnectionReconnection supported
No user managementUser management built in
No groupsGroups built in
No broadcastBroadcast built in
Works with any technologyServer in ASP.NET Core; clients for .NET, JS/TS and Java
No specific client requiredNeeds a client compatible with the SignalR protocol

All of this makes development with SignalR extremely simple, but it also makes testing quite a bit harder when you don't have those same abstractions available. That problem is exactly what led me to write this article: understanding a few details of the SignalR protocol so you can run tests when you don't have a SignalR client at hand.

What SignalR adds

SignalR uses that "pipe" to implement an RPC protocol. Instead of sending plain text, it sends messages with meaning. That makes calling a remote method as simple as:

await hubConnection.InvokeAsync("SendMessage", message);

But there's actually a fair amount of protocol behind it.

How a SignalR connection works

The connection has three phases:

  1. negotiate: the client makes a POST to /hub/negotiate and receives a temporary connectionToken.
  2. WebSocket: the client opens the WebSocket connection using that token: ws://{host}/hub?id={connectionToken}.
  3. handshake: the first message sent over the socket is a JSON telling SignalR which protocol the client will use.
sequenceDiagram
  Note over Client,Server: 1. negotiate
  Client->>Server: POST /hub/negotiate
  Server-->>Client: connectionToken

  Note over Client,Server: 2. WebSocket
  Client->>Server: ws://{host}/hub?id={connectionToken}
  Server-->>Client: 101 Switching Protocols

  Note over Client,Server: 3. handshake
  Client->>Server: JSON {"protocol":"json","version":1}
  Server-->>Client: {}

  Note over Client,Server: Connection established

Only after the handshake does the hub consider the connection established.

These last two phases are exactly what make testing SignalR applications more challenging than just testing a WebSocket. SignalR is not just a WebSocket: it's a complete protocol on top of the WebSocket.

The SignalR handshake

Right after opening the WebSocket, the client sends a SignalR handshake message. In other words: opening a WebSocket to a SignalR endpoint is not enough. SignalR expects to receive a specific handshake right after the connection. Without it, the connection stays open but the hub never considers it "ready".

{
  "protocol": "json",
  "version": 1
}

In practice, it's sent on a single line, terminated by the record separator ():

{"protocol":"json","version":1}␞
  • protocol - Serialization protocol used, either json or messagepack
  • version - SignalR protocol version

If the server accepts the protocol, it replies with an empty JSON, also terminated by a record separator:

{}␞

Without the handshake response, the connection is not considered established.

But the payload actually sent over the wire is:

7b2270726f746f636f6c223a226a736f6e222c2276657273696f6e223a317d1e
HexChars
7b{
22"
70 72 6f 74 6f 63 6f 6cprotocol
3a:
6a 73 6f 6ejson
2c,
76 65 72 73 69 6f 6eversion
311
7d}
1e0x1E

If you look closely, you'll see the message ends with a special byte, called the Record Separator.

Record Separator

It's the delimiter used by the SignalR protocol. It's what tells SignalR where a message ends. Without it, SignalR receives text but can't tell where the first message ends, and the handshake never completes.

Why does the Record Separator exist?

On a WebSocket, several messages can arrive in a single frame. Without a delimiter, they're indistinguishable:

Message1Message2Message3

So SignalR separates each message with the byte 0x1E ():

Message1␞Message2␞Message3␞

That way, the marks the end of each message.

How to view SignalR messages

There are several ways, but let's use the simplest one: open the browser DevTools and watch the WebSocket traffic.

DevTools comparison between a raw WebSocket connection and a SignalR connection

As you can see in the image, on the left we have a raw WebSocket connection, carrying only the application's final data. On the right, a SignalR connection, where you can see handshake messages, remote method invocations, and ping/pong messages. Notice too that SignalR messages follow a different payload structure, with the 0x1E delimiter at the end of each message.

Anatomy of a SignalR message

SignalR messages are identified by the type field, which determines the structure and meaning of the message. The main types are:

TypeNameFunction
1InvocationInvoke a remote method and wait for a response
2StreamItemSend an item from a data stream
3CompletionComplete a stream or return a final result
4StreamInvocationInvoke a remote method that returns a stream
5CancelInvocationCancel a stream in progress
6PingKeep the connection alive (keep-alive)
7CloseClose the connection gracefully

Example: remote method invocation

A simple invocation example:

{
  "type": 1,
  "target": "ReceiveMessage",
  "arguments": [
    "Hello"
  ]
}

Which, on the wire, is sent on a single line, terminated by the record separator:

{"type":1,"target":"ReceiveMessage","arguments":["Hello"]}␞
  • type - Type 1: remote method invocation (Invocation)
  • target - Name of the remote method to invoke
  • arguments - Arguments sent to the remote method

Example: Keep-alive (Ping)

A ping just to keep the connection alive:

{}␞

Or explicitly:

{"type":6}␞

The server replies with a ping the same way to confirm the connection.

The main friction points when testing SignalR

Before we get to the tools, it's worth summarizing the main friction points that make testing SignalR harder:

  • It's not a single HTTP request. It's a sequence: negotiate (HTTP) → WebSocket → handshake (a message inside the socket). Traditional HTTP testing tools don't cover this natively.
  • The negotiation token has a limited lifetime. The connectionToken maps to a connection created by the server during negotiate; if you take too long to open the WebSocket, that connection no longer exists and the server returns 404.
  • The WebSocket connection on its own isn't enough. A lot of people open the socket, don't send the handshake, and assume SignalR "isn't working", when it's actually waiting for the first message.
  • The delimiter is easy to forget, especially when writing the payload by hand or generating messages programmatically without the official client.

How to test SignalR without the official client

I'll show two tools that are widely used during development: Postman, for manual tests, and k6, for load tests. Both are great, but neither has native support for SignalR. That means you have to build the SignalR protocol on top of the WebSocket by hand.

Manual tests with Postman

Recent versions of Postman support WebSockets, but only WebSocket. It can open the connection, but it doesn't know about:

  • negotiate
  • handshake
  • protocol framing
  • invocations

You have to build everything by hand. To test the full flow, including the WebSocket:

1. Negotiate (negotiate protocol and transport)

POST: http://localhost:8080/demo-hub/negotiate?negotiateVersion=1 and copy the connectionToken from the response.

What is negotiateVersion=1? It's the version of the negotiation protocol (not to be confused with the SignalR protocol version in the handshake).

  • negotiateVersion=0: legacy format, supports only one transport per negotiation
  • negotiateVersion=1: current format, supports multiple transports in the same negotiation

Always use negotiateVersion=1 to get full fallback support (WebSocket → ServerSentEvents → LongPolling).

{
  "negotiateVersion": 1,
  "connectionId": "fdmsZsc0A3ejTY5FI0r0NA",
  "connectionToken": "3lWE4OAsva6TLb2CcJcoaA",
  "availableTransports":
  [
    { "transport": "WebSockets", "transferFormats": ["Text", "Binary"] },
    { "transport": "ServerSentEvents", "transferFormats": ["Text"] },
    { "transport": "LongPolling", "transferFormats": ["Text", "Binary"] }
  ]
}

Difference between connectionId and connectionToken:

  • connectionId: a readable identifier for the connection, used to send messages to a specific connection, logging, debugging, etc. (e.g. "fdmsZsc0A3ejTY5FI0r0NA")
  • connectionToken: a temporary authentication token, required to open the WebSocket (e.g. "3lWE4OAsva6TLb2CcJcoaA")

Always use the connectionToken on the WebSocket, not the connectionId.

2. WebSocket (open the connection)

Open a new WebSocket Request to ws://localhost:8080/demo-hub?id={{connectionToken}}.

3. Handshake (send the protocol)

  • As soon as the socket connects, send this as the first message: {"protocol":"json","version":1}␞.
  • If the handshake is accepted, you're ready to send and receive SignalR messages.

WebSocket request in Postman sending the SignalR handshake

Load tests with k6

k6 has WebSocket support and can hold thousands of concurrent connections, which makes it a great base for load testing applications.

The challenge is exactly the same as with Postman: k6 knows WebSockets, but it doesn't know SignalR.

For real load, with many simultaneous connections, each with its own negotiate → WebSocket → handshake cycle, k6's plain HTTP approach isn't enough; you need the k6/ws module to open the socket.

Here's an example script with those steps implemented:

import { check, sleep } from 'k6';
import http from 'k6/http';
import ws from 'k6/ws';

const API_HOST = __ENV.API_HOST || 'http://localhost:8080';
const DURATION = parseInt(__ENV.DURATION || '1000');
const WS_HOST = API_HOST.replace(/^http/, 'ws');

// SignalR frames are delimited by ASCII record separator (0x1e)
const RS = String.fromCharCode(30);

export default () => {
  const negotiateRes = http.post(`${API_HOST}/demo-hub/negotiate?negotiateVersion=1`);
  check(negotiateRes, { 'negotiate: status 200': (r) => r.status === 200 });

  const { connectionId, connectionToken } = JSON.parse(negotiateRes.body);

  let handshakeOk = false;
  let messagesReceived = 0;

  const wsRes = ws.connect(
    `${WS_HOST}/demo-hub?id=${encodeURIComponent(connectionToken)}`,
    null,
    (socket) => {
      socket.on('open', () => {
        socket.send(`{"protocol":"json","version":1}${RS}`);
      });

      socket.on('message', (data) => {
        // Loop through all frames in the message (there may be multiple frames in a single message)
        for (const frame of data.split(RS).filter(Boolean)) {
          const msg = JSON.parse(frame); // parse the frame as JSON
          if (Object.keys(msg).length === 0) {
            // handshake ack, trigger the demo stream targeted at this connection
            handshakeOk = true;
            http.get(`${API_HOST}/demo?duration=${DURATION}&connectionId=${connectionId}`);
          } else if (msg.type === 1) {
            messagesReceived++;
          }
        }
      });

      socket.setTimeout(() => socket.close(), 2000);
    }
  );

  check(wsRes, { 'ws: upgraded (101)': (r) => r && r.status === 101 });
  check({ handshakeOk, messagesReceived }, {
    'ws: handshake completed': (r) => r.handshakeOk,
    'ws: received streamed messages': (r) => r.messagesReceived > 0,
  });

  sleep(1);
}

See the full script on GitHub

Conclusion

SignalR greatly simplifies building real-time applications in .NET, but that simplicity hides a fairly sophisticated protocol.

While a WebSocket only carries bytes, SignalR adds a full communication layer, including negotiation, handshake, method invocation, pings, streaming, and connection lifecycle management.

That abstraction is exactly what makes development more productive and testing more challenging.

Understanding what really happens "on the wire" helps not only to diagnose problems but also to build test tools that are more reliable and closer to reality.

References

Mermaid processor

© 2026 Nelson Nobre. All rights reserved. v1.12.0

Nelson Nobre
We are using cookies to ensure that we give you the best experience on our website. By clicking "Accept", you consent to the use of ALL the cookies. However you may visit Cookie policy to provide a controlled consent.