Real-time communication has become a standard expectation for modern applications. Whether it is customer support, collaborative editing, multiplayer gaming, or internal messaging systems, users expect instant updates without manually refreshing the page. WebSockets provide a persistent, bidirectional communication channel that makes these experiences possible.
The introduction of modern Core WebSocket APIs has simplified the implementation of high-performance real-time systems. Combined with GraphQL schemas for strongly typed APIs and Protocol Buffers (Proto files) for efficient serialization, developers can build scalable live chat applications that are both type-safe and performant.
This article demonstrates how to build a live chat application using the Core WebSocket API while generating typed clients from both a GraphQL schema and a Protocol Buffer definition. The goal is to create a maintainable architecture where the server and client share strongly typed contracts that reduce runtime errors and improve developer productivity.
Understanding the Architecture
Before writing code, it is useful to understand the overall architecture.
The application consists of four major components:
- A WebSocket server
- A browser or desktop client
- A GraphQL schema for API typing
- A Protocol Buffer definition for compact message serialization
The communication flow looks like this:
Client
│
│ WebSocket Connection
▼
WebSocket Server
│
├── GraphQL Schema
│
└── Proto Message Serialization
Instead of sending loosely structured JSON objects, the application uses predefined contracts. GraphQL describes the available operations and data structures, while Protocol Buffers define the binary format used during transmission.
This combination provides excellent performance without sacrificing maintainability.
Why Use the Core WebSocket API?
Traditional HTTP communication requires a request for every response.
WebSockets establish one persistent connection.
Benefits include:
- Low latency
- Minimal overhead
- Bidirectional communication
- Reduced server load
- Better scalability for chat applications
Once connected, either the client or server can send messages at any time.
Designing the GraphQL Schema
GraphQL serves as the shared contract between backend and frontend.
A simple schema may look like this:
type User {
id: ID!
username: String!
}
type ChatMessage {
id: ID!
sender: User!
text: String!
timestamp: String!
}
type Query {
messages: [ChatMessage!]!
}
type Mutation {
sendMessage(text: String!): ChatMessage!
}
type Subscription {
messageReceived: ChatMessage!
}
This schema clearly defines:
- Users
- Messages
- Queries
- Mutations
- Real-time subscriptions
Many code generators can transform this schema into strongly typed classes for multiple programming languages.
Generating Typed Clients from GraphQL
Instead of manually writing interfaces, developers can generate them.
Example TypeScript output:
export interface User {
id: string;
username: string;
}
export interface ChatMessage {
id: string;
sender: User;
text: string;
timestamp: string;
}
Now every message in the application follows the exact same structure.
Autocomplete improves dramatically while runtime bugs decrease.
Creating the Protocol Buffer Definition
Protocol Buffers define compact binary messages.
Example:
syntax = "proto3";
package chat;
message User {
string id = 1;
string username = 2;
}
message ChatMessage {
string id = 1;
User sender = 2;
string text = 3;
string timestamp = 4;
}
Unlike JSON, Protocol Buffers are optimized for speed and bandwidth.
Advantages include:
- Smaller payloads
- Faster parsing
- Version compatibility
- Language independence
Generating Typed Classes from the Proto File
After compilation, generated classes may resemble:
const message = new ChatMessage();
message.setId("100");
message.setText("Hello World");
const bytes = message.serializeBinary();
Receiving data becomes equally straightforward:
const received = ChatMessage.deserializeBinary(bytes);
console.log(received.getText());
Because these classes are generated automatically, there is no risk of mismatched field names.
Creating the WebSocket Server
The server accepts WebSocket connections and broadcasts messages.
Example:
import { WebSocketServer } from "ws";
const server = new WebSocketServer({
port: 8080
});
const clients = new Set();
server.on("connection", socket => {
clients.add(socket);
socket.on("message", data => {
for (const client of clients) {
if (client.readyState === client.OPEN) {
client.send(data);
}
}
});
socket.on("close", () => {
clients.delete(socket);
});
});
This simple server forwards incoming messages to every connected client.
In production environments, additional concerns such as authentication, authorization, rate limiting, and persistence should also be implemented.
Creating the Client
The browser connects using the WebSocket API.
const socket = new WebSocket("ws://localhost:8080");
socket.onopen = () => {
console.log("Connected");
};
socket.onmessage = event => {
console.log(event.data);
};
Sending messages is equally simple.
socket.send("Hello Everyone");
Once Protocol Buffers are introduced, binary messages replace plain strings.
Sending Binary Protocol Buffer Messages
Instead of JSON:
socket.send(JSON.stringify(message));
Use:
const bytes = protoMessage.serializeBinary();
socket.send(bytes);
Receiving binary data:
socket.binaryType = "arraybuffer";
socket.onmessage = event => {
const message =
ChatMessage.deserializeBinary(
new Uint8Array(event.data)
);
console.log(message.getText());
};
This significantly reduces bandwidth usage.
Integrating GraphQL with WebSockets
GraphQL subscriptions naturally pair with WebSockets.
Whenever a new chat message arrives:
- The client sends a mutation.
- The server stores the message.
- The server publishes an event.
- Every subscribed client receives the update.
Pseudo implementation:
publish("MESSAGE_RECEIVED", chatMessage);
Subscription:
subscription {
messageReceived {
id
text
sender {
username
}
}
}
Every connected client immediately receives updates without polling.
Handling Authentication
Most chat applications require authenticated users.
One common strategy is passing a token during the initial WebSocket handshake.
Example:
const socket = new WebSocket(
"ws://localhost:8080?token=JWT_TOKEN"
);
The server validates the token before accepting the connection.
If validation fails:
Connection Rejected
If successful:
Connection Accepted
Only authenticated users can join the chat.
Managing Connected Users
Keeping track of active users enables useful features such as online indicators.
Example:
const users = new Map();
server.on("connection", socket => {
users.set(socket, {
username: "Alice"
});
});
Disconnects remove users:
socket.on("close", () => {
users.delete(socket);
});
The server can broadcast presence updates whenever users connect or disconnect.
Persisting Messages
Real chat systems usually save messages in a database.
Example object:
const message = {
id: 15,
sender: "Alice",
text: "Welcome!",
timestamp: Date.now()
};
Store it before broadcasting.
Save Database
│
▼
Broadcast
│
▼
Clients
This ensures new users can retrieve chat history.
Handling Large Numbers of Connections
Thousands of simultaneous users require careful design.
Several optimization strategies include:
- Horizontal scaling
- Load balancing
- Distributed message brokers
- Stateless WebSocket gateways
- Connection pooling
Large deployments often separate WebSocket servers from business logic.
Incoming events travel through distributed messaging systems before reaching subscribers.
Error Handling
Unexpected failures should never terminate the chat session.
Example:
socket.onerror = error => {
console.error(error);
};
Server-side validation should also reject malformed messages.
Example:
if (!message.text) {
return;
}
Strong typing generated from GraphQL and Protocol Buffers greatly reduces invalid payloads before they reach production.
Reconnection Strategy
Internet connections occasionally fail.
Clients should reconnect automatically.
Example:
function connect() {
const socket =
new WebSocket("ws://localhost:8080");
socket.onclose = () => {
setTimeout(connect, 3000);
};
}
connect();
This approach improves user experience without requiring manual refreshes.
Improving Performance
Several optimizations can make the chat system faster.
Compress payloads where appropriate.
Avoid unnecessary broadcasts.
Batch multiple events together.
Reuse Protocol Buffer objects when possible.
Generate typed models instead of manually parsing JSON.
Reduce allocations during serialization.
Together these optimizations can substantially improve throughput under heavy traffic.
Testing the Chat System
Testing should include both functional and performance scenarios.
Functional tests verify:
- User authentication
- Message delivery
- Binary serialization
- GraphQL subscriptions
- Reconnection
- Validation
Performance testing should simulate hundreds or thousands of simultaneous clients.
Important metrics include:
- Latency
- Memory consumption
- CPU usage
- Network throughput
- Average response time
- Failed connections
Automated testing helps identify bottlenecks long before deployment.
Security Considerations
Real-time applications are attractive targets for abuse and therefore require multiple layers of security. Always validate incoming messages against expected schemas before processing them. Enforce authentication and authorization for every connection, not just the initial login. Consider implementing message size limits to prevent excessive memory consumption, rate limiting to reduce spam and denial-of-service attempts, and encryption through secure WebSocket (WSS) connections to protect data in transit. Logging suspicious behavior and monitoring connection patterns can also help detect attacks early. By combining secure transport, strict validation, and access control, developers can significantly reduce the attack surface of a live chat platform.
Extending the Chat Application
Once the core functionality is complete, additional features can greatly improve the user experience. Typing indicators allow users to see when someone is composing a message, while read receipts provide confirmation that messages have been viewed. File sharing, emoji reactions, threaded conversations, and message editing are all natural extensions of the architecture presented in this article. Because both the GraphQL schema and the Protocol Buffer definitions serve as shared contracts, introducing these features primarily involves extending the existing models and regenerating the typed clients rather than manually updating interfaces across multiple codebases.
Conclusion
Building a modern live chat application is much easier when using a combination of the Core WebSocket API, GraphQL schemas, and Protocol Buffers. Each technology addresses a specific challenge: WebSockets provide persistent, low-latency communication, GraphQL offers a strongly typed and expressive contract for the application’s data model, and Protocol Buffers deliver compact, efficient binary serialization that minimizes bandwidth usage and improves performance.
One of the greatest advantages of this approach is the elimination of inconsistencies between the client and server. Instead of maintaining separate models in multiple languages, developers define their data structures once in a GraphQL schema and a Proto file, then generate typed clients automatically. This reduces duplicated effort, improves IDE support through autocomplete and compile-time checking, and significantly decreases the likelihood of runtime errors caused by mismatched payloads or incorrect property names.
The architecture also scales well as applications grow. Features such as user authentication, online presence, typing indicators, message persistence, read receipts, and file sharing can all be introduced by extending the existing schemas and regenerating the typed classes. Because every component shares the same contracts, maintaining large codebases becomes considerably easier than with loosely typed JSON-based implementations.
Performance is another compelling reason to adopt this design. Persistent WebSocket connections eliminate the overhead of repeated HTTP requests, while Protocol Buffers reduce payload size and parsing costs. Together, they enable responsive communication even under heavy traffic. Combined with proper error handling, reconnection logic, horizontal scaling, load balancing, and secure WebSocket connections, the resulting chat platform can reliably support large numbers of concurrent users.
Ultimately, the combination of the Core WebSocket API, GraphQL-generated typed clients, and Protocol Buffer-generated models represents a modern, scalable, and maintainable solution for real-time messaging. By investing in strong typing, efficient serialization, and a clear architectural separation of responsibilities, developers can create live chat systems that are easier to build, simpler to maintain, more resilient to change, and capable of delivering the fast, seamless communication experiences that today’s users expect.