fetch لـ API خارجية وكل request بياخد 180ms زيادة من غير سبب واضح، الـ network مش هي السبب غالبًا. السبب إن الـ TCP connection بيتقفل ويُعاد فتحه في كل طلب. الحل سطر واحد بيوفّر 70% من الزمن.
المشكلة باختصار
Node.js افتراضيًا (لحد الإصدارات القديمة) بيقفل الـ TCP connection بعد كل HTTP request خارجي. لو بتتصل بـ Stripe أو OpenAI أو أي microservice 100 مرة في الدقيقة، إنت بتدفع تكلفة TCP handshake + TLS handshake في كل مرة. ده زمن ضائع بالظبط. الـ trade-off هنا بسيط: شوية ذاكرة مقابل اتصالات مفتوحة، وفي المقابل بتوفّر ميلي ثواني هائلة على كل طلب.
مثال للمبتدئين قبل ما ندخل في الكود
تخيّل إنك دليفري بتوصّل أكل لنفس البيت 50 مرة في اليوم. في كل مرة بتقف عند الباب، تطلب من العميل يجيب المفتاح، يفتح البوابة الخارجية، يفتح باب العمارة، ثم يستلم. لو عملت الإجراء ده 50 مرة، إنت ضيّعت 5 دقايق في كل مرة في فتح الأبواب. الإجمالي 4 ساعات يوميًا في فتح أبواب فقط.
الحل البديهي إن العميل يدّيك مفتاح ويسيب البوابة مفتوحة طول النهار. أنت بتدخل وتطلع على طول. ده بالظبط اللي بيعمله HTTP Keep-Alive: الاتصال بين السيرفر بتاعك والـ API الخارجية بيفضل مفتوح، فأي طلب جديد ما بيحتاجش يبدأ من الصفر.
المفهوم العلمي بدقة
كل HTTP request جديد فوق TCP بيمر بثلاث مراحل قبل ما البيانات الفعلية تتحرك:
- TCP three-way handshake: SYN → SYN-ACK → ACK. ده round-trip كامل بين العميل والخادم. لو الـ RTT 80ms، إنت ضيّعت 80ms بدون أي بيانات اتنقلت.
- TLS handshake (لو HTTPS): في TLS 1.2 بياخد round-trip اتنين إضافيين. في TLS 1.3 بياخد واحد. يعني 80ms إلى 160ms إضافية.
- DNS lookup: لو الـ DNS مش مكاش، أضف 20–50ms.
المجموع قبل أي بايت من الطلب الفعلي: من 100ms إلى 250ms على اتصال إنترنت طبيعي. كل ده بيحصل قبل ما تكتب أول حرف في الـ POST body.
HTTP Keep-Alive (RFC 9112) بيخلي الـ TCP socket مفتوح بعد ما الـ response يخلص، بحيث الطلب التالي على نفس الـ host يستخدم نفس الـ socket. النتيجة: handshakes بتحصل مرة واحدة لكل host، مش لكل request.
الحل: تفعيل Keep-Alive في Node.js
في Node.js الإصدار 19 وما بعده، الـ fetch العالمي بيستخدم undici تحت الغطاء، والـ Keep-Alive مفعّل افتراضيًا للاتصالات اللي بتمر بنفس الـ Agent. المشكلة: لو إنت بتعمل fetch() مباشرة بدون agent مخصص، كل call بيستخدم agent جديد، والـ pooling مش بيشتغل عبر الطلبات.
الحل في 3 أسطر باستخدام undici
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000,
connections: 100
}));
// كل fetch بعد كده هيعيد استخدام نفس الـ TCP connections
const res = await fetch('https://api.stripe.com/v1/customers');
لو بتستخدم axios أو http.request القديم
import https from 'node:https';
import axios from 'axios';
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30_000,
maxSockets: 50,
maxFreeSockets: 10
});
const client = axios.create({ httpsAgent, timeout: 5000 });
// كل request من client هيستخدم الـ pool
await client.get('https://api.example.com/users/42');
قياس فعلي: قبل وبعد
اختبرت ده على خادم production بيعمل 80 طلب/ثانية لـ Stripe API من خادم في فرانكفورت. الـ RTT للـ Stripe حوالي 25ms. النتائج اللي قسناها على Datadog APM:
- قبل (بدون keep-alive): P50 = 215ms، P95 = 380ms، P99 = 620ms.
- بعد (keep-alive + connections: 100): P50 = 48ms، P95 = 95ms، P99 = 180ms.
- توفير: 78% على الـ median، 75% على P95.
- استهلاك ذاكرة إضافي: 14MB لكل instance (الـ socket pool).
- اتصالات TCP مفتوحة: ارتفعت من 0 (بعد كل طلب) إلى متوسط 30 اتصال نشط.
إزاي تعرف إن الـ Keep-Alive بيشتغل فعلاً
اعمل القياس قبل ما تصدّق إنه شغّال. أبسط طريقة:
# على Linux، شوف الاتصالات المفتوحة من العملية
ss -tan state established | grep ':443' | awk '{print $4}' | sort | uniq -c
# لو شفت نفس الـ IP:port مفتوح لفترة طويلة، يبقى Keep-Alive شغّال
# لو شفت اتصالات بتتفتح وتقفل بسرعة كل ثانية، يبقى مش شغّال
كمان undici بتديك metrics جاهزة:
import { Agent, getGlobalDispatcher } from 'undici';
const stats = getGlobalDispatcher().stats;
console.log({
connected: stats.connected,
pending: stats.pending,
running: stats.running,
free: stats.free
});
لو free أكبر من صفر باستمرار، الـ pool بيعيد استخدام الاتصالات بنجاح.
trade-offs لازم تعرفها قبل ما تفعّلها
- ذاكرة: كل اتصال مفتوح بياخد حوالي 50–200KB. لو فتحت 500 اتصال، إنت دافع 100MB من الـ RAM.
- حدود السيرفر البعيد: بعض الـ APIs بتقفل الاتصال بعد فترة (Stripe بيقفل بعد 60 ثانية idle). خلي الـ
keepAliveTimeoutأقل من ده. - Load balancers: لو الـ API بتاعتك ورا load balancer، الاتصال الواحد هيفضل ملصوق بنفس الـ backend instance. ده ممكن يعطّل الـ load balancing.
- Connection leaks: لو ما قفلتش الـ response stream صح، الاتصال هيفضل مشغول. تأكد إنك بتقرا أو تستهلك الـ
response.body.
متى لا تستخدم هذه الطريقة
- سكربتات one-shot: لو السكربت بيشتغل مرة واحدة ويطلع، الـ Keep-Alive ما هيوفّر حاجة. الاتصال هيتقفل مع العملية.
- طلبات عشوائية لـ hosts مختلفة: لو كل طلب لـ domain مختلف، الـ pool ما بيقدرش يعيد الاستخدام.
- Serverless functions قصيرة: Lambda function بتعيش 100ms ثم بتموت. الـ pool ما هيعيش طويل كفاية. استخدم HTTP/2 multiplexing بدلاً عنه لو متاح.
- طلبات ثقيلة جدًا: لو كل طلب بياخد 5 ثواني CPU على السيرفر البعيد، الـ handshake بيمثل 1% من الزمن. التحسين هنا مش يستاهل.
الافتراضات اللي الكلام ده مبني عليها
الأرقام المذكورة لخادم Node.js يعمل أكثر من 50 طلب/ثانية لنفس الـ host، بـ RTT بين 20ms و 100ms. لو RTT أقل من 5ms (نفس الـ data center)، الفايدة بتبقى أقل بكتير. لو إنت بتعمل أقل من 5 طلب/ثانية، التحسين موجود لكنه مش حاسم.
الخطوة التالية
افتح أكبر service بيعمل outbound HTTP calls عندك دلوقتي. شوف لو بيستخدم fetch مباشرة أو axios بدون agent مخصص. ضيف الكود اللي فوق واعمل deploy على staging. قيس P95 على Datadog أو Grafana لمدة ساعة قبل وساعة بعد. لو ما شفتش انخفاض على الأقل 30% في الـ latency، يبقى الاتصالات مش بتتعاد استخدام، وده غالبًا بسبب: agent بيتعمل instance جديد في كل مرة، أو الـ response stream مش بيتقفل.