Mert Özen Her Satırda Daha İleri
Backend Real-time Uygulamalar — #01

Real-time Seçenekleri: Polling, Long Polling, Server-Sent Events, WebSocket ve SignalR — Hangisi Ne Zaman?

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Real-time Seçenekleri: Polling, Long Polling, Server-Sent Events, WebSocket ve SignalR — Hangisi Ne Zaman?

Tek yönlü bildirim için SSE, çift yönlü düşük gecikme için WebSocket ya da SignalR, nadiren değişen veri için polling yeterli. Aynı sipariş durumunu beş teknikle yapıp gecikmeyi, istek sayısını ve bağlantı başına belleği ölçüyoruz; karar tablosu, WebTransport'un durumu ve neden her şeyi real-time yapmamak gerektiği.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Microsoft.AspNetCore.SignalR.Client 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'da bir sipariş 15 saniye içinde beş kez durum değiştiriyor: Received, Packed, Shipped, OutForDelivery, Delivered. Aynı siparişi beş ayrı istemci izliyor. Biri her 2 saniyede bir soruyor (polling), biri sorusunu açık tutup cevabı bekliyor (long polling), biri tek bir HTTP (Hypertext Transfer Protocol) yanıtını açık tutuyor (SSE, Server-Sent Events), biri WebSocket açmış, sonuncusu SignalR kullanıyor. Sence 2 saniyelik polling bu beş değişikliği ortalama kaç milisaniye gecikmeyle görür? Long polling kaç HTTP isteği atar? Ve en önemlisi: hangisi "en iyisi"?

Cevapları yazının ortasında gerçek bir ölçümle göreceğiz. Spoiler vermeden şunu söyleyeyim: "en iyisi" sorusunun cevabı tekniğin kendisinde değil, senin özelliğinin ihtiyacında.

Ortak Senaryo: Sipariş Durumu

Beş tekniği adil karşılaştırmak için hepsini aynı kaynağa bağlayalım. Durumu tutan küçük bir store, her değişiklikte artan bir Version ve "bir sonraki değişikliği bekle" diyebilmek için bir TaskCompletionSource:

public sealed record OrderStatusChanged(int OrderId, string Status, int Version, DateTimeOffset ChangedAt);

public sealed class OrderStatusStore
{
    private readonly Lock _gate = new();
    private OrderStatusChanged _current = new(42, "Created", 0, DateTimeOffset.UtcNow);
    private TaskCompletionSource<OrderStatusChanged> _next = NewSignal();

    public OrderStatusChanged Current
    {
        get { lock (_gate) { return _current; } }
    }

    public void Publish(string status)
    {
        TaskCompletionSource<OrderStatusChanged> toComplete;
        OrderStatusChanged change;
        lock (_gate)
        {
            change = new OrderStatusChanged(42, status, _current.Version + 1, DateTimeOffset.UtcNow);
            _current = change;
            toComplete = _next;
            _next = NewSignal();
        }
        toComplete.SetResult(change);
    }

    public async Task<OrderStatusChanged?> WaitForChangeAsync(int knownVersion, TimeSpan timeout, CancellationToken cancellationToken)
    {
        Task<OrderStatusChanged> next;
        lock (_gate)
        {
            if (_current.Version > knownVersion)
            {
                return _current;
            }
            next = _next.Task;
        }

        try
        {
            return await next.WaitAsync(timeout, cancellationToken);
        }
        catch (TimeoutException)
        {
            return null;
        }
    }

    private static TaskCompletionSource<OrderStatusChanged> NewSignal() =>
        new(TaskCreationOptions.RunContinuationsAsynchronously);
}

İki ayrıntı önemli. SetResult'ı lock'un dışında çağırıyoruz ve RunContinuationsAsynchronously kullanıyoruz; yoksa bekleyen bütün istemcilerin devamı lock'u tutan thread üzerinde senkron çalışmaya başlar. İkincisi knownVersion: istemci "ben 2. sürümü gördüm" dediğinde, arada kaçırdığı bir değişiklik varsa beklemeden döneriz. Bu küçük sürüm numarası ileride SSE'deki Last-Event-ID'nin de, SignalR'daki stateful reconnect'in de özünü oluşturuyor: istemcinin nerede kaldığını bilmeden kaçırılan mesajı telafi edemezsin.

