The earlier post’s minimal sample was already broad enough to cover Streaming Resources well and dive into their semantic nuances. In this piece, I’d like to push further and offer something far more practical. This is a basic chat client, giving us room to explore several additional points.

From a technical standpoint, this chat example diverges from the Timer in one key way: every incoming message matters, not merely the newest one. That distinction introduces a modest hurdle in the signal-driven architecture, yet it remains solvable.
The chat server relies on a fork of a Mozilla Developer Network sample project. This Node-based server ships within the sample repository, and its readme.md outlines how to launch it.
We owe a debt of gratitude to Alex Rickabaugh on the Angular team, who reviewed the examples and engaged in valuable conversations about how Streaming Resources should be applied to message handling.
The content here reflects updates aligned with the changes to the—at the time—experimental API introduced in Angular 20. Angular 19 features some subtle discrepancies.
Consumer's Perspective
Within our setup, the Angular client kicks off the chat via the chatConnection factory. That factory supplies a structure that represents each chat message as a streaming resource, while also exposing supplementary Signals and a send function:
@Component([…])
export class ChatResourceComponent {
ResourceStatus = ResourceStatus;
userName = signal('');
chat = chatConnection('ws://localhost:6502', this.userName);
messages = computed(() => this.chat.resource.value() ?? []);
userNameInField = linkedSignal(() => this.chat.acceptedUserName());
currentMessage = signal<string>('');
send() {
this.chat.send(this.currentMessage());
this.currentMessage.set('');
}
join() {
this.userName.set(this.userNameInField());
}
}
The userName Signal is what triggers the Resource via chatConnection, opening a link to the chat server. Input fields are connected to the userNameInField and currentMessages Signals. Calling the join function assigns the contents of userNameInField to the userName Signal, thus establishing the connection.
The server might alter the requested username to guarantee it is unique. Because of this, userNameInField relies on linkedSignal. This setup lets the Linked Signal replace the locally stored value with the server-corrected one, delivered via the accpetedUserName Signal.
For sending messages, the app hands the currentMessage to the send method. After that, currentMessage is cleared so the user is ready to type the next one.
The Glitch-Free Property as a Hurdle for Streaming Resources
There is a key difference between the chat scenario and the timer from the earlier article: the timer only cared about the stream's latest piece of data, whereas the chat needs every message that has arrived, or at least those within a given timeframe. So the timer is a single entity shifting over time, but the chat comprises numerous events whose complete set must be processed.
Examining the timer from this angle suits Signals nicely, as they always reflect the newest value. The glitch-free property—where superfluous intermediate values are skipped—also works here: if the Resource shifted the timer from 0 to 1, then 1 to 2, and 2 to 3 within one task, the consumer would see only 3.
Yet, this same behavior would be disastrous for an event-driven setup like the chat, causing individual messages to vanish. Testing this is straightforward: have the chat server dispatch the same message repeatedly to one client. In a system that focuses only on the final received message, most of these duplicates would be dropped.
This highlights that Signals, unlike Observables in RxJS, are not ideal for depicting events or messages! That is a real hurdle for streaming resources, but we can get past it by shifting our perspective: if the Resource is set to represent not just the most recent value but all messages collected so far—or those within a relevant period—then this entire collection can again be treated as a time-varying value.
Simply put, we must push the responsibility for gathering and handling individual messages onto the Resource. This approach aligns with the earlier article's observation that the Signals ecosystem favors use-case-specific, broader building blocks.
More on this: Angular Architecture Workshop (Remote, Interactive, Advanced)
Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!
English Version | German Version
Message Types and the Underlying Protocol
For every message that flows between the client and the chat server, the example declares a distinct type. These types are outlined here:
export type ChatRequest =
| {
type: 'username';
id: number;
name: string;
}
| {
type: 'message';
id: number;
text: string;
};
export type ChatResponse =
| {
type: 'id';
id: number;
}
| {
type: 'username';
id: number;
name: string;
}
| {
type: 'message';
id: number;
name: string;
text: string;
};
The communication flow between the client and the server is structured like this:
- The client initiates a web socket connection.
- A ChatResponse with the type id is sent back by the server, providing the client with a distinct session identifier.
- To share the user's name, the client transmits a ChatRequest that has the type username.
- The server validates this username by responding with a ChatResponse of type username. In cases where the requested username is unavailable, the client gets an adjusted username through this message; this corrected name is generated by the server appending a numerical suffix to the original one.
- Messages are sent by the client as ChatRequests with the type message). The server then broadcasts this exact content to every connected client, delivering it as a ChatResponse that also uses the type message.
Representation of the Chat
The example featuring the type ChatConnection demonstrates how the connection to the chat is modeled:
export type SendFn = (message: string) => void;
export type ChatConnection = {
resource: ResourceRef<ChatResponse[] | undefined>;
connected: () => boolean;
acceptedUserName: () => string;
send: SendFn;
};
The ChatConnection revolves around a Resource that holds every message received so far, packed into an array. Alongside it, supplementary status details are exposed as signals, such as whether the connection is active (connected) and the username (acceptedUserName) that the server may adjust when needed.
To let the chat transmit messages to the server, the send function is provided. Its implementation takes the incoming string and transforms it into a ChatRequest categorized as message.
Chat Factory
Building the chat's factory follows a pattern akin to the timer factory from earlier. The difference lies in a few extra variables that are set up at the start:
export function chatConnection(
websocketUrl: string,
userName: () => string
): ChatConnection {
let connection: WebSocket;
const connected = signal(false);
const id = signal(0);
const acceptedUserName = signal('');
const params = computed(() => ({
userName: userName(),
}));
const chatResource = resource({
params,
stream: async (loaderParams) => {
// init web socket connection
// handle and collect messages
[…]
},
});
const send: SendFn = (message: string) => {
const request: ChatRequest = {
type: 'message',
id: id(),
text: message,
};
connection.send(JSON.stringify(request));
};
return {
connected,
resource: chatResource,
acceptedUserName,
send,
};
}
Among these variables are the connection, which represents the web socket link that the Streaming Loader establishes, along with the connected and accpetedUserName Signals mentioned earlier, plus an id Signal that tracks the user's current session. Ultimately, the factory exports a subset of these Signals, the instantiated Streaming Resource, and the send method wrapped as a ChatConnection. The id Signal remains hidden, since it is only relevant for internal logic.
For the resource's streaming loader, it starts by initializing an Array messages to accumulate the incoming chat messages:
const chatResource = resource({
request,
stream: async (loaderParams) => {
const userName = loaderParams.params.userName;
let messages: ChatResponse[] = [];
// 1. Create Signal representing the Stream
const resultSignal = signal<ResourceStreamItem<ChatResponse>>({
value: messages,
});
if (!userName) {
return resultSignal;
}
// 2. Set up async logic updating the Signal
connection = new WebSocket(websocketUrl, 'json');
connection.addEventListener('open', (event) => {
console.log('[open]');
connected.set(true);
});
connection.addEventListener('message', (event) => {
const value = JSON.parse(event.data) as ChatResponse;
console.log('[message]', value);
if (value.type === 'id') {
id.set(value.id);
sendUserName(value.id, userName, connection);
}
if (value.type === 'username' && value.id == id()) {
acceptedUserName.set(value.name);
}
if (value.type === 'message' || value.type === 'username') {
messages = [...messages, value];
resultSignal.set({ value: messages });
}
});
connection.addEventListener('error', (event) => {
const error = new Error(Error with websocket connection);
console.log('[event]', event);
resultSignal.set({ error });
});
// 3. Set up clean-up handler triggered by AbortSignal
params.abortSignal.addEventListener('abort', () => {
console.log('clean up!');
connection.close();
connected.set(false);
id.set(0);
acceptedUserName.set('');
});
// 4. Return Signal
return resultSignal;
},
});
function sendUserName(id: number, userName: string, connection: WebSocket) {
const message: ChatRequest = {
type: 'username',
id: id,
name: userName,
};
connection.send(JSON.stringify(message));
}
The remaining logic aligns with the four principles outlined in the earlier section. The prepared signal mirrors the incoming stream and holds the managed array of delivered messages.
Data from the server is processed through the designated event listeners. The message handler implements the protocol described previously. Every received message is appended to the messages array. This operation must be performed immutably—through a shallow clone—so that the Signal detects the update.
The open listener assigns true to connected, while the error listener pushes the current error onto the stream. The AbortSignal’s abort listener terminates the web socket connection and restores the managed properties to their defaults. Concluding, the Streaming Loader returns the signal that represents the stream in the standard manner.
Conclusion
Angular Signals only ever hold the current value. Transient intermediate states from successive changes within the same task are omitted, a property known as Glitch Free. This behavior suits volatile state like a counter, yet it risks missing data in event sequences—for instance, a chat where every message must be shown.
This issue is addressed by gathering all incoming messages (within the relevant time frame) inside the Resource and exposing the resulting array through it. That array then acts as a value that evolves over time.
Although this pattern offers an intriguing option, alternative tools like RxJS, which model event streams directly, remain perfectly valid choices.
