If your functions call external APIs, process webhooks, or handle spiky traffic, you need flow control. Whether it's a flash sale flooding your fulfillment pipeline, a batch import hammering a third-party API, or an AI agent making dozens of tool calls in parallel, these four patterns help you stay in control without writing retry logic or rate limiting by hand.
§Which primitive do you need?
| Symptom | Primitive | What it does |
|---|---|---|
| External APIs return 429s or timeout under load | Throttle | Caps executions per time window |
| Too many functions hit the same resource at once | Concurrency | Caps parallel executions per key |
| One action triggers the same function multiple times | Debounce | Waits for a burst to settle, runs once |
| Webhooks or events arrive more than once | Idempotency | Guarantees exactly-once execution per key |
Most real workloads need two or more of these. Start with the one that matches your most painful symptom, then layer.
Throttle vs. Concurrency: when it's not obvious
Throttle is about rate (X per minute). Concurrency is about parallelism (X at the same time). If your API says "100 requests per minute," use throttle. If your database performs poorly with more than 20 concurrent writes, use concurrency.
§Primitive 1: Throttle
You need this if: You're calling a third-party API (Stripe, Shopify, SendGrid, any shipping provider) and you've seen 429 errors or silent failures under load.
Without throttle: Retries compound the problem. Each one hits the same rate limit, backs off, and tries again.
01import { inngest } from "./client";0203export const generateShippingLabel = inngest.createFunction(04 {05 id: "generate-shipping-label",06 triggers: [{ event: "order/fulfillment.ready" }],07 throttle: {08 limit: 80, // stay under the provider's 100/min limit09 period: "1m",10 key: "event.data.carrier", // separate budget per carrier11 },12 },13 async ({ event, step }) => {14 // Create the label. Throttle ensures we never exceed the carrier's rate limit.15 const label = await step.run("create-label", async () => {16 return await shippingProvider.createLabel({17 orderId: event.data.order_id,18 address: event.data.shipping_address,19 carrier: event.data.carrier,20 });21 });2223 // Update the order with tracking info. Separate step so it retries independently.24 await step.run("update-order-tracking", async () => {25 await db.orders.update({26 where: { id: event.data.order_id },27 data: { trackingNumber: label.tracking_number },28 });29 });30 }31);Adapt it:
- Change
limitandperiodto match your API's published rate limit. Leave 10-20% headroom. - Set
keyto whatever dimension your rate limit applies to (API key, account, carrier, region). Omitkeyif the limit is global. - Throttled runs are queued and processed in order once capacity is available.
§Primitive 2: Concurrency
You need this if: Multiple function runs hit the same database, warehouse, or downstream service, and you've seen timeouts, connection pool exhaustion, or degraded performance under load.
Without concurrency: Parallel requests overwhelm connection pools and cause timeouts across unrelated functions.
01import { inngest } from "./client";0203export const processOrderFulfillment = inngest.createFunction(04 {05 id: "process-order-fulfillment",06 triggers: [{ event: "order/created" }],07 concurrency: [08 {09 key: "event.data.warehouse_id", // each warehouse gets its own limit10 limit: 10,11 },12 ],13 },14 async ({ event, step }) => {15 // Check inventory. Concurrency limit prevents overwhelming the warehouse API.16 const inventory = await step.run("check-inventory", async () => {17 return await warehouse.checkStock({18 warehouseId: event.data.warehouse_id,19 sku: event.data.sku,20 });21 });2223 // Reserve stock. Runs only after inventory check succeeds.24 await step.run("reserve-stock", async () => {25 await warehouse.reserveUnits({26 warehouseId: event.data.warehouse_id,27 sku: event.data.sku,28 quantity: event.data.quantity,29 });30 });3132 // Hand off to shipping. Triggers the throttled label generation function.33 await step.run("route-to-shipping", async () => {34 await inngest.send({35 name: "order/fulfillment.ready",36 data: {37 order_id: event.data.order_id,38 warehouse_id: event.data.warehouse_id,39 carrier: inventory.preferred_carrier,40 shipping_address: event.data.shipping_address,41 },42 });43 });44 }45);Adapt it:
- Set
keyto the resource you're protecting (warehouse, database shard, tenant ID, API account). - Set
limitbased on what the downstream system can handle. Start conservative; you can always increase. - You can stack multiple concurrency keys on one function (e.g., limit per warehouse and per tenant).
§Primitive 3: Debounce
You need this if: A single user action or external event fires multiple webhooks in quick succession, and your function does redundant work processing each one. Shopify, for example, can fire several order/updated webhooks within seconds of a single change.
Without debounce: A single order change can fire 5-8 webhooks in 30 seconds, each triggering the same fulfillment logic.
01import { inngest } from "./client";0203export const syncOrderToFulfillment = inngest.createFunction(04 {05 id: "sync-order-to-fulfillment",06 triggers: [{ event: "order/updated" }],07 debounce: {08 key: "event.data.order_id", // one debounce window per order09 period: "30s", // wait 30s after the last event10 },11 },12 async ({ event, step }) => {13 // event contains the *last* event fired before the debounce window closed.14 // However, that event payload can be stale by the time the function fires15 // (the debounce window itself adds latency). Refetch the current state to16 // guarantee you're working with the authoritative record, not a snapshot.17 const order = await step.run("fetch-latest-order", async () => {18 return await shopify.orders.get(event.data.order_id);19 });2021 // Sync once with the latest data instead of 5 times with intermediate states.22 await step.run("sync-to-fulfillment-system", async () => {23 await fulfillment.syncOrder({24 orderId: order.id,25 status: order.status,26 lineItems: order.line_items,27 shippingAddress: order.shipping_address,28 });29 });30 }31);Adapt it:
- Set
keyto the entity generating rapid-fire events (order ID, user ID, document ID). - Set
periodbased on how long your bursts typically last. For Shopify webhooks, 15-30s is usually enough. For real-time user input, 1-5s. - The function fires once, with the last event's data. If you need data from every event in the burst, debounce isn't the right tool. Use idempotency with a step that fetches current state instead.
§Primitive 4: Idempotency
You need this if: Your event source has at-least-once delivery (most webhooks do, including Shopify's), and duplicate execution has real consequences: double charges, duplicate shipments, duplicate records.
Without idempotency: A webhook retry delivers the same payment/authorized event twice. Two payment captures fire. The customer gets charged double and you get a support ticket.
01import { inngest } from "./client";0203export const capturePayment = inngest.createFunction(04 {05 id: "capture-payment",06 triggers: [{ event: "payment/authorized" }],07 // payment_id is our internal dedup key; payment_intent_id is Stripe's identifier08 idempotency: "event.data.payment_id", // one execution per payment, guaranteed09 },10 async ({ event, step }) => {11 // Capture the charge. Idempotency ensures this only runs once per payment_id.12 const capture = await step.run("capture-charge", async () => {13 return await stripe.paymentIntents.capture(14 event.data.payment_intent_id15 );16 });1718 // Record in your database. Separate step so it retries independently if DB is down.19 await step.run("record-payment", async () => {20 await db.payments.create({21 data: {22 orderId: event.data.order_id,23 amount: capture.amount,24 status: "captured",25 },26 });27 });2829 // Send receipt. Only fires once even if the webhook delivered twice.30 await step.run("send-receipt", async () => {31 await email.send({32 to: event.data.customer_email,33 template: "payment-receipt",34 data: { amount: capture.amount, orderId: event.data.order_id },35 });36 });37 }38);Adapt it:
- Set the idempotency expression to a field that uniquely identifies the logical event: payment ID, webhook delivery ID, transaction ID.
- If your events don't have a natural unique key, generate one at the event source (e.g., a hash of order ID + event type + timestamp).
- Idempotency keys expire after 24 hours. For longer windows, use
rateLimitwithlimit: 1and a customperiod.
§Composing primitives
These patterns stack. Here's what a production function handling a fulfillment pipeline looks like with all four:
01import { inngest } from "./client";0203export const fulfillOrder = inngest.createFunction(04 {05 id: "fulfill-order",06 triggers: [{ event: "order/fulfillment.ready" }],0708 // Don't process the same webhook twice.09 // idempotency_key is set at the event source — often a webhook delivery ID.10 idempotency: "event.data.idempotency_key",1112 // Wait for rapid-fire order updates to settle13 debounce: {14 key: "event.data.order_id",15 period: "30s",16 },1718 // Max 10 orders processing per warehouse at a time19 concurrency: [20 { key: "event.data.warehouse_id", limit: 10 },21 ],2223 // Stay under shipping API rate limits24 throttle: {25 key: "event.data.carrier",26 limit: 80,27 period: "1m",28 },29 },30 async ({ event, step }) => {31 // Your fulfillment logic here. Each step is individually retriable,32 // and the function is deduplicated, debounced, concurrency-limited,33 // and throttled.34 }35);You don't need all four on every function. Start with the one that solves your current pain, deploy it, and add more as you find new failure modes.