Bir BackgroundService (Background Job'lar serisinin ikinci yazısında anlattığımız yapı) POST /orders/42/simulate çağrısıyla tetikleniyor ve 3 saniyede bir sonraki duruma geçiyor.

Polling: En Basit Olan

app.MapGet("/orders/{id:int}/status", (int id, OrderStatusStore store) =>
    TypedResults.Ok(store.Current));
$ curl -s -i http://localhost:17601/orders/42/status
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 01:55:18 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"orderId":42,"status":"Delivered","version":5,"changedAt":"2026-11-08T01:55:13.695524+00:00"}

Sunucu tarafında yeni hiçbir şey yok; sıradan bir endpoint. İstemci bunu bir zamanlayıcıyla çağırıyor. Polling'in gücü de burada: cache'lenebilir, load balancer ve proxy'lerle sorunsuz çalışır, sunucu yeniden başlasa bile istemci bir sonraki turda kaldığı yerden devam eder. Bağlantı durumu diye bir kavram yok.

Bedeli iki yönlü. Aralığı kısaltırsan boşa giden istek sayısı artar; 10.000 kullanıcı 2 saniyede bir sorarsa sunucu saniyede 5.000 istek karşılar ve bunların büyük çoğunluğu "değişiklik yok" der. Aralığı uzatırsan gecikme artar: bir değişiklik ortalama aralığın yarısı kadar, en kötü ihtimalle bir aralık boyu geç fark edilir. Bir ayrıntı daha: ETag ve If-None-Match ile "değişmediyse 304 dön" dersen gövde gitmez ama istek yine gider.

Long Polling: Yanıtı Bekletmek

app.MapGet("/orders/{id:int}/status/long-poll", async Task<IResult> (
    int id, int version, OrderStatusStore store, CancellationToken cancellationToken) =>
{
    OrderStatusChanged? next = await store.WaitForChangeAsync(version, TimeSpan.FromSeconds(25), cancellationToken);
    return next is null ? TypedResults.NoContent() : TypedResults.Ok(next);
});

İstemci bildiği sürümü gönderiyor. Yeni bir şey varsa yanıt hemen dönüyor, yoksa sunucu isteği 25 saniyeye kadar bekletiyor. Değişiklik gelir gelmez yanıt gidiyor ve istemci hemen yeni bir istek açıyor. Hiçbir şey olmazsa:

$ time curl -s -i "http://localhost:17601/orders/42/status/long-poll?version=5"
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 01:55:43 GMT
Server: Kestrel

curl -s -i "http://localhost:17601/orders/42/status/long-poll?version=5"  0.00s user 0.00s system 0% cpu 25.008 total

Neden 25 saniye? Çünkü aradaki proxy'ler boşta bekleyen isteği bir süre sonra keser: Nginx'in proxy_read_timeout varsayılanı 60 saniye, AWS (Amazon Web Services) Application Load Balancer'ın idle timeout varsayılanı da 60 saniye. Onlardan önce sen kapatırsan istemci "504 Gateway Timeout" yerine bilinçli bir 204 alır. CancellationToken parametresi de önemli: istemci sayfayı kapatırsa bekleme iptal ediliyor, sunucu boşuna 25 saniye tutmuyor.

Long polling düz HTTP olduğu için her ortamda çalışır; SignalR bile WebSocket ve SSE kullanılamadığında son çare olarak buna düşer. Zayıf yanı her mesajdan sonra yeni bir istek, yani yeni header'lar, yeniden authentication ve yoğun anlarda mesajlar arasında kısa bir "kör nokta".

Server-Sent Events: Tek Yönlü Akış

app.MapGet("/orders/{id:int}/status/stream", (int id, OrderStatusStore store, CancellationToken cancellationToken) =>
    TypedResults.ServerSentEvents(store.WatchAsync(cancellationToken), eventType: "status"));
public async IAsyncEnumerable<OrderStatusChanged> WatchAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
    int version = Current.Version;
    while (!cancellationToken.IsCancellationRequested)
    {
        OrderStatusChanged? change = await WaitForChangeAsync(version, Timeout.InfiniteTimeSpan, cancellationToken);
        if (change is null)
        {
            continue;
        }
        version = change.Version;
        yield return change;
    }
}

