2025-09-08
Gerçek Zamanlı Bildirimler ve Çok Kanallı Teslimat: WebSocket, Push, Email ve Ötesi
WebSocket, push bildirim, email, SMS ve webhook kanalları için üretimde test edilmiş gerçek zamanlı bildirim teslimat stratejileri
Gerçek zamanlı bildirimler, platforma özgü push bildirim farklılıkları, ölçekte WebSocket bağlantı yönetimi ve trafikle birlikte çoğalan vendor maliyetleriyle uğraşana kadar basit görünür.
Çok kanallı bildirim sistemleri, asıl zorluğun bildirim göndermek olmadığını ortaya çıkarır—güvenilir şekilde, ölçekte, her birinin kendi tuhaflıkları, sınırları ve hata modları olan farklı teslimat mekanizmaları üzerinden yapmak zorluğu. WebSocket’te connection drain, push’ta device token invalidation, email’de bounce handling—her kanal farklı operasyonel zorluklar getirir. iOS ve Android push gereksinimleri farklıdır; WebSocket bağlantı yönetimi Redis veya benzeri bir store gerektirir.
Üretim ortamlarında çalışan WebSocket bağlantıları, push bildirimler, email teslimatı, SMS ve webhook’lar için desenleri paylaşayım. Her kanalın kendi SLA beklentisi var; WebSocket sub-second gecikme gerektirirken email birkaç dakika kabul edilebilir. Connection drain ve device token invalidation production’da sık karşılaşılan operasyonel zorluklardır.
WebSocket Yönetimi: Gerçek Zamanın Temeli
WebSocket’ler binlerce eşzamanlı bağlantıyla üretime çıkana kadar basit görünüyor. Gerçekten ölçeklenen WebSocket altyapısı inşa etmek hakkında öğrendiklerim:
İşe Yarayan Bağlantı Yönetimi
En büyük ders: bağlantılar geçici, ama kullanıcı durumu değil. Birden fazla ürün lansmanını atlatan bağlantı yöneticisi:
interface ConnectionMetadata {
userId: string;
deviceId?: string;
userAgent: string;
connectedAt: Date;
lastPing: Date;
subscriptions: Set<string>;
metadata: Record<string, any>;
}
class WebSocketConnectionManager {
private connections: Map<string, {
socket: WebSocket;
metadata: ConnectionMetadata;
}> = new Map();
private userConnections: Map<string, Set<string>> = new Map();
private redis: Redis;
private heartbeatInterval: NodeJS.Timeout;
constructor(redis: Redis) {
this.redis = redis;
this.startHeartbeat();
}
async handleConnection(socket: WebSocket, request: IncomingMessage): Promise<void> {
const connectionId = this.generateConnectionId();
try {
// JWT token veya session'dan kullanıcı bilgisi çıkar
const userInfo = await this.authenticateConnection(request);
if (!userInfo) {
socket.close(1008, 'Authentication required');
return;
}
const metadata: ConnectionMetadata = {
userId: userInfo.userId,
deviceId: userInfo.deviceId,
userAgent: request.headers['user-agent'] || '',
connectedAt: new Date(),
lastPing: new Date(),
subscriptions: new Set(),
metadata: {}
};
// Bağlantıyı sakla
this.connections.set(connectionId, { socket, metadata });
// Kullanıcı bağlantı mapping'ini güncelle
if (!this.userConnections.has(userInfo.userId)) {
this.userConnections.set(userInfo.userId, new Set());
}
this.userConnections.get(userInfo.userId)!.add(connectionId);
// Çok instance desteği için Redis'te bağlantı bilgisini sakla
await this.redis.hset(
`ws:connections:${userInfo.userId}`,
connectionId,
JSON.stringify({
serverId: process.env.SERVER_ID,
connectedAt: metadata.connectedAt,
deviceId: metadata.deviceId
})
);
// Event handler'ları kur
this.setupConnectionHandlers(connectionId, socket, metadata);
// Bağlantı onayı gönder
await this.sendMessage(connectionId, {
type: 'connection_ack',
data: { connectionId, timestamp: new Date() }
});
console.log(`WebSocket connection established for user ${userInfo.userId}`);
} catch (error) {
console.error('WebSocket connection setup failed:', error);
socket.close(1011, 'Internal server error');
}
}
private setupConnectionHandlers(
connectionId: string,
socket: WebSocket,
metadata: ConnectionMetadata
): void {
socket.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
await this.handleMessage(connectionId, message);
} catch (error) {
console.error('Message handling error:', error);
await this.sendError(connectionId, 'Invalid message format');
}
});
socket.on('pong', () => {
metadata.lastPing = new Date();
});
socket.on('close', async (code, reason) => {
await this.handleDisconnection(connectionId, code, reason);
});
socket.on('error', async (error) => {
console.error(`WebSocket error for ${connectionId}:`, error);
await this.handleDisconnection(connectionId, 1011, 'Connection error');
});
}
async sendNotificationToUser(userId: string, notification: NotificationEvent): Promise<void> {
const userConnectionIds = this.userConnections.get(userId) || new Set();
if (userConnectionIds.size === 0) {
// Kullanıcı bu sunucu instance'ına bağlı değil
// Diğer sunucu instance'ları için Redis'i kontrol et
const remoteConnections = await this.redis.hgetall(`ws:connections:${userId}`);
if (Object.keys(remoteConnections).length > 0) {
// Kullanıcı başka bir sunucu instance'ına bağlı
await this.redis.publish('ws:notification', JSON.stringify({
userId,
notification,
targetServerId: null // tüm sunuculara broadcast
}));
}
return;
}
// Yerel bağlantılara gönder
const sendPromises = Array.from(userConnectionIds).map(async (connectionId) => {
try {
await this.sendMessage(connectionId, {
type: 'notification',
data: notification
});
return { connectionId, success: true };
} catch (error) {
console.error(`Failed to send notification to ${connectionId}:`, error);
return { connectionId, success: false, error };
}
});
const results = await Promise.allSettled(sendPromises);
// Başarısız bağlantıları temizle
results.forEach((result, index) => {
if (result.status === 'rejected' ||
(result.status === 'fulfilled' && !result.value.success)) {
const connectionId = Array.from(userConnectionIds)[index];
this.handleDisconnection(connectionId, 1011, 'Send failed');
}
});
}
}
WebSocket Ölçeklendirme Desenleri
WebSocket’ler hakkında zor ders: REST API’ler gibi ölçeklenmiyor. Farklı deployment senaryolarında çalışmış çok instance koordinasyon deseni:
class WebSocketCluster {
constructor(
private connectionManager: WebSocketConnectionManager,
private redis: Redis
) {
this.setupClusterCommunication();
}
private setupClusterCommunication(): void {
// Teslim edilmesi gereken bildirimler için dinle
this.redis.subscribe('ws:notification');
this.redis.subscribe('ws:broadcast');
this.redis.on('message', async (channel, message) => {
try {
const data = JSON.parse(message);
if (channel === 'ws:notification') {
await this.handleRemoteNotification(data);
} else if (channel === 'ws:broadcast') {
await this.handleBroadcast(data);
}
} catch (error) {
console.error('Cluster message handling error:', error);
}
});
}
private async handleRemoteNotification(data: {
userId: string;
notification: NotificationEvent;
targetServerId?: string;
}): Promise<void> {
// Sadece hedef sunucu belirtilmemiş veya biziz işle
if (data.targetServerId && data.targetServerId !== process.env.SERVER_ID) {
return;
}
await this.connectionManager.sendNotificationToUser(
data.userId,
data.notification
);
}
async broadcastSystemMessage(message: any): Promise<void> {
await this.redis.publish('ws:broadcast', JSON.stringify({
message,
senderId: process.env.SERVER_ID,
timestamp: new Date()
}));
}
}
Push Bildirimler: Mobil’in İki Yönlü Kılıcı
Push bildirimler dokümantasyonda basit görünüyor ama birden fazla platformu, kullanıcı izinlerini ve teslimat garantilerini yönetmen gerektiğinde hızla karmaşık hale geliyor. Üretimin bana öğrettiği:
Çok Platformlu Push Servisi
Ana içgörü: iOS ve Android’i ikisi de “push bildirim” olsa bile tamamen farklı canavarlar olarak ele al:
interface PushProvider {
sendNotification(
tokens: string[],
payload: PushPayload,
options?: PushOptions
): Promise<PushResult[]>;
validateToken(token: string): Promise<boolean>;
getInvalidTokens(results: PushResult[]): string[];
}
interface PushPayload {
title: string;
body: string;
data?: Record<string, any>;
badge?: number;
sound?: string;
icon?: string;
image?: string;
}
class UnifiedPushService {
private providers: Map<PushPlatform, PushProvider> = new Map();
private tokenStore: TokenStore;
private analytics: PushAnalytics;
constructor() {
this.providers.set('ios', new APNSProvider());
this.providers.set('android', new FCMProvider());
this.providers.set('web', new WebPushProvider());
}
async sendPushNotification(
userId: string,
notification: NotificationEvent
): Promise<PushDeliveryResult> {
try {
// Kullanıcının tüm push token'larını al
const userTokens = await this.tokenStore.getUserTokens(userId);
if (userTokens.length === 0) {
return {
success: false,
reason: 'no_tokens',
deliveries: []
};
}
// Token'ları platformlara göre grupla
const tokensByPlatform = this.groupTokensByPlatform(userTokens);
// Platforma özel payload'ları hazırla
const payloads = await this.createPlatformPayloads(notification);
// Her platforme gönder
const deliveryPromises = Object.entries(tokensByPlatform).map(
([platform, tokens]) => this.sendToPlatform(
platform as PushPlatform,
tokens,
payloads[platform as PushPlatform],
notification
)
);
const results = await Promise.allSettled(deliveryPromises);
// Sonuçları işle ve geçersiz token'ları temizle
const deliveries = await this.processDeliveryResults(results, userTokens);
// Analitikleri takip et
await this.analytics.trackPushDelivery(notification.id, deliveries);
return {
success: deliveries.some(d => d.success),
deliveries
};
} catch (error) {
console.error('Push notification delivery failed:', error);
return {
success: false,
reason: 'send_error',
error: error.message,
deliveries: []
};
}
}
private async sendToPlatform(
platform: PushPlatform,
tokens: PushToken[],
payload: PushPayload,
notification: NotificationEvent
): Promise<PlatformDeliveryResult> {
const provider = this.providers.get(platform);
if (!provider) {
throw new Error(`No provider for platform ${platform}`);
}
// Platforma özel seçenekler
const options: PushOptions = {
priority: this.mapPriorityToPlatform(notification.priority, platform),
ttl: notification.expiresAt ?
Math.floor((notification.expiresAt.getTime() - Date.now()) / 1000) :
3600, // 1 saat default
collapseKey: platform === 'android' ? notification.type : undefined,
apnsTopic: platform === 'ios' ? process.env.APNS_TOPIC : undefined
};
const tokenStrings = tokens.map(t => t.token);
const results = await provider.sendNotification(tokenStrings, payload, options);
// Geçersiz token'ları temizle
const invalidTokens = provider.getInvalidTokens(results);
if (invalidTokens.length > 0) {
await this.tokenStore.markTokensInvalid(userId, invalidTokens);
}
return {
platform,
tokens: tokenStrings,
results,
invalidTokens
};
}
private async createPlatformPayloads(
notification: NotificationEvent
): Promise<Record<PushPlatform, PushPayload>> {
// Kullanıcı tercihlerine göre lokalize içerik al
const template = await this.templateService.getTemplate(
notification.type,
'push',
'tr' // Kullanıcının locale'i olmalı
);
const rendered = await this.templateService.render(template, notification.data);
return {
ios: {
title: rendered.subject || '',
body: rendered.body,
data: {
notificationId: notification.id,
type: notification.type,
...notification.data
},
badge: await this.getBadgeCount(notification.userId),
sound: this.getSoundForNotificationType(notification.type)
},
android: {
title: rendered.subject || '',
body: rendered.body,
data: {
notificationId: notification.id,
type: notification.type,
...notification.data
},
icon: 'ic_notification',
// Android özel styling
color: '#007AFF'
},
web: {
title: rendered.subject || '',
body: rendered.body,
data: notification.data,
icon: '/icons/notification-icon.png',
image: notification.data.imageUrl
}
};
}
}
Push Token Yönetimi
Token yönetimi, çoğu push implementasyonunun üretimde başarısız olduğu yer. Token’lar geçersiz hale geliyor, kullanıcılar app’leri kaldırıyor ve bunu zarifçe yönetmen gerekiyor:
class PushTokenStore {
constructor(private db: Database, private redis: Redis) {}
async registerToken(
userId: string,
token: string,
platform: PushPlatform,
deviceId: string
): Promise<void> {
try {
// Token formatını doğrula
if (!this.isValidTokenFormat(token, platform)) {
throw new Error('Invalid token format');
}
// Token'ın başka bir kullanıcıda olup olmadığını kontrol et
const existingToken = await this.db.query(
'SELECT user_id FROM push_tokens WHERE token = $1',
[token]
);
if (existingToken.length > 0 && existingToken[0].user_id !== userId) {
// Token yeni kullanıcıya geçmiş, güncelle
await this.db.query(
'UPDATE push_tokens SET user_id = $1, updated_at = NOW() WHERE token = $2',
[userId, token]
);
} else {
// Token'ı ekle veya güncelle
await this.db.query(`
INSERT INTO push_tokens (user_id, token, platform, device_id, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (token)
DO UPDATE SET
user_id = $1,
is_active = true,
updated_at = NOW()
`, [userId, token, platform, deviceId]);
}
// Hızlı lookup için aktif token'ları cache'le
await this.redis.sadd(`push_tokens:${userId}`, token);
console.log(`Push token registered for user ${userId} on ${platform}`);
} catch (error) {
console.error('Push token registration failed:', error);
throw error;
}
}
async markTokensInvalid(userId: string, tokens: string[]): Promise<void> {
if (tokens.length === 0) return;
await this.db.query(
'UPDATE push_tokens SET is_active = false, updated_at = NOW() WHERE token = ANY($1)',
[tokens]
);
// Redis cache'ten kaldır
if (tokens.length > 0) {
await this.redis.srem(`push_tokens:${userId}`, ...tokens);
}
console.log(`Marked ${tokens.length} tokens as invalid for user ${userId}`);
}
async getUserTokens(userId: string): Promise<PushToken[]> {
// Önce cache'i dene
const cachedTokens = await this.redis.smembers(`push_tokens:${userId}`);
if (cachedTokens.length > 0) {
// Veritabanından tam token bilgisini al
const tokens = await this.db.query(`
SELECT token, platform, device_id, created_at
FROM push_tokens
WHERE user_id = $1 AND is_active = true AND token = ANY($2)
`, [userId, cachedTokens]);
return tokens;
}
// Cache miss, veritabanından al ve cache'i doldur
const tokens = await this.db.query(`
SELECT token, platform, device_id, created_at
FROM push_tokens
WHERE user_id = $1 AND is_active = true
ORDER BY updated_at DESC
`, [userId]);
if (tokens.length > 0) {
await this.redis.sadd(
`push_tokens:${userId}`,
...tokens.map(t => t.token)
);
await this.redis.expire(`push_tokens:${userId}`, 86400); // 24 saat
}
return tokens;
}
}
Email Teslimatı: Düşündüğünden Daha Karmaşık
Email, teslimat edilebilirlik, bounce yönetimi ve vendor limitleriyle uğraşana kadar “kolay” kanal gibi görünüyor. Spam klasörlerine düşmeden milyonlarca email yöneten email servisi:
Sağlayıcı Failover’lı Email Servisi
Üretim gerçeği: email sağlayıcıları başarısız oluyor, rate limit’e takılıyor veya teslimat edilebilirlik sorunları yaşıyor. Birden fazla sağlayıcıya ve akıllı routing’e ihtiyacın var:
interface EmailProvider {
sendEmail(email: EmailMessage): Promise<EmailResult>;
handleWebhook(payload: any): Promise<WebhookResult>;
getDeliverabilityScore(): Promise<number>;
}
class EmailDeliveryService {
private providers: EmailProvider[] = [];
private primaryProvider: EmailProvider;
private fallbackProviders: EmailProvider[];
constructor() {
// Sağlayıcıları öncelik sırasına göre başlat
this.providers = [
new SendGridProvider(),
new AmazonSESProvider(),
new PostmarkProvider()
];
this.primaryProvider = this.providers[0];
this.fallbackProviders = this.providers.slice(1);
}
async sendEmail(
userId: string,
notification: NotificationEvent
): Promise<EmailDeliveryResult> {
try {
// Kullanıcı email ve tercihlerini al
const user = await this.getUserWithEmailPrefs(userId);
if (!user.email || !user.emailEnabled) {
return {
success: false,
reason: 'email_disabled',
attempts: []
};
}
// Kullanıcının suppression listesinde olup olmadığını kontrol et
if (await this.isUserSuppressed(user.email)) {
return {
success: false,
reason: 'user_suppressed',
attempts: []
};
}
// Email içeriğini render et
const emailContent = await this.renderEmailContent(notification, user);
// Email mesajını hazırla
const emailMessage: EmailMessage = {
to: user.email,
from: this.getFromAddress(notification.type),
subject: emailContent.subject,
html: emailContent.html,
text: emailContent.text,
metadata: {
userId,
notificationId: notification.id,
notificationType: notification.type
},
tags: [notification.type, `user:${userId}`],
unsubscribeUrl: this.generateUnsubscribeUrl(userId, notification.type)
};
// Önce birincil sağlayıcıyı dene
let result = await this.attemptDelivery(this.primaryProvider, emailMessage);
if (!result.success) {
// Yedek sağlayıcıları dene
for (const provider of this.fallbackProviders) {
console.warn(`Primary email provider failed, trying fallback: ${provider.constructor.name}`);
result = await this.attemptDelivery(provider, emailMessage);
if (result.success) break;
}
}
// Teslimat sonucunu sakla
await this.storeDeliveryResult(notification.id, 'email', result);
return {
success: result.success,
attempts: [result],
providerId: result.providerId,
messageId: result.messageId
};
} catch (error) {
console.error('Email delivery failed:', error);
return {
success: false,
reason: 'delivery_error',
error: error.message,
attempts: []
};
}
}
private async renderEmailContent(
notification: NotificationEvent,
user: User
): Promise<EmailContent> {
// Email template'ini al
const template = await this.templateService.getTemplate(
notification.type,
'email',
user.locale
);
// Kullanıcı verisi ve bildirim verisiyle render et
const context = {
user,
...notification.data,
unsubscribeUrl: this.generateUnsubscribeUrl(user.id, notification.type),
preferencesUrl: this.generatePreferencesUrl(user.id)
};
const rendered = await this.templateService.render(template, context);
// Gerekirse markdown'ı HTML'ye çevir
const html = this.markdownToHtml(rendered.body);
const text = this.htmlToText(html);
return {
subject: rendered.subject,
html,
text
};
}
}
Email Bounce ve Complaint Yönetimi
Bounce ve complaint event’lerini işle. SES/SendGrid webhook’ları ile suppression listelerini güncelle. Hard bounce’da e-posta adresini devre dışı bırak.
SMS ve Webhook Kanalları: Destekleyici Kadro
SMS ve webhook’lar çok kanallı yaklaşımı tamamlıyor. Onları güvenilir şekilde nasıl implement edeceğin:
SMS Teslimat Servisi
SMS kritik bildirimler için “nükleer seçenek”. Basit ve güvenilir tut:
class SMSDeliveryService {
private provider: SMSProvider;
private fallbackProvider: SMSProvider;
constructor() {
this.provider = new TwilioProvider();
this.fallbackProvider = new AmazonSNSProvider();
}
async sendSMS(
userId: string,
notification: NotificationEvent
): Promise<SMSDeliveryResult> {
try {
const user = await this.getUserWithSMSPrefs(userId);
if (!user.phone || !user.smsEnabled) {
return { success: false, reason: 'sms_disabled' };
}
// SMS içeriği kısa olmalı
const content = await this.renderSMSContent(notification, user);
// Birincil sağlayıcıyı dene
let result = await this.provider.sendSMS({
to: user.phone,
message: content,
metadata: {
userId,
notificationId: notification.id
}
});
if (!result.success) {
// Yedeği dene
result = await this.fallbackProvider.sendSMS({
to: user.phone,
message: content,
metadata: { userId, notificationId: notification.id }
});
}
await this.storeDeliveryResult(notification.id, 'sms', result);
return result;
} catch (error) {
console.error('SMS delivery failed:', error);
return { success: false, error: error.message };
}
}
private async renderSMSContent(
notification: NotificationEvent,
user: User
): Promise<string> {
const template = await this.templateService.getTemplate(
notification.type,
'sms',
user.locale
);
const rendered = await this.templateService.render(template, {
user,
...notification.data
});
// SMS karakter limitleri var
return this.truncateForSMS(rendered.body, 160);
}
}
Entegrasyonlar için Webhook Teslimatı
Harici sistemlere webhook ile bildirim gönder. Retry, signature doğrulama ve idempotency key’leri ile güvenilir teslimat.
Kanal Koordinasyonu ve Teslimat Mantığı
Çok kanallı sistemde kullanıcı tercihlerine göre kanal seçimi. WebSocket için online kullanıcılar, push için mobil, e-posta için async.
Teslimat Savaş Alanından Dersler
WebSocket bağlantı fırtınalarından email teslimat edilebilirlik krizlerine kadar her şeyi debug ettikten sonra, öğrenilen dersler:
-
Bağlantılar geçici: WebSocket altyapını bağlantıların düşeceğini varsayarak inşa et. Kritik durumu bağlantı dışında sakla.
-
Push token’lar süresi doluyor: Geçersiz token’ları zarifçe yöneten ve gerektiğinde token’ları yeniden kaydeden sağlam bir token yönetim sistemin olsun.
-
Email teslimat edilebilirliği bir sanat: Birden fazla sağlayıcı, düzgün bounce yönetimi ve suppression listeler opsiyonel değil - hayatta kalma gereksinimleri.
-
Her kanalın rate limitleri var: Sağlayıcı limitlerini saygıyla karşılayan ve akıllı backoff stratejileri uygulayan sistem inşa et.
-
Kullanıcılar fikrini değiştiriyor: Tercih güncellemeyi kolay yap ve opt-out’ları hemen yönet. Teslimat edilebilirliğin buna bağlı.
-
Her şeyi izle: Her kanalın spesifik izlemeye ihtiyacı var. WebSocket bağlantı sayıları, push teslimat oranları, email bounce oranları, SMS maliyetleri - hepsini takip et.
Bu serinin bir sonraki bölümünde, bu dersleri öğreten üretim deneyimlerine dalacağız. Bildirim sisteminiz kritik bir iş anında erimekteyken işe yarayan debugging teknikleri ve izleme stratejilerini ele alacağız.
Burada inşa ettiğimiz çok kanallı teslimat sistemi mutlu yolu iyi yönetiyor, ama gerçek test işler ters gittiğinde geliyor. Ve bildirim sistemlerinde bir şeyler her zaman ters gidiyor.
Ölçeklenebilir Kullanıcı Bildirim Sistemi Geliştirme
Kurumsal seviye bildirim sistemlerinin tasarımı, implementasyonu ve üretim zorluklarını kapsayan kapsamlı 4-parça serisi. Mimari ve veritabanı tasarımından gerçek zamanlı teslimat, ölçekte debugging ve performans optimizasyonuna kadar.
Serideki tüm yazılar
İlgili yazılar
AWS AppSync ile ölçeklenebilir real-time API'ler geliştirmek için kapsamlı bir rehber: JavaScript resolver'lar, subscription filtering, caching stratejileri ve infrastructure as code pattern'leri.
AWS Bedrock + Knowledge Bases + OpenSearch Serverless üstüne CDK ile TypeScript kullanarak RAG agent kurmak — mimari, IAM bağlantısı, otomatik ingestion ve chat UI.
AgentCore Runtime üzerinde minimal bir Strands agent'ı CDK ile deploy etme rehberi — parametrize stack, arm64 build, deploy ve invoke akışı, ve ilk çağrıdan önce gereken IAM ve Marketplace ön koşulları.
DI container'lar, monolitik SDK'lar, god-handler'lar, modül üstü secret çağrıları ve ağır ORM'ler - soğuk başlatmada bedeli ve yerine geçen fonksiyonel yapı.
Tek bir backend üzerinde çalışan web SPA ve mobil uygulama için uzun süreli işlere dair tek bir varsayılan desen ve onu geçersiz kılmanız gereken durumlar.