Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 047 / 100

Payment retries and timeouts: what developers should know

Network failures happen during payments.

Expona Admin10 min read

We've all been there: a customer initiates a payment, the network hiccups, and suddenly you're unsure whether the charge went through. Payment retries and timeouts: what developers should know covers the practical patterns that separate resilient payment systems from fragile ones. In this post, we'll walk through how timeouts, idempotency, state queries, and user feedback work together to build confidence in your Stripe integration.

Why timeouts and retries matter

Payment processing sits at the boundary between your application and external networks. Unlike a database query that lives entirely within your infrastructure, a payment request to Stripe travels across the internet, introducing latency, packet loss, and connection resets. A request can fail for legitimate reasons—server is unreachable, DNS resolution times out—or for transient ones—a momentary network blip that would succeed if retried.

The danger isn't just a poor user experience. Without proper timeout and retry logic, you risk charging customers multiple times or failing to charge them at all, leading to refund support tickets and eroded trust. We've seen teams discover this the hard way in production, so let's build the right patterns now.

Timeouts — Set client timeouts on API calls

A timeout is a guard rail. If Stripe doesn't respond within a reasonable window, we stop waiting and move on rather than hanging indefinitely. The key is choosing a timeout that's long enough to handle legitimate network delays but short enough to fail fast.

For Stripe API calls, we recommend a 30-second timeout as a starting point. This gives Stripe's infrastructure time to process the request even under load, but prevents your application from blocking for minutes on end. If a request takes longer than 30 seconds, it's likely a network issue rather than Stripe being slow.

Here's how to set timeouts in a Node.js/NestJS environment using the official Stripe SDK:

typescript
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  httpClient: {
    timeout: 30000, // 30 seconds in milliseconds
  },
  maxNetworkRetries: 0, // We'll handle retries ourselves
});

// Example: Create a PaymentIntent with explicit timeout handling
async function createPaymentIntent(amount: number, currency: string) {
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      automatic_payment_methods: {
        enabled: true,
      },
    });
    return { success: true, paymentIntent };
  } catch (error) {
    if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
      // Network timeout: we don't know if the request reached Stripe
      return { success: false, timedOut: true, error };
    }
    // Other errors (validation, authentication, etc.)
    throw error;
  }
}

Notice we set maxNetworkRetries: 0. This prevents the Stripe SDK from retrying automatically, which would hide the timeout from us. We want explicit control over retry logic because we need to check state before retrying.

Retry safely — Idempotency keys make retries safe

An idempotency key is a unique identifier that tells Stripe: "If you've already processed a request with this key, return the same result instead of processing it again." Without idempotency keys, retrying a payment request could charge the customer twice.

Think of it like submitting a form online. If you accidentally double-click the submit button, you want the form to be processed only once, not twice. An idempotency key is the mechanism that ensures this.

In the Stripe API, you pass an idempotency key via the Idempotency-Key HTTP header. The Stripe SDK handles this for you if you use the idempotencyKey option:

typescript
import crypto from 'crypto';

function generateIdempotencyKey(): string {
  return crypto.randomUUID();
}

async function createPaymentIntentWithIdempotency(
  amount: number,
  currency: string,
  customerId: string,
) {
  const idempotencyKey = generateIdempotencyKey();
  
  try {
    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount,
        currency,
        customer: customerId,
        automatic_payment_methods: {
          enabled: true,
        },
      },
      {
        idempotencyKey, // Stripe stores this for 24 hours
      },
    );
    
    // Store the idempotency key with the order for later reference
    await db.orders.update({
      stripe_idempotency_key: idempotencyKey,
      stripe_payment_intent_id: paymentIntent.id,
    });
    
    return paymentIntent;
  } catch (error) {
    if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
      // Timeout occurred, but if we retry with the same idempotency key,
      // Stripe will return the PaymentIntent it created (if any)
      return { timedOut: true, idempotencyKey };
    }
    throw error;
  }
}

The idempotency key must be globally unique for each logical operation. If you're retrying the same payment, reuse the same key. If you're processing a different payment, generate a new key.

Unknown state — Query the PaymentIntent before retrying

A timeout doesn't tell you whether the request was processed or not. The request might have reached Stripe, the PaymentIntent might have been created, and then the response got lost on its way back. Retrying blindly—even with an idempotency key—means you might create two PaymentIntents when you only needed one.

The right approach is to query Stripe for the PaymentIntent before retrying. If it exists, we know the original request succeeded. If not, we can safely retry.

