What are the most confident teams using to build AI? → 2026 Benchmark Report
Flow Control/Flash sales and bursty workflows
01Flow Control

Spike-proof the boring stuff

Flash sales and bursty workflows

Use throttle, concurrency, debounce, and idempotency to handle traffic spikes without overwhelming downstream services.

Flow ControlReliability

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?

SymptomPrimitiveWhat it does
External APIs return 429s or timeout under loadThrottleCaps executions per time window
Too many functions hit the same resource at onceConcurrencyCaps parallel executions per key
One action triggers the same function multiple timesDebounceWaits for a burst to settle, runs once
Webhooks or events arrive more than onceIdempotencyGuarantees 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.

typescript
01import { inngest } from "./client";
02
03export 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 limit
09 period: "1m",
10 key: "event.data.carrier", // separate budget per carrier
11 },
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 });
22
23 // 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 limit and period to match your API's published rate limit. Leave 10-20% headroom.
  • Set key to whatever dimension your rate limit applies to (API key, account, carrier, region). Omit key if 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.

typescript
01import { inngest } from "./client";
02
03export 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 limit
10 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 });
22
23 // 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 });
31
32 // 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 key to the resource you're protecting (warehouse, database shard, tenant ID, API account).
  • Set limit based 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.

typescript
01import { inngest } from "./client";
02
03export 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 order
09 period: "30s", // wait 30s after the last event
10 },
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 fires
15 // (the debounce window itself adds latency). Refetch the current state to
16 // 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 });
20
21 // 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 key to the entity generating rapid-fire events (order ID, user ID, document ID).
  • Set period based 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.

typescript
01import { inngest } from "./client";
02
03export 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 identifier
08 idempotency: "event.data.payment_id", // one execution per payment, guaranteed
09 },
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_id
15 );
16 });
17
18 // 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 });
28
29 // 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 rateLimit with limit: 1 and a custom period.

§Composing primitives

These patterns stack. Here's what a production function handling a fulfillment pipeline looks like with all four:

typescript
01import { inngest } from "./client";
02
03export const fulfillOrder = inngest.createFunction(
04 {
05 id: "fulfill-order",
06 triggers: [{ event: "order/fulfillment.ready" }],
07
08 // 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",
11
12 // Wait for rapid-fire order updates to settle
13 debounce: {
14 key: "event.data.order_id",
15 period: "30s",
16 },
17
18 // Max 10 orders processing per warehouse at a time
19 concurrency: [
20 { key: "event.data.warehouse_id", limit: 10 },
21 ],
22
23 // Stay under shipping API rate limits
24 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.


§Additional Resources