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');