Angular 19 SSE using EventSource
Server-Sent Events (SSE) is a web technology that enables a server to automatically push real-time text-based updates to a client (such as a web browser) over a single, persistent HTTP connection. The client uses the EventSource JavaScript interface to receive these updates.
In zoneless Angular, async APIs like EventSource don’t trigger change detection. We use signals to store streamed data and manually call ApplicationRef.tick() — ideally batched — to update the UI. This gives us predictable performance and avoids zone-related overhead.
✅ Zoneless Angular SSE Service
✔ No NgZone
✔ No recursive ApplicationRef.tick()
✔ Batched updates
✔ Signal-based
✔ Production-ready
“SSE events don’t trigger Angular change detection, so signal updates alone don’t re-render. We manually schedule change detection.”
import {
Injectable,
signal,
ApplicationRef,
OnDestroy,
inject,
} from '@angular/core';
export interface SseMessage {
id?: string;
data: any;
}
@Injectable({ providedIn: 'root' })
export class SseService implements OnDestroy {
private appRef = inject(ApplicationRef);
private eventSource?: EventSource;
// ===== SIGNAL STATE =====
readonly messages = signal<SseMessage[]>([]);
readonly status = signal<'idle' | 'connected' | 'error' | 'disconnected'>(
'idle'
);
// ===== INTERNAL BUFFER =====
private buffer: SseMessage[] = [];
private flushTimer?: number;
// ===== TICK CONTROL (CRITICAL) =====
private tickScheduled = false;
// ===== CONNECT =====
/**
EventSource
↓
buffer.push()
↓ (every 100ms)
signal.update()
↓
queueMicrotask()
↓
ApplicationRef.tick() ← ONCE
In zoneless Angular, SSE runs completely outside Angular. We batch events into signals and schedule ApplicationRef.tick() asynchronously to avoid recursive change detection.
*/
connect(url: string, options?: { withCredentials?: boolean }) {
this.disconnect();
this.eventSource = new EventSource(url, {
withCredentials: options?.withCredentials ?? true,
});
this.eventSource.onopen = () => {
this.status.set('connected');
this.scheduleTick();
};
this.eventSource.onmessage = (event) => {
// ❗ NO Angular calls here
this.buffer.push({
id: event.lastEventId || undefined,
data: this.safeParse(event.data),
});
};
this.eventSource.onerror = () => {
this.status.set('error');
this.scheduleTick();
};
this.startFlushLoop();
}
// ===== FLUSH BUFFER (BATCHED) =====
private startFlushLoop() {
this.flushTimer = window.setInterval(() => {
if (!this.buffer.length) return;
const batch = [...this.buffer];
this.buffer.length = 0;
this.messages.update((m) => [...m, ...batch]);
this.scheduleTick(); // ✅ SAFE
}, 100);
}
// ===== SAFE MANUAL CHANGE DETECTION =====
private scheduleTick() {
if (this.tickScheduled) return;
this.tickScheduled = true;
queueMicrotask(() => {
this.tickScheduled = false;
this.appRef.tick(); // ✅ NEVER recursive
});
}
// ===== DISCONNECT =====
disconnect() {
this.eventSource?.close();
this.eventSource = undefined;
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
this.status.set('disconnected');
this.scheduleTick();
}
ngOnDestroy() {
this.disconnect();
}
private safeParse(data: string) {
try {
return JSON.parse(data);
} catch {
return data;
}
}
}👇Why Signals alone are NOT sufficient ? ApplicationRef.tick() will be required ?
yes — in zoneless Angular, signals alone are NOT sufficient.
ApplicationRef.tick() (or something equivalent) is required.
1️⃣ Why signals feel like they should be enough (but aren’t)
Signals do trigger change detection…
⚠️ only when Angular knows “something happened.”
In zone-based Angular:
Browser async event
↓ (zone.js patches it)
Angular is notified
↓
Signals update → UI updates automaticallySo you never think about it.
2️⃣ What changes in zoneless Angular
When you remove Zone.js:
EventSource / setTimeout / WebSocket
↓
Angular is NOT notified
↓
Signals update silently
❌ UI does NOT re-renderSignals update state, but Angular never runs change detection.
Get Piyali Das 🧡🤍💚’s stories in your inbox
Join Medium for free to get updates from this writer.
👉 Signals are reactive, not magical.
3️⃣ The rule (this is the key insight)
Signals update data
Change detection updates the DOM
In zoneless Angular:
- Angular will not run CD on its own
- You must explicitly tell Angular to render
4️⃣ When ApplicationRef.tick() is required
You need tick() when ALL of these are true:
| Condition | Value |
| ---------------- | ----------------------------- |
| Zoneless Angular | ✅ |
| Async source | SSE / WebSocket / setInterval |
| Outside Angular | Browser API |
| Updating signals | ✅ |Only when change detection runs again:
ApplicationRef.tick()During that tick:
- Dirty views are checked
- Templates re-run
- Signals are re-read
- DOM updates
➡️ tick is mandatory
5️⃣ Why not ChangeDetectorRef.detectChanges()?
❌ detectChanges()
- Requires component reference
- Breaks easily with lazy / routed / destroyed views
- Hard to scale across services
✅ ApplicationRef.tick()
- Global
- Safe for services
- Works across routes
- Official zoneless pattern
Angular core team explicitly recommends it for zoneless apps.
6️⃣ Why NOT call tick() directly every time?
Because this happens 💥:
tick()
↳ CD runs
↳ signal updates
↳ tick()
↳ NG0101 💀That’s why you must schedule it asynchronously:
queueMicrotask(() => appRef.tick());This avoids recursive change detection.
7️⃣ Is there ANY way to avoid tick()?
✅ Yes — but only in very specific cases
| Case | tick needed? |
| ------------------------ | ------------ |
| Click / input event | ❌ |
| Router navigation | ❌ |
| HttpClient | ❌ |
| EventSource / WebSocket | ✅ |
| setInterval / setTimeout | ✅ |Why?
Because Angular already runs CD for framework-managed events.
Zone-based Angular:
async → CD → signals → DOM
Zoneless Angular:
async → signals → ❌ DOM
↑
tick() neededNow main question is “Why needs Change Detection using appRef.tick() when we have used Signals ??? “
✅ Your statement (100% correct)
“Here no Angular event is firing, that’s why change detection is not running. So we have to manually trigger CD.”
✔️ Yes. That is precisely what’s happening.
Signals notify Angular only if Angular is already running change detection.
They do NOT start change detection on their own. Signals are reactive state, not a scheduler.
✅ Why this happens (step-by-step)
✔️ EventSource fires
- Browser-native API
- Angular does not know it happened
✔️ Signal value changes
- State is updated
- Angular is not currently rendering
✔️ Template is not re-evaluated
- Signals are not re-read
- DOM stays the same
✔️ Solution
- Manually tell Angular:
👉 “Hey, re-render now”
That’s what ApplicationRef.tick() does.
“Signals update state, not the DOM.
In zoneless Angular, we must explicitly trigger rendering for async sources like SSE.”
Angular event (no manual CD)
<button (click)="add()">Add</button>add() {
this.messages.update(m => [...m, 'new']);
}Why it works:
(click)is an Angular-managed event- CD already runs
- Signal is read → DOM updates
Browser event (manual CD required)
eventSource.onmessage = () => {
this.messages.update(m => [...m, 'new']);
};Why it doesn’t:
- Angular never scheduled CD
- Signal updated in isolation
- DOM never re-renders
Mental model (lock this in)
Angular renders ONLY when it decides to render
Signals don’t decide that
Events doSSE ≠ Angular event.
Why Angular does NOT auto-detect signal writes
If Angular re-rendered on every signal write:
- Infinite loops would be easy
- Performance would collapse
- Zoneless would be pointless
So Angular keeps:
- State change (signals)
- Render scheduling (CD)
separate.
When Zoneless + SSE is a GREAT idea
| Scenario | Recommendation |
| ---------------------- | ---------------------- |
| AI streaming | ✅ Excellent |
| Dashboards | ✅ Excellent |
| High-frequency updates | ✅ With batching |
| Forms / CRUD apps | ❌ Overkill |
| Small apps | ❌ Not worth complexity |








