External services fail in ways you cannot predict. A payment API that responds in 2 seconds under normal load might take 30 seconds during peak traffic. A file conversion service might hang without limit. Your application should not break when this occurs.
This guide shows you how to build resilience around external dependencies that run slow, drop connections, or behave unpredictably.
Step 1: Identify Why External Service Calls Timeout
Timeouts happen when a response does not arrive within the expected window. Common causes:
| Cause | Example |
|---|---|
| Service overload | Payment API slow during Black Friday |
| Network congestion | Cross-region call hits routing delays |
| Cold starts | Third-party service spinning up containers |
| Large payloads | Video API processing a 2GB file |
| Downstream dependencies | The service you call is waiting on its own backends |
| Rate limiting | Service throttles your request and delays the response |
The key insight: you often cannot fix the external service. You can only control how your application handles the delay.
Step 2: Decouple the Request from the Wait
The most common mistake is making your user wait while you wait for the external service:
// BAD - user waits for everythingapp.post('/api/create-order', async (req, res) => { const inventory = await externalInventoryCheck(req.body); // 3s const payment = await externalPaymentCharge(req.body); // 8s const shipping = await externalShippingRate(req.body); // 5s // User waited 16 seconds. Or got a timeout error. res.json({ order: { inventory, payment, shipping } });});Instead, accept the request at once and process in the background:
// GOOD - user gets instant responseapp.post('/api/create-order', async (req, res) => { const { task } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/process-order', payload: req.body, timeout: 120, maxRetries: 3, onCompleteUrl: 'https://your-app.com/api/order-complete', });
res.json({ orderId: req.body.orderId, taskId: task.id, status: 'processing' });});Your user sees a response in milliseconds. The external service calls happen in the background with proper timeout handling and retries.
Step 3: Use Retries with Backoff for Transient Failures
Many timeout failures are transient. The service was overloaded for a moment but recovers in seconds. Retries with exponential backoff handle this on autopilot.
await aq.tasks.create({ targetUrl: 'https://payment-api.example.com/charge', payload: { amount: 99.99, customerId: 'cus_123' }, timeout: 30, maxRetries: 3, retryBackoff: 'exponential', // 1s, 2s, 4s between retries});Retry timeline:
Attempt 1: Call API -> timeout after 30sWait 1sAttempt 2: Call API -> timeout after 30sWait 2sAttempt 3: Call API -> success in 4sWithout retries, the first timeout would become a permanent failure. With retries, transient issues resolve on their own.
Tip: Only retry on 5xx errors and timeouts. Do not retry 4xx errors - those indicate a problem with your request that persists across attempts.
Step 4: Use Wait-for-Signal for Callback-Based Services
Some external services do not respond synchronously. They accept your request, process in the background, and notify you when finished. Payment processors, file converters, and identity verification services commonly follow this model.
For these, use waitForSignal:
// Start the processconst { task, signalToken } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/request-kyc-verification', payload: { userId: 'user_456', documentUrl: 'https://...' }, waitForSignal: true, maxWaitTime: 86400, // KYC can take up to 24 hours timeout: 15, // The initial request should be fast});
await db.verifications.create({ userId: 'user_456', signalToken, taskId: task.id,});When the KYC provider calls your webhook:
app.post('/api/kyc-callback', async (req, res) => { const verification = await db.verifications.findByProvider(req.body.referenceId);
await fetch(`https://api.asyncqueue.io/v1/signals/${verification.signalToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: req.body.approved ? 'completed' : 'failed', result: { decision: req.body.decision, details: req.body.details }, }), });
res.json({ received: true });});This pattern eliminates timeout errors for callback-based services. Your initial request finishes fast (registering with the provider), and the result arrives whenever the provider completes its work.
Step 5: Set Appropriate Timeout Values
Generic timeouts (30 seconds for everything) cause two problems: they cut off genuinely slow services too early and let fast services hang too long before failing.
Measure first, then configure:
// Step 1: Log actual response timesconst start = Date.now();const result = await fetch(externalApi);console.log(`External API took ${Date.now() - start}ms`);
// Step 2: Set timeout to 2-3x the 95th percentile// If p95 is 8 seconds, set timeout to 20 secondsRecommended approach by service type:
| External Service | Recommended Timeout | Pattern |
|---|---|---|
| Payment charge | 30s | Fire-and-forget with retries |
| Payment with 3D Secure | 10s initial + 3600s wait | Wait-for-signal |
| File conversion (small) | 60s | Fire-and-forget with retries |
| File conversion (large) | 15s initial + 1800s wait | Wait-for-signal |
| Email API | 10s | Fire-and-forget |
| SMS API | 10s | Fire-and-forget |
| AI inference | 120s | Fire-and-forget with retries |
| Identity verification | 10s initial + 86400s wait | Wait-for-signal |
The pattern: if the external service processes synchronously, use a generous HTTP timeout with retries. If it processes in the background and calls back, use wait-for-signal.