SSE, tarayıcıların EventSource API'siyle (Application Programming Interface) doğrudan konuştuğu, text/event-stream içerik tipli ve hiç bitmeyen bir HTTP yanıtı. Formatı WHATWG (Web Hypertext Application Technology Working Group) HTML (HyperText Markup Language) standardında tanımlı: event:, data:, id:, retry: satırları ve her event'in sonunda bir boş satır. .NET 10'daki TypedResults.ServerSentEvents'i ASP.NET Core Web API serisinin ".NET 10 Yenilikleri" yazısında tanıtmıştık. Bu yazıda yalnızca seçim açısından bakıyoruz; Last-Event-ID ile kaldığı yerden devam ve proxy tuzaklarını serinin üçüncü yazısı derinlemesine anlatıyor.

SSE'nin en sevdiğim yanı tarayıcıdaki otomatik yeniden bağlanma: bağlantı koparsa EventSource kendiliğinden tekrar bağlanıyor ve son gördüğü id'yi header'da gönderiyor. Sınırı ise yönü: sunucudan istemciye. İstemci bir şey söylemek isterse ayrı bir HTTP isteği atar. Bildirim, ilerleme çubuğu, canlı skor, AI (Artificial Intelligence) yanıtının token token akması gibi senaryolarda bu bir eksik değil; zaten tek yönlü işler.

WebSocket: Çift Yönlü Kanal

app.UseWebSockets();

app.Map("/ws/orders/{id:int}", async (int id, HttpContext context, OrderStatusStore store) =>
{
    if (!context.WebSockets.IsWebSocketRequest)
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        return;
    }

    using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
    await foreach (OrderStatusChanged change in store.WatchAsync(context.RequestAborted))
    {
        byte[] payload = JsonSerializer.SerializeToUtf8Bytes(change, JsonSerializerOptions.Web);
        await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, context.RequestAborted);
    }
});

WebSocket, RFC (Request for Comments) 6455 ile tanımlı ayrı bir protokol. Bağlantı bir HTTP isteği olarak başlıyor (Upgrade: websocket), sunucu 101 Switching Protocols dönünce aynı TCP (Transmission Control Protocol) bağlantısı üzerinde iki taraf da istediği an frame gönderebiliyor. Gecikme en düşük, header yükü yok, binary veri doğal olarak taşınıyor.

Ama yukarıdaki kodda olmayanlara dikkat et: mesaj formatı yok (JSON (JavaScript Object Notation) seçtik ama bu bizim kararımız), kopunca yeniden bağlanma yok, "şu kullanıcının bütün sekmelerine gönder" yok, keep-alive ayarı yok, ve SendAsync aynı anda iki yerden çağrılamıyor. Bunların hepsini kendin yazacaksın. Serinin ikinci yazısı tam olarak bunu yapıyor ve sonunda neden çoğu ekibin SignalR'a geçtiğini kendi gözünle görüyorsun.

SignalR: Üstüne Kurulmuş Framework

