Blog · November 2026 · 8 min read Blog · Novembre 2026 · 8 min de lectureالمدونة · نوفمبر 2026 · 8 دقائق قراءة
Realtime without WebSockets: PostgreSQL LISTEN/NOTIFY and Server-Sent Events Du temps réel sans WebSocket : LISTEN/NOTIFY de PostgreSQL et les Server-Sent Eventsالوقت الحقيقي دون WebSocket: LISTEN/NOTIFY في PostgreSQL و Server-Sent Events
How I kept live rankings in sync across several servers with nothing more than the database I already had, and the production pitfalls I ran into. Comment j'ai gardé des classements en direct synchronisés sur plusieurs serveurs avec rien de plus que la base de données que j'avais déjà, et les pièges rencontrés en production.كيف أبقيت ترتيباً مباشراً متزامناً عبر عدة خوادم بقاعدة البيانات التي كانت لديّ أصلاً، دون أي إضافة، والفخاخ التي صادفتها في بيئة الإنتاج.
The problemLe problèmeالمشكلة
Realtime Leaderboard is a ranking service for games: a game sends a score through a REST API, and everyone watching the leaderboard should see it move, right away, without refreshing. Realtime Leaderboard est un service de classement pour les jeux : un jeu envoie un score par une API REST, et tous ceux qui regardent le classement doivent le voir bouger, tout de suite, sans recharger.Realtime Leaderboard خدمة ترتيب للألعاب: ترسل اللعبة نتيجة عبر REST API، ويجب أن يرى كل من يتابع الترتيب تغيّره فوراً، دون إعادة تحميل.
With one server, that is easy. With two or more, it is not: the score arrives on server A, but some viewers are connected to server B. Server B has to find out. The classic answer is a WebSocket server plus Redis pub/sub. I wanted to see how far I could go without adding any of that. Avec un seul serveur, c'est facile. Avec deux ou plus, non : le score arrive sur le serveur A, mais certains spectateurs sont connectés au serveur B. Le serveur B doit l'apprendre. La réponse classique, c'est un serveur WebSocket et Redis en pub/sub. Je voulais voir jusqu'où on peut aller sans rien ajouter de tout ça.مع خادم واحد، الأمر سهل. مع اثنين أو أكثر، لا: تصل النتيجة إلى الخادم A، لكن بعض المتابعين متصلون بالخادم B، ويجب أن يعلم الخادم B بذلك. الحل المعتاد هو خادم WebSocket مع Redis بنمط pub/sub. أردت أن أرى إلى أي حد يمكن الذهاب دون إضافة أي من ذلك.
Why Server-Sent Events, not WebSocketsPourquoi des Server-Sent Events, et pas des WebSocketsلماذا Server-Sent Events وليس WebSockets
Viewers only receive updates; scores are sent through the REST API. For one-way traffic, Server-Sent Events (SSE) are simpler: it is a normal HTTP response that never ends, the browser's built-in EventSource reconnects on its own, and events can have names (leaderboard, score, game-deleted).
Les spectateurs ne font que recevoir des mises à jour ; les scores passent par l'API REST. Pour un trafic dans un seul sens, les Server-Sent Events (SSE) sont plus simples : c'est une réponse HTTP normale qui ne se termine jamais, l'EventSource intégré au navigateur se reconnecte tout seul, et les événements peuvent avoir un nom (leaderboard, score, game-deleted).المتابعون يستقبلون التحديثات فقط؛ أما النتائج فتمرّ عبر REST API. ولحركة في اتجاه واحد، تُعدّ Server-Sent Events (SSE) أبسط: إنها استجابة HTTP عادية لا تنتهي أبداً، ويعيد EventSource المدمج في المتصفح الاتصال تلقائياً، ويمكن تسمية الأحداث (leaderboard، score، game-deleted).
PostgreSQL as the message busPostgreSQL comme bus de messagesPostgreSQL كناقل للرسائل
PostgreSQL has a little-known feature: NOTIFY sends a message on a channel, and every connection that ran LISTEN on that channel receives it. That is pub/sub, already inside the database.
PostgreSQL a une fonctionnalité peu connue : NOTIFY envoie un message sur un canal, et toutes les connexions qui ont fait LISTEN sur ce canal le reçoivent. C'est du pub/sub, déjà dans la base de données.في PostgreSQL ميزة غير معروفة كثيراً: يرسل NOTIFY رسالة على قناة، وتستقبلها كل الاتصالات التي نفّذت LISTEN على تلك القناة. إنه pub/sub موجود أصلاً داخل قاعدة البيانات.
Publishing is one line, after the score is saved: Publier tient en une ligne, après l'enregistrement du score :النشر سطر واحد، بعد حفظ النتيجة:
export async function publish(event: RealtimeEvent): Promise<void> {
await db.$executeRaw`SELECT pg_notify(${CHANNEL}, ${JSON.stringify(event)})`
}
On the receiving side, each server opens one dedicated connection, runs LISTEN leaderboard_events, and re-emits every notification on an in-process EventEmitter. Each open SSE stream simply subscribes to that emitter. One database connection per server, however many viewers it has.
Côté réception, chaque serveur ouvre une seule connexion dédiée, exécute LISTEN leaderboard_events, et réémet chaque notification sur un EventEmitter en mémoire. Chaque flux SSE ouvert s'abonne simplement à cet émetteur. Une connexion à la base par serveur, quel que soit le nombre de spectateurs.في جهة الاستقبال، يفتح كل خادم اتصالاً مخصّصاً واحداً فقط، وينفّذ LISTEN leaderboard_events، ويعيد بث كل إشعار عبر EventEmitter في الذاكرة. وكل تدفق SSE مفتوح يشترك ببساطة في هذا الباث. اتصال واحد بقاعدة البيانات لكل خادم، مهما كان عدد المتابعين.
client.on("notification", (msg) => {
if (msg.channel !== CHANNEL || !msg.payload) return
hub.emitter.emit("event", JSON.parse(msg.payload))
})
await client.connect()
await client.query(`LISTEN ${CHANNEL}`)
Keeping an SSE stream aliveGarder un flux SSE en vieإبقاء تدفق SSE حياً
An SSE response is a ReadableStream that the server keeps writing to. A few details make the difference between "works on my machine" and "works behind a real proxy":
Une réponse SSE est un ReadableStream dans lequel le serveur continue d'écrire. Quelques détails font la différence entre « ça marche chez moi » et « ça marche derrière un vrai proxy » :استجابة SSE هي ReadableStream يواصل الخادم الكتابة فيه. وبعض التفاصيل تصنع الفرق بين « يعمل عندي » و« يعمل خلف proxy حقيقي »:
- A snapshot first: on connect, the stream sends the full current ranking. So if a viewer misses events while reconnecting, it does not matter: the next connection starts from the truth.D'abord un instantané : à la connexion, le flux envoie le classement complet. Si un spectateur rate des événements pendant une reconnexion, ce n'est pas grave : la connexion suivante repart de la vérité.أولاً لقطة كاملة: عند الاتصال، يرسل التدفق الترتيب كاملاً. وإذا فاتت المتابعَ أحداثٌ أثناء إعادة الاتصال، فلا بأس: الاتصال التالي ينطلق من الحالة الصحيحة.
- A heartbeat comment (
: ping) every 25 seconds, because proxies and load balancers close connections that stay silent too long.Un signal de vie (le commentaire: ping) toutes les 25 secondes, parce que les proxys et répartiteurs de charge coupent les connexions trop longtemps silencieuses.إشارة حياة (التعليق: ping) كل 25 ثانية، لأن الـ proxy وموزّعات الحمل تقطع الاتصالات الصامتة لمدة طويلة. retry: 3000tells the browser to reconnect after 3 seconds if the connection drops.retry: 3000demande au navigateur de se reconnecter après 3 secondes si la connexion tombe.retry: 3000يطلب من المتصفح إعادة الاتصال بعد 3 ثوانٍ إذا انقطع الاتصال.- The right headers, so nothing in between buffers the stream:Les bons en-têtes, pour que rien entre les deux ne mette le flux en mémoire tampon :الترويسات الصحيحة، حتى لا يخزّن أي وسيط التدفق مؤقتاً:
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
When the viewer leaves, the request's abort signal fires, and the stream stops its heartbeat and unsubscribes, so nothing leaks. Quand le spectateur part, le signal d'annulation de la requête se déclenche, et le flux arrête son signal de vie et se désabonne : rien ne fuit.عندما يغادر المتابع، تنطلق إشارة إلغاء الطلب، فيوقف التدفق إشارة الحياة ويلغي اشتراكه: لا شيء يتسرّب.
Bursts of scoresLes rafales de scoresدفعات النتائج
Recomputing the ranking after every single score would be wasteful when ten scores arrive in the same second. Each stream forwards the individual score event right away (for the "new score" highlight), but waits 200 ms of calm before sending one fresh ranking.
Recalculer le classement après chaque score serait du gaspillage quand dix scores arrivent dans la même seconde. Chaque flux transmet tout de suite l'événement score (pour mettre en avant le nouveau score), mais attend 200 ms de calme avant d'envoyer un seul classement à jour.إعادة حساب الترتيب بعد كل نتيجة هدر حين تصل عشر نتائج في الثانية نفسها. ينقل كل تدفق حدث score فوراً (لإبراز النتيجة الجديدة)، لكنه ينتظر 200 ms من الهدوء قبل إرسال ترتيب محدَّث واحد.
send("score", event)
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
send("leaderboard", await getLeaderboard(game, period))
}, 200)
The ranking itself is one SQL query: DISTINCT ON ("playerId") keeps each player's best score in the period, and ties go to whoever got there first.
Le classement lui-même est une seule requête SQL : DISTINCT ON ("playerId") garde le meilleur score de chaque joueur sur la période, et en cas d'égalité, le premier arrivé passe devant.الترتيب نفسه استعلام SQL واحد: DISTINCT ON ("playerId") يُبقي أفضل نتيجة لكل لاعب خلال الفترة، وعند التعادل يتقدّم من سبق.
What production taught meCe que la production m'a apprisما علّمتني إياه بيئة الإنتاج
- Connection poolers break LISTEN. My hosted database (Neon) gives a pooled URL, and poolers in transaction mode do not keep a session, so
LISTENsilently gets nothing. The listener uses the direct, unpooled URL; normal queries keep the pooled one.Les poolers de connexions cassent LISTEN. Ma base hébergée (Neon) fournit une adresse avec pooler, et un pooler en mode transaction ne garde pas de session :LISTENne reçoit alors rien, sans erreur. L'écoute utilise l'adresse directe ; les requêtes normales gardent celle avec pooler.مُجمِّعات الاتصالات (poolers) تُعطّل LISTEN. قاعدتي المستضافة (Neon) توفّر عنواناً مع pooler، والـ pooler في وضع المعاملات لا يحتفظ بالجلسة: فلا يستقبلLISTENشيئاً، ودون أي خطأ. يستعمل الاستماع العنوان المباشر؛ وتبقى الاستعلامات العادية على العنوان مع pooler. - The connection will drop. Neon suspends an idle database, networks hiccup. The listener reconnects with exponential backoff (1 s, 2 s, 4 s… up to 30 s), and only while someone is actually subscribed.La connexion va tomber. Neon met en veille une base inactive, le réseau a des ratés. L'écoute se reconnecte avec un délai croissant (1 s, 2 s, 4 s… jusqu'à 30 s), et seulement tant que quelqu'un est vraiment abonné.الاتصال سينقطع حتماً. يضع Neon القاعدة غير النشطة في وضع السكون، وللشبكة هفواتها. يعيد الاستماع الاتصال بمهلة متزايدة (1 s، 2 s، 4 s… حتى 30 s)، وفقط ما دام هناك مشترك فعلي.
- Serverless is the wrong fit. Long-lived SSE streams and a permanent LISTEN connection need a real Node.js server, so the app runs on Render, not on serverless functions.Le serverless ne convient pas. Des flux SSE longs et une connexion LISTEN permanente demandent un vrai serveur Node.js : l'appli tourne donc sur Render, pas sur des fonctions serverless.الـ serverless لا يناسب. تدفقات SSE الطويلة واتصال LISTEN الدائم يتطلبان خادم Node.js حقيقياً: لذا يعمل التطبيق على Render، لا على دوال serverless.
- Keep payloads small. A NOTIFY payload must stay under 8 KB, so events carry ids and values, not whole rankings.Garder des messages petits. Un message NOTIFY doit rester sous 8 Ko : les événements portent des identifiants et des valeurs, pas des classements entiers.إبقاء الرسائل صغيرة. يجب أن تبقى رسالة NOTIFY أقل من 8 KB: تحمل الأحداث معرّفات وقيماً، لا ترتيباً كاملاً.
When I would use something elseQuand j'utiliserais autre choseمتى أستعمل حلاً آخر
NOTIFY is not a durable queue: a server that is offline misses the messages (the snapshot on reconnect covers that here). With very high traffic, or if clients also needed to send messages, Redis or a dedicated WebSocket service would make sense. For a leaderboard, the database I already had was enough. NOTIFY n'est pas une file durable : un serveur hors ligne rate les messages (ici, l'instantané à la reconnexion compense). Avec un très gros trafic, ou si les clients devaient aussi envoyer des messages, Redis ou un service WebSocket dédié auraient du sens. Pour un classement, la base que j'avais déjà suffisait.NOTIFY ليس طابوراً دائماً: الخادم غير المتصل تفوته الرسائل (وهنا تعوّض اللقطة الكاملة عند إعادة الاتصال). مع حركة كبيرة جداً، أو إذا كان على العملاء إرسال رسائل أيضاً، يصبح لـ Redis أو لخدمة WebSocket مخصّصة معنى. أما لترتيب ألعاب، فقاعدة البيانات التي كانت لديّ كفت.
TakeawaysÀ retenirالخلاصة
- For one-way realtime, Server-Sent Events are simpler than WebSockets and reconnect for free.Pour du temps réel dans un seul sens, les Server-Sent Events sont plus simples que les WebSockets et se reconnectent gratuitement.للوقت الحقيقي في اتجاه واحد، Server-Sent Events أبسط من WebSockets وتعيد الاتصال تلقائياً.
- PostgreSQL LISTEN/NOTIFY can sync several servers without adding Redis.LISTEN/NOTIFY de PostgreSQL peut synchroniser plusieurs serveurs sans ajouter Redis.يمكن لـ LISTEN/NOTIFY في PostgreSQL مزامنة عدة خوادم دون إضافة Redis.
- Design for dropped connections from day one: snapshot on connect, heartbeats and backoff.Prévoir les coupures dès le premier jour : instantané à la connexion, signaux de vie et reconnexion progressive.التحسّب للانقطاعات من اليوم الأول: لقطة كاملة عند الاتصال، وإشارات حياة، وإعادة اتصال تدريجية.
Try it on the live demo (free hosting, it may take up to a minute to wake up), or read the code on GitHub. Essayez la démo en ligne (hébergement gratuit, jusqu'à une minute pour démarrer), ou lisez le code sur GitHub.جرّب العرض التجريبي (استضافة مجانية، قد يستغرق التشغيل حتى دقيقة)، أو اقرأ الشيفرة على GitHub.