typescript
async function getOrCreatePaymentIntent(
  customerId: string,
  amount: number,
  currency: string,
): Promise {
  const idempotencyKey = crypto.randomUUID();
  
  // Attempt 1: Create the PaymentIntent
  try {
    return await stripe.paymentIntents.create(
      {
        amount,
        currency,
        customer: customerId,
        automatic_payment_methods: { enabled: true },
      },
      { idempotencyKey },
    );
  } catch (error) {
    // Network error: unknown state
    if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
      console.warn('Timeout creating PaymentIntent, checking for existing one');
      
      // Attempt 2: Query for PaymentIntent linked to this customer and amount
      const existingIntents = await stripe.paymentIntents.list({
        customer: customerId,
        limit: 10,
      });
      
      const matchingIntent = existingIntents.data.find(
        (pi) => pi.amount === amount && pi.currency === currency &&
                pi.status === 'requires_payment_method',
      );
      
      if (matchingIntent) {
        console.info('Found existing PaymentIntent, reusing it');
        return matchingIntent;
      }
      
      // Attempt 3: Retry creation with the same idempotency key
      console.warn('No existing PaymentIntent found, retrying creation');
      return await stripe.paymentIntents.create(
        {
          amount,
          currency,
          customer: customerId,
          automatic_payment_methods: { enabled: true },
        },
        { idempotencyKey }, // Same key ensures Stripe returns the one it created before
      );
    }
    
    throw error;
  }
}

This three-step pattern—attempt, query, retry—handles the most common scenarios. In practice, if your API latency is reasonable (under 5-10 seconds), the query will almost always find the existing PaymentIntent, and you'll avoid duplicate attempts.

User feedback — Show processing, not failure, while unsure

While your backend is retrying and querying, your frontend must show the customer something sensible. Showing "Payment failed" when you don't actually know the status is worse than a timeout—it confuses customers and triggers duplicate payment attempts from their end.

The pattern is simple: show "Payment processing" until you know the outcome. Only show success or failure once you're certain.

typescript
// Frontend component (React example)
export function PaymentForm() {
  const [status, setStatus] = useState<'idle' | 'processing' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState('');

  async function handlePayment() {
    setStatus('processing');
    setErrorMessage('');

    try {
      // Call your backend endpoint
      const response = await fetch('/api/create-payment-intent', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          amount: 9999,
          currency: 'usd',
        }),
      });

      if (!response.ok) {
        // Backend returned an error (validation, auth, etc.)
        const error = await response.json();
        setStatus('error');
        setErrorMessage(error.message || 'Payment failed');
        return;
      }

      const data = await response.json();
      
      if (data.timedOut) {
        // Backend timed out but will retry and query
        // Keep showing "processing" and poll for status
        await pollForPaymentStatus(data.paymentIntentId);
        return;
      }

      // Backend has a confirmed PaymentIntent
      setStatus('success');
    } catch (error) {
      setStatus('error');
      setErrorMessage('Network error. Please try again.');
    }
  }

  async function pollForPaymentStatus(paymentIntentId: string) {
    // Poll your backend for the final status
    let attempts = 0;
    while (attempts < 30) {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      
      const response = await fetch(`/api/payment-status/${paymentIntentId}`);
      const data = await response.json();
      
      if (data.status === 'succeeded') {
        setStatus('success');
        return;
      } else if (data.status === 'requires_payment_method') {
        // Still processing or needs action
        attempts++;
        continue;
      } else {
        setStatus('error');
        setErrorMessage('Payment declined');
        return;
      }
    }
    
    // Polling timed out; show error but suggest checking back
    setStatus('error');
    setErrorMessage('Payment status unclear. Please check your email or contact support.');
  }

  return (
    <div>
      
        {status === 'processing' && 'Processing...'}
        {status === 'idle' && 'Pay now'}
        {status === 'success' && 'Success!'}
        {status === 'error' && 'Try again'}
      
      {errorMessage && <p>{errorMessage}</p>}
    </div>
  );
}

On the backend, you'll want a status endpoint that returns the current state of the PaymentIntent:

typescript
app.get('/api/payment-status/:paymentIntentId', async (req, res) => {
  try {
    const paymentIntent = await stripe.paymentIntents.retrieve(req.params.paymentIntentId);
    res.json({
      id: paymentIntent.id,
      status: paymentIntent.status,
      amount: paymentIntent.amount,
    });
  } catch (error) {
    res.status(404).json({ error: 'PaymentIntent not found' });
  }
});

Common mistakes

Mistake 1: Retrying without idempotency keys. If your retry logic runs twice due to a bug or misconfiguration, you'll charge the customer twice. Always use idempotency keys.

Mistake 2: Showing failure immediately on timeout. Customers see "Payment failed" and try again with a different card, leading to multiple PaymentIntents and confusion. Keep showing "processing" until you know the actual state.

Mistake 3: Ignoring network errors in production. Testing with a reliable local network won't catch timeout issues. Simulate network latency and failures using tools like toxiproxy or network throttling in your staging environment.

Mistake 4: Storing idempotency keys per row but not reusing them on retry. If you generate a new idempotency key on each retry, you defeat the purpose. Store the original key and reuse it.

Mistake 5: Setting timeouts too short. A 5-second timeout might work for your local tests but fail under production load when Stripe or your network is slower. 30 seconds is a safe baseline.

Key takeaways

  • Set a 30-second timeout on Stripe API calls and handle timeout errors explicitly.
  • Use idempotency keys on every payment creation request; store and reuse them on retries.
  • After a timeout, query for the PaymentIntent before retrying to avoid duplicates.
  • Show "processing" to customers until you know the final state; avoid premature failure messages.
  • Test timeout and retry logic in staging with network simulation tools.
  • Log all timeouts and retries so you can debug issues in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles

Payment retries and timeouts | Payments & Stripe | Expona