Server-Sent Events (SSE), sunucunun tek bir uzun ömürlü HTTP yanıtı üzerinden tarayıcıya olay akışı göndermesini sağlayan web standardıdır. Tarayıcıdaki EventSource API’si bağlantıyı açar, gelen olayları ayrıştırır ve bağlantı koparsa yeniden kurar. Yalnız sunucudan istemciye veri akışı gereken durumlarda WebSocket’ten daha sade bir seçenektir ve mevcut HTTP altyapısıyla doğrudan çalışır.
Aşağıda olay akışının biçimi, Node.js ile bir sunucu ve tarayıcı istemcisi, Last-Event-ID ile kaldığı yerden devam etme, proxy tamponlama ve kimlik doğrulama tuzakları ile SSE’nin WebSocket’e göre ne zaman tercih edileceği ele alınıyor.
SSE hangi sorunu çözer?
Klasik istek-yanıt modelinde istemci yeni veri olup olmadığını öğrenmek için sunucuya sık sık sorar (polling). Bildirim, ilerleme durumu veya canlı ölçüm gibi tek yönlü akışlarda bu tekrar eden istekler hem gecikme hem gereksiz yük üretir.
SSE’de istemci tek bir istek açar, sunucu Content-Type: text/event-stream ile yanıt verir ve yanıtı kapatmadan yeni olayları yazmaya devam eder. Bağlantı koparsa tarayıcı belirli bir süre bekleyip yeniden bağlanır ve aldığı son olayın kimliğini sunucuya bildirir. Kaldığı yerden devam etmek için sunucunun olayları kimlikleriyle saklaması gerekir.
SSE, WebSocket ve long polling
Long polling: İstemci istek açar, sunucu veri oluşana ya da zaman aşımına (timeout) kadar yanıtı bekletir; yanıttan sonra yeni istek gerekir. Bağlantı sürelerinin sınırlandığı ortamlarda hâlâ geçerli bir uyumluluk seçeneğidir.
WebSocket: İki yönlü ve eş zamanlı (full-duplex) mesajlaşma sağlar. İstemciden sunucuya da sürekli olay akan ortak düzenleme, çok oyunculu oyun veya sohbet gibi senaryolarda daha uygundur; bağlantı durumu ve geri basınç (backpressure) yönetimi uygulamaya kalır.
SSE: Sunucudan istemciye tek yönlüdür, düz HTTP üzerinde çalışır ve tarayıcının yerleşik EventSource API’sini kullanır. Otomatik yeniden bağlanma ve olay kimliği standarda dahildir; buna karşın kimlik doğrulama, proxy tamponlama ve açık bağlantı sınırları ayrıca ele alınmalıdır.
Ne zaman SSE kullanılır?
- Canlı bildirimler: yeni mesaj, yorum veya sipariş durumu
- İlerleme durumu: dosya işleme, rapor üretme gibi uzun süren işler
- Akış hâlinde metin: büyük dil modeli yanıtlarının parça parça gösterilmesi
- Canlı veri: skor, fiyat veya ölçüm güncellemeleri
- İzleme panoları: sunucu metrikleri ve log akışı
İstemcinin sunucuya yalnız arada bir veri göndermesi gerekiyorsa SSE ile normal HTTP istekleri birlikte kullanılabilir: akış SSE’den gelir, kullanıcı eylemleri ayrı POST istekleriyle gider. İstemciden sunucuya da sık ve düşük gecikmeli mesaj gerekiyorsa WebSocket daha doğru seçimdir.
Olay akışının biçimi
Akış UTF-8 kodlu düz metindir. Her olay bir veya daha fazla alan: değer satırından oluşur ve boş bir satırla biter:
: iki nokta ile başlayan satır yorumdur, heartbeat için kullanılır
retry: 5000
id: 41
data: Sipariş alındı
id: 42
event: notification
data: {"title": "Yeni mesaj"}
id: 43
data: birinci satır
data: ikinci satır
data: Olayın içeriği. Art arda gelendatasatırları aralarına satır sonu konarak birleştirilir.event: Olay türü. Verilmezse olaymessagetüründe teslim edilir.id: Olay kimliği. Tarayıcı bunu saklar ve yeniden bağlanırkenLast-Event-IDbaşlığıyla geri gönderir.retry: Bağlantı koptuğunda yeniden bağlanmadan önce beklenecek süre (milisaniye).:ile başlayan satır yorumdur ve yok sayılır; bağlantıyı canlı tutmak için kullanılır.
Alan adları büyük-küçük harfe duyarlıdır, tanınmayan alanlar yok sayılır. JSON gibi tek satırlık veriler doğrudan data: satırına yazılabilir; satır sonu içeren metin birden çok data: satırına bölünmelidir.
Node.js ile sunucu örneği
Aşağıdaki Express sunucusu her saniye bir olay yayımlar, olaylara kimlik verir, yeniden bağlanan istemciye kaçırdığı olayları gönderir ve 15 saniyede bir heartbeat yazar.
// server.js: Basitleştirilmiş örnek. Olay geçmişi bellekte tutulur;
// gerçek sistemde kalıcı bir depo veya mesaj aracısı kullanılır.
const express = require("express");
const app = express();
const clients = new Set();
const history = [];
let lastId = 0;
function send(res, event) {
res.write(`id: ${event.id}\ndata: ${event.data}\n\n`);
}
function publish(payload) {
const event = { id: ++lastId, data: JSON.stringify(payload) };
history.push(event);
if (history.length > 1000) history.shift(); // son 1000 olayı tut
for (const res of clients) send(res, event);
}
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", // nginx bu yanıtı tamponlamasın
});
res.write("retry: 5000\n\n"); // kopmadan sonra 5 saniye bekle
// Yeniden bağlanan tarayıcı son aldığı olay kimliğini bu başlıkla gönderir
const lastEventId = req.get("Last-Event-ID");
if (lastEventId !== undefined) {
const since = Number(lastEventId);
for (const event of history) {
if (event.id > since) send(res, event);
}
}
clients.add(res);
// Boşta kalan bağlantıyı proxy'lerin kapatmaması için yorum satırı yaz
const heartbeat = setInterval(() => res.write(": ping\n\n"), 15000);
// İstemci ayrıldığında zamanlayıcıyı ve kaydı temizle
res.on("close", () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
setInterval(() => publish({ time: new Date().toISOString() }), 1000);
app.listen(3000);
Ayrılan istemciyi tespit etmek için res.on("close") kullanılır. Node.js 16’dan beri istek nesnesindeki close olayı alttaki bağlantı kapandığında değil, istek tamamlandığında tetiklenir; bu yüzden req.on("close") uzun ömürlü akışlarda temizliği yanlış zamanda çalıştırabilir.
Örnekteki olay geçmişi ve sayaç bellekte durduğu için sunucu yeniden başlayınca kimlikler sıfırlanır. Gerçek sistemde kimlikler ve kısa süreli olay geçmişi kalıcı bir depodan ya da mesaj aracısından gelmelidir.
Tarayıcı tarafı:
const source = new EventSource("/events");
// event alanı olmayan olaylar
source.onmessage = event => {
console.log("Olay:", event.lastEventId, event.data);
};
// event: notification olarak gönderilen olaylar
source.addEventListener("notification", event => {
const payload = JSON.parse(event.data);
console.log("Bildirim:", payload.title);
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
// Kalıcı kapanma: tarayıcı yeniden denemeyecek
console.error("Akış kapandı");
} else {
// CONNECTING: tarayıcı kendisi yeniden bağlanıyor
console.warn("Bağlantı koptu, yeniden deneniyor");
}
};
Yeniden bağlanma ve kalıcı kapanma
Ağ hatasında veya sunucu bağlantıyı kapattığında EventSource en son retry değeri kadar bekler ve aynı adrese yeniden bağlanır. Son olay kimliği Last-Event-ID başlığıyla gönderildiği için sunucu kaçırılan olayları tekrar yollayabilir. Tarayıcı bu yeniden bağlanmayı kendisi yapar; uygulamanın ayrıca kod yazması gerekmez.
Bazı durumlarda ise bağlantı kalıcı olarak kapanır ve tarayıcı yeniden denemez:
- Sunucu
200dışında bir durum kodu döndürürse (ör. süresi dolan oturum için401, ya da500). - Yanıtın
Content-Typedeğeritext/event-streamdeğilse. - Sunucu akışı bilerek bitirmek için
204 No Contentdöndürürse. - İstemci
source.close()çağırırsa.
Bu durumda readyState değeri CLOSED olur. Kimlik doğrulama hatasından sonra akışı sürdürmek isteyen uygulama yeni kimlik bilgileriyle yeni bir EventSource oluşturmalıdır.
Proxy, sıkıştırma ve bağlantı sınırları
SSE, yanıtın parça parça istemciye ulaşmasına dayanır. Araya giren her katman yanıtı tamponlarsa olaylar gecikir ya da hiç gelmez.
- Ters proxy: nginx proxy’lenen yanıtları varsayılan olarak tamponlar (
proxy_buffering on). YanıtaX-Accel-Buffering: nobaşlığı eklemek ya da ilgililocationiçinproxy_buffering offayarlamak tamponlamayı kapatır. - Zaman aşımı: nginx’in
proxy_read_timeoutvarsayılanı 60 saniyedir; bu sürede veri gelmezse bağlantı kapatılır. Spesifikasyon, bu tür proxy’lere karşı yaklaşık 15 saniyede bir yorum satırı göndermeyi önerir. - Sıkıştırma: Express’in
compressionara katmanı iyi sıkıştırma için çıktıyı tamponladığından SSE ile doğrudan çalışmaz. Akış rotasını sıkıştırmanın dışında tutun ya da her yazmadan sonra ara katmanın eklediğires.flush()çağrısını yapın. - CDN: Akış önbelleğe alınacak bir içerik değildir. Araya bir CDN giriyorsa yanıtları tamponlamadan geçirdiğini ve uzun süren bağlantılara izin verdiğini sağlayıcının belgesinden doğrulayın.
- Bağlantı sınırı: HTTP/1.1 üzerinde tarayıcılar aynı alan adına aynı anda en fazla 6 bağlantı açar ve bu sınır tüm sekmeler arasında paylaşılır. HTTP/2’de aynı bağlantı üzerinden açılabilecek akış sayısı sunucu ile istemci arasında belirlenir (varsayılan 100).
Aynı tamponlama sorunu server-side rendering’deki streaming yanıtlarında da görülür; her iki durumda da proxy ve sıkıştırma ayarları birlikte kontrol edilmelidir.
Ölçekleme
Her SSE bağlantısı, istemci ayrılana kadar bir sunucu örneğine bağlı kalır. Birden fazla sunucu örneği çalıştığında bir olayın tüm istemcilere ulaşması için olaylar ortak bir kanaldan dağıtılmalıdır: Redis pub/sub veya RabbitMQ gibi bir mesaj aracısı her örneğe olayın bir kopyasını iletir, örnek de bunu kendi bağlantılarına yazar.
Yeniden bağlanan istemci farklı bir örneğe düşebilir. Last-Event-ID ile kaçırılan olayları gönderebilmek için olay geçmişi ve kimlikler örnekler arasında paylaşılan bir yerde tutulmalıdır.
Kimlik doğrulama ve güvenlik
EventSource yalnız GET isteği açar ve Authorization gibi özel başlık eklemeye izin vermez. Bu yüzden kimlik doğrulama için şu seçenekler kalır:
- Cookie: Aynı kökenden (same-origin) açılan akışta oturum cookie’si otomatik gönderilir. Cookie
HttpOnly,Secureve uygunSameSitedeğeriyle ayarlanmalıdır. Farklı köken içinnew EventSource(url, { withCredentials: true })ve sunucuda kimlik bilgisine izin veren CORS başlıkları gerekir. - Kısa ömürlü bilet: Uzun ömürlü erişim token’ını sorgu dizesine (
/events?token=...) koymayın. URL’ler sunucu, proxy ve CDN erişim günlüklerine, izleme araçlarına ve hata raporlarına olduğu gibi yazılır; token bu kayıtlara sızar. Sorgu dizesi kaçınılmazsa istemci önce kimliği doğrulanmış birPOSTile birkaç saniye geçerli, tek kullanımlık bir bilet alır; sunucu bileti akışı açarken doğrular ve hemen geçersiz kılar. - fetch ile okuma:
fetchveReadableStreamile akışı kendiniz ayrıştırırsanızAuthorizationbaşlığı vePOSTkullanılabilir. Bu durumda yeniden bağlanma veLast-Event-IDgönderimi uygulamanın işidir.
Bunlara ek olarak akışı yalnız HTTPS üzerinden sunun, kullanıcı başına açık bağlantı sayısını ve bağlantı açma hızını sınırlayın.
SSE’nin sınırları
- Tek yön: Yalnız sunucudan istemciye akar; istemci veri göndermek için ayrı HTTP istekleri kullanır.
- Yalnız metin: Akış UTF-8 metindir. İkili veri Base64 ile kodlanırsa boyut yaklaşık üçte bir artar.
- İstek biçimi:
EventSourceyalnızGETyapar ve özel başlık desteklemez. - Bağlantı sayısı: HTTP/1.1 üzerinde alan adı başına 6 bağlantı sınırı birden çok sekmede sorun çıkarır; HTTP/2 bu sorunu büyük ölçüde giderir.
Gerçek dünyada kullanım
Büyük dil modeli API’leri SSE’nin bugün en görünür kullanımlarından biridir. Anthropic’in Messages API’si "stream": true verildiğinde yanıtı SSE olarak gönderir; olaylar message_start, content_block_delta gibi event: adları taşır ve araya ping olayları girer. OpenAI’nin akış modu da SSE kullanır. Bu API’ler isteği POST ile açtığı için tarayıcıda EventSource yerine fetch ile okunur ya da sağlayıcının SDK’sı kullanılır. Bu akışı kendi backend’iniz üzerinden aktarırken kullanımı ve maliyeti doğru ölçmek ayrı bir sorundur; LLM API gateway yazısı olay sırasını, kümülatif kullanım alanlarını ve yarım kalan akışların faturalanmasını ele alıyor.
SSE mi WebSocket mi?
SSE seçin:
- Veri yalnız sunucudan istemciye akıyorsa
- Mevcut HTTP altyapısı (proxy, yük dengeleyici, güvenlik duvarı) değiştirilmeden kullanılacaksa
- Otomatik yeniden bağlanma ve olay kimliği yeterliyse
- Gönderilen veri metin ya da JSON ise
WebSocket seçin:
- İstemciden sunucuya da sık mesaj gidiyorsa
- İkili veri aktarılacaksa
- Uygulamaya özgü, iki yönlü bir protokol tasarlanacaksa
Diğer seçenekler
WebTransport: HTTP/3 üzerinde çalışır; tek bağlantıda çok sayıda akış ve güvenilmez (UDP benzeri) datagram sunar. MDN’e göre Mart 2026’dan beri güncel ana tarayıcılarda desteklenir (Baseline 2026); bazı alt özelliklerin desteği farklılık gösterebilir ve sunucu tarafında HTTP/3 gerekir.
gRPC-Web: Tarayıcıdan gRPC servislerine erişim sağlar. Unary çağrıları ve yalnız grpcwebtext modunda sunucu akışını destekler; istemci ve çift yönlü akış desteklenmez. Araya Envoy gibi bir proxy gerekir.
GraphQL abonelikleri: GraphQL bir taşıma protokolü tanımlamaz; abonelikler WebSocket veya SSE üzerinden çalışan kütüphanelerle uygulanır.
Sonuç
SSE, sunucudan istemciye tek yönlü veri akışı gereken bildirim, ilerleme durumu, canlı pano ve model yanıtı akışı gibi senaryolarda az kodla güvenilir bir çözüm sunar. Olay kimliği, heartbeat ve tamponlamasız proxy ayarı olmadan bu güvenilirlik kâğıt üzerinde kalır. İstemciden sunucuya da yoğun mesaj gerekiyorsa ya da ikili veri taşınacaksa WebSocket veya WebTransport daha uygundur.