Introduction
You've built a payment system that works beautifully in staging. Transactions flow through, webhooks arrive reliably, and your team celebrates the launch. Then Black Friday hits, traffic spikes 10x, and suddenly you're losing money to timeout errors, duplicate charges, and rate-limited API calls. Scaling payment systems for Payments & Stripe developers means anticipating these failure modes before they happen.
The difference between a payment system that handles 100 requests per second and one that handles 1,000 isn't just about bigger servers. It's about architectural decisions made months earlier: how you queue webhooks, how you ensure idempotency, how you respect provider limits, and how you rehearse scale with load testing. We'll walk through each one with practical patterns we've deployed in production.
Queue Webhooks — Absorb Spikes with Background Workers
Webhook handlers are synchronous by default. A customer completes checkout, Stripe sends you a payment_intent.succeeded event, your server processes it, updates the database, and returns 200. If your handler takes 500ms and you receive 100 webhooks per second, you need to handle 50 concurrent requests. Add a traffic spike and you're rejecting events.
The solution is deceptively simple: accept the webhook, queue it, return 200 immediately. A background worker processes the queue at its own pace.
// API handler: accept and queue
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Queue the event immediately
await queue.add('process-stripe-event', event, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }
});
// Return success before processing
res.status(200).json({ received: true });
});
// Background worker: process at own pace
queue.process('process-stripe-event', async (job) => {
const event = job.data;
switch (event.type) {
case 'payment_intent.succeeded':
return await handlePaymentSuccess(event.data.object);
case 'charge.dispute.created':
return await handleDispute(event.data.object);
default:
return;
}
});This pattern uses Bull (Redis-backed) or similar queue library. The webhook handler returns instantly, so Stripe sees a 200 within milliseconds. Your worker processes events in the background, retrying on failure. During traffic spikes, events queue up and process as your workers have capacity.
Choose your queue library based on your stack: Bull for Node.js, Celery for Python, Hangfire for .NET, or managed options like AWS SQS. The key is decoupling acceptance from processing.
Idempotency Everywhere — Retries Are Guaranteed Under Load
Network failures happen. A timeout between your service and Stripe might cause your code to retry a charge, creating a duplicate transaction. Under load, timeouts become common, not exceptional.
Stripe provides idempotency keys: include the same key in repeated requests and Stripe returns the cached result. But you need idempotency in your own system too.
// Database schema with idempotency key
interface Payment {
id: string;
idempotencyKey: string;
amount: number;
currency: string;
status: 'pending' | 'succeeded' | 'failed';
stripePaymentIntentId: string;
createdAt: Date;
}
// Handler that retries safely
async function chargeCustomer(
customerId: string,
amount: number,
idempotencyKey: string
) {
// Check if we've already processed this idempotency key
const existing = await db.payments.findOne({
customerId,
idempotencyKey
});
if (existing) {
// Return cached result
if (existing.status === 'succeeded') {
return { success: true, paymentIntentId: existing.stripePaymentIntentId };
} else if (existing.status === 'failed') {
throw new Error(`Payment previously failed: ${existing.id}`);
} else {
// Still pending, caller should retry
throw new Error('Payment still processing');
}
}
// Create a pending record
const payment = await db.payments.create({
customerId,
amount,
idempotencyKey,
status: 'pending'
});
try {
// Pass idempotencyKey to Stripe too
const paymentIntent = await stripe.paymentIntents.create(
{ amount, currency: 'usd', customer: customerId },
{ idempotencyKey }
);
await db.payments.updateOne(
{ id: payment.id },
{ status: 'succeeded', stripePaymentIntentId: paymentIntent.id }
);
return { success: true, paymentIntentId: paymentIntent.id };
} catch (err) {
await db.payments.updateOne(
{ id: payment.id },
{ status: 'failed' }
);
throw err;
}
}
Generate idempotency keys on the client side (typically using a UUID or hash of the transaction). Pass it to your API, which stores it alongside the payment record. If the request retries, you return the cached result. Stripe sees the same key and returns its cached intent.
This pattern prevents the most common money-losing scenario: a customer clicks "pay" twice, the first request times out, the client retries, but your handler processed both, charging them twice. With idempotency, the second request returns the result from the first without creating a new charge.
Rate Limits — Respect Provider API Limits
Stripe imposes rate limits: 100 requests per second for most endpoints. When you hit the limit, Stripe returns a 429 status code. Fire a thousand concurrent requests and you'll get throttled, causing cascading failures.
The solution is threefold: respect limits in your code, implement backoff strategies, and monitor your usage.
// Implement exponential backoff with jitter
async function callStripeWithRetry(
fn: () => Promise,
maxAttempts = 5
): Promise {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
if (err.status === 429) {
// Rate limited
const retryAfter = err.headers['retry-after']
? parseInt(err.headers['retry-after'])
: Math.pow(2, attempt) + Math.random() * 1000; // exponential + jitter
console.log(`Rate limited. Retrying after ${retryAfter}ms`);
await new Promise(resolve => setTimeout(resolve, retryAfter));
continue;
}
throw err;
}
}
throw new Error('Max retry attempts exceeded');
}
// Usage
const charge = await callStripeWithRetry(() =>
stripe.charges.create({
amount: 2000,
currency: 'usd',
customer: customerId
})
);
Beyond client-side retry logic, implement request queuing. Rather than firing requests in parallel, process them sequentially or with a concurrency limit:
// Limit concurrent Stripe API calls
import pLimit from 'p-limit';
const stripeLimit = pLimit(10); // Max 10 concurrent requests
const chargePromises = customers.map(customer =>
stripeLimit(() => stripe.charges.create({ amount: 2000, customer: customer.id }))
);
const results = await Promise.all(chargePromises);
Monitor your rate limit consumption by logging responses and setting alerts. Stripe provides rate-limit headers in responses; track these metrics in your observability platform. If you consistently hit 80% of the limit, it's time to optimize or contact Stripe for a higher limit.
Load Test — Use Test Mode to Simulate Peak Checkouts
You can't know how your payment system scales until you stress-test it. Use Stripe's test mode to simulate realistic payment load without spending money.
// Load test script using k6 or Artillery
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100, // 100 virtual users
duration: '5m', // 5 minute test
rps: 1000 // target 1000 requests per second
};
export default function () {
const payload = JSON.stringify({
idempotencyKey: `load-test-${Date.now()}-${Math.random()}`,
amount: Math.floor(Math.random() * 10000) + 500,
currency: 'usd',
email: `test-${Math.random()}@example.com`
});
const response = http.post(
'http://localhost:3000/api/checkout',
payload,
{
headers: { 'Content-Type': 'application/json' }
}
);
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'no errors': (r) => !r.body.includes('error')
});
sleep(1);
}
Run this against your staging environment with test Stripe API keys. Monitor metrics:
- Response time: Should remain under 500ms at p95 even under load.
- Error rate: Should stay near 0%. Any 429s or timeouts indicate bottlenecks.
- Queue depth: If using background workers, monitor how many events queue up. Deep queues mean workers can't keep pace.
- Database connections: Under load, database connection pools can exhaust. Monitor active connections.
- Stripe API latency: Log response times from Stripe. Slow responses from Stripe will cascade through your system.
Run these tests monthly or before major events. Load testing reveals bottlenecks that code review can't catch.
Common Mistakes
Processing webhooks synchronously: We still see teams processing entire webhook handlers before returning. A single slow database query in the handler blocks Stripe's retry queue. Queue everything.
Skipping idempotency on internal retries: Many teams implement idempotency with Stripe but forget to apply it to their own retry logic. A timeout in your payment service that retries should also use the same idempotency key.
Ignoring Stripe's rate-limit headers: The Stripe SDK includes retry logic, but teams often bypass it or set incorrect concurrency limits. Stripe tells you when you're close to limits via headers; respect them.
Load testing only during crises: Teams often load test only when traffic actually spikes, then scramble to fix issues. Test before you need to.
Assuming test mode is production: Stripe's test mode has different rate limits and behavior than production. Load tests with test keys are useful, but production load tests (with production keys on a dedicated account) reveal more.
Key Takeaways
- Queue webhook processing immediately and return 200. Let background workers handle the actual logic at their own pace.
- Implement idempotency at both the database and Stripe API level. Always provide an idempotency key when creating charges or payment intents.
- Respect provider rate limits with exponential backoff and concurrency limits. Monitor rate-limit consumption and alert when approaching limits.
- Load test regularly in test mode using tools like k6 or Artillery. Monitor response times, error rates, queue depth, and database connections.
- Assume timeouts and retries are normal under load. Build your system to handle them gracefully through idempotency and queuing.






Comments
Loading comments…