public sealed class OrderHub : Hub
{
    public Task WatchOrder(int orderId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
}

// Program.cs
builder.Services.AddSignalR();
app.MapHub<OrderHub>("/hubs/orders");

// simülatörde, her değişiklikten sonra
await hub.Clients.Group("order-42").SendAsync("StatusChanged", store.Current, stoppingToken);

SignalR bir transport değil; WebSocket, SSE ve long polling'i transport olarak kullanan bir RPC (Remote Procedure Call) framework'ü. İstemci önce negotiate endpoint'ine soruyor:

$ curl -s -X POST "http://localhost:17601/hubs/orders/negotiate?negotiateVersion=1"
{"negotiateVersion":1,"connectionId":"yX3XQ-1mLl-Td12WNgsucQ","connectionToken":"NrpzGoy0tYkwOJKUJwnCAQ","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]},{"transport":"ServerSentEvents","transferFormats":["Text"]},{"transport":"LongPolling","transferFormats":["Text","Binary"]}]}

Sunucu üç transport'u da sunuyor; istemci sırayla WebSocket, SSE ve long polling'i deniyor ve çalışan ilkinde kalıyor. Üstüne hub method'ları, gruplar, kullanıcıya göre gönderim, otomatik reconnect, JSON veya MessagePack protocol'ü ve IHubContext ile uygulamanın her yerinden mesaj gönderebilme geliyor. Bedeli: istemcide @microsoft/signalr library'si gerekiyor (düz EventSource ya da WebSocket ile konuşamazsın), birden fazla sunucuda sticky session veya backplane istiyor ve kendi protokolü var.

Yan Yana Ölçüm ve Karar Tablosu

Beş istemciyi aynı .NET konsol uygulamasında aynı anda başlattım, simülasyonu tetikledim ve 18 saniye bekledim. Her değişikliğin sunucudaki ChangedAt zamanı ile istemciye ulaştığı an arasındaki farkı ölçtüm (istemci ve sunucu aynı makinede, saat farkı yok):

Polling (2 s)    HTTP requests:   9  changes: 5  avg delay:     894 ms  worst:   1497 ms
Long polling     HTTP requests:   5  changes: 5  avg delay:       1 ms  worst:      1 ms
SSE              HTTP requests:   1  changes: 5  avg delay:       1 ms  worst:      1 ms
WebSocket        HTTP requests:   1  changes: 5  avg delay:       1 ms  worst:      1 ms
SignalR          HTTP requests:   2  changes: 5  avg delay:       1 ms  worst:      3 ms

Baştaki sorunun cevabı: 2 saniyelik polling değişiklikleri ortalama 894 ms geç gördü ve 9 isteğin 4'ü boşa gitti. Long polling 5 değişiklik için 5 istek attı ve gecikmesi diğer üç "push" tekniğiyle aynı. SignalR'ın 2 isteği negotiate ve WebSocket upgrade'i. Yerel makinede ölçtüğümüz için 1 ms'ler ağ gecikmesini içermiyor; gerçek ağda hepsine aynı round-trip eklenir, aradaki fark değişmez.

01-five-techniques-tr.png

Görselde farkı görüyorsun: polling'de değişiklik ile onu taşıyan yanıt arasında boşluk var, long polling'de her değişiklik bir isteği kapatıp yenisini açıyor, diğer üçünde tek bağlantı açık kalıyor. Karar tablosu:

İhtiyaçİlk tercihNeden
Dakikada bir değişen veri, birkaç saniye gecikme sorun değilPollingDurumsuz, cache'lenebilir, altyapıya hiç dokunmaz
Sunucudan istemciye bildirim, ilerleme yüzdesi, AI token streamSSEDüz HTTP, tarayıcıda otomatik reconnect, kaldığı yerden devam
Çift yönlü, düşük gecikmeli, binary veya özel protokolWebSocketTek bağlantı, iki yön, frame başına çok küçük ek yük
Chat, canlı pano, kullanıcı ve grup bazlı gönderim, .NET backendSignalRGruplar, kullanıcılar, reconnect, transport fallback hazır
WebSocket ve SSE'nin geçemediği kurumsal proxy arkasıLong pollingHer yerde çalışan düz HTTP

Tabloda olmayan bir aday: WebTransport. HTTP/3 ve QUIC (UDP (User Datagram Protocol) üzerinde çalışan transport protocol) üzerine kurulu, birden fazla stream ve sırasız datagram gönderebiliyor. MDN'e (MDN Web Docs) göre tarayıcı API'si Mart 2026'dan beri Baseline. Kestrel tarafında ise hâlâ preview: EnablePreviewFeatures ve deneysel bir runtime switch'i istiyor, SignalR de onu transport olarak kullanmıyor. Bugün production için değil, izlemeye değer.

Üretimde Dikkat

Push tekniklerinin ortak bedeli bağlantının kalıcı olması. Klasik HTTP isteği birkaç milisaniye sunucuda kalıp gider; SSE, WebSocket ve SignalR bağlantısı kullanıcı sekmeyi kapatana kadar açık kalır. Bunu ölçtüm: aynı sunucuyu Release build'le başlatıp önce 2.000 çıplak WebSocket, sonra ayrı bir koşuda 2.000 SignalR bağlantısı açtım ve sunucu sürecinin fiziksel belleğine (ps -o rss) baktım:

ws before= 98144 after=128512
signalr before= 98304 after=160272

Değerler KB cinsinden. 2.000 boşta bekleyen WebSocket yaklaşık 30 MB, aynı sayıda SignalR bağlantısı yaklaşık 62 MB ekledi; bağlantı başına kabaca 15 ve 31 KB. SignalR'ın farkı her bağlantı için tuttuğu connection context'i, pipe buffer'ları ve hub protocol durumu. Bu sayılar GC'nin (garbage collector) o anki durumuna göre oynar, kesin bir maliyet değil; ama mertebe olarak "100.000 eşzamanlı kullanıcı birkaç GB bellek ve 100.000 açık soket demek" diye düşünebilirsin. Üstüne işletim sisteminin dosya tanımlayıcı limitleri ve load balancer'ın bağlantı limitleri biniyor.

Buradan çıkan pratik kural: her şeyi real-time yapmak kötü fikir. Sipariş listesi sayfası, raporlar, katalog gibi kullanıcının kendisi yenilediği ekranlar için kalıcı bağlantı açmak; bellek, soket, load balancer yapılandırması ve izleme yükü getirir ama kullanıcıya bir şey kazandırmaz. Real-time'ı "kullanıcı bu değişikliği saniyesinde görmezse bir şey kaybeder mi?" sorusunun cevabı evet olan yerlere sakla: ödeme sonucu, kargo durumu, chat, stok tükenmesi.

Bir de ölçek: sunucu sayısı birden fazla olduğunda push tekniklerinin hepsi "bu kullanıcı hangi sunucuya bağlı?" sorusuyla karşılaşıyor. Polling bu soruyu hiç sormuyor. Bu konuyu serinin sekizinci yazısında Redis backplane ve Azure SignalR Service ile ele alıyoruz.

Sık Düşülen Tuzaklar

SignalR'ı varsayılan seçim yapmak. Tek yönlü bir ilerleme yüzdesi için hub, istemci library'si, sticky session ve backplane kurmak gereğinden ağır. SSE çoğu bildirim senaryosu için yeterli ve tarayıcıda sıfır bağımlılık.

Long polling'de timeout koymamak. Sunucu isteği süresiz bekletirse aradaki bir proxy onu sessizce keser, istemci de hiçbir zaman değişiklik almayan bir istekte asılı kalır. Sunucu tarafında, proxy'nin idle timeout'undan kısa bir süre koy.

Polling'i sürüm numarası olmadan yapmak. İstemci sadece "son durum ne?" diye sorarsa iki poll arasında iki değişiklik olduğunda ortadakini hiç görmez. Bizim ölçümde de polling Packed ile Shipped'i farklı turlarda yakaladı, ama aralık biraz uzasaydı birini kaçıracaktı. Ara adımlar önemliyse istemci "şu sürümden sonrakileri ver" diyebilmeli.

WebSocket'i CORS (Cross-Origin Resource Sharing) ile korunuyor sanmak. Tarayıcı WebSocket isteği için preflight yapmıyor; Origin header'ını sunucuda kendin kontrol etmen gerekiyor. Detaylar serinin ikinci yazısında.

Real-time'ı veri kaynağı sanmak. Push kanalı bir bildirim yolu; kopabilir, mesaj kaçabilir. İstemci bağlandığında ya da yeniden bağlandığında güncel durumu her zaman sıradan bir HTTP isteğiyle çekmeli, push mesajları bunun üstüne gelen değişiklikler olmalı.

Küçük Bir Deneme

★☆☆ Polling aralığını 2 saniyeden 500 ms'ye indir ve ölçümü tekrarla. Ortalama gecikme ve istek sayısı nasıl değişti? 10.000 kullanıcı için saniyedeki istek sayısını hesapla.

★★☆ Long polling endpoint'ine bekleme süresi yerine ?timeout=5 parametresi ekle ve değişiklik olmayan bir sürüm iste. 5 saniye sonra 204 geldiğini, istemci bağlantıyı erken kapatınca da sunucu tarafındaki CancellationToken'ın tetiklendiğini bir log satırıyla doğrula.

★★★ Simülatörü 3 saniye yerine 300 ms'de bir ilerleyecek şekilde değiştir. Hangi teknikler beş değişikliğin beşini de gördü? Polling'in kaçırdığı ara durumları sürüm numarasıyla nasıl telafi edersin?

Bir sonraki yazıda tabloda en alt seviyedeki araçla, çıplak WebSocket ile başlıyoruz: UseWebSockets, receive döngüsü, parçalı mesajlar, close handshake, connection registry ve Angular'dan RxJS webSocket() ile bağlanmak.