Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesHow do I add tenant-based concurrency limits in Inngest (one customer can’t starve everyone else)?
You’re right to worry about one noisy tenant starving everyone else. In multi-tenant systems, “let’s just add a queue” turns into “why did this one customer’s backlog freeze our entire platform?”—especially when you’re processing webhooks, sync jobs, or AI workflows.
With Inngest, you don’t bolt concurrency on after the fact. You express tenant-based limits directly in code using concurrency keys and flow control so each customer gets their own virtual queue and can’t trample others.
This guide walks through how to add tenant-based concurrency limits in Inngest, how the mechanics work, and how to evolve from simple caps to full-blown multi-tenant flow control.
Why tenant-based concurrency matters
In a multi-tenant workload, you typically want to guarantee:
- One tenant can’t consume all workers and starve others.
- Work for a single tenant runs in order, without overlapping runs that corrupt shared state.
- You can set different limits per tenant or per “space/account” as needed.
- You don’t have to maintain N physical queues, workers, and DLQs.
Traditional approach: you spin up queues per tenant, rate limit per queue, and maintain workers and custom metrics. That’s the infrastructure tax.
Inngest’s approach: express tenant isolation and limits in your function config. Inngest turns that into virtual queues using concurrency keys—no extra workers, queues, or cron wiring.
How tenant-based concurrency works in Inngest
At a high level:
- You define an Inngest function with
inngest.createFunction(). - You attach concurrency rules to that function’s config.
- Each run is associated with a concurrency key (for example, a
spaceId,customerId, ororganizationId). - Inngest enforces:
- Per-key concurrency (how many runs for a tenant can be in-flight).
- Global limits (how many runs for this function overall).
- Additional policies like rate limiting and debounce.
The result is what GitBook and others leaned on: each space/customer effectively gets its own sequenced queue, but you don’t manage any queue infrastructure.
Basic pattern: concurrency keys per tenant
Here’s the core TypeScript pattern for tenant-based concurrency limits:
import { inngest } from "@/inngest/client";
export const processTenantEvent = inngest.createFunction(
{
id: "process-tenant-event",
concurrency: {
// Global max in-flight runs for this function
limit: 100,
// We’ll define tenant-based keys below
key: "event.data.tenantId",
// Optional: cap concurrency per tenant
keyLimit: 1,
},
},
{ event: "app/tenant.updated" },
async ({ event, step }) => {
// This run is now tied to event.data.tenantId for concurrency.
const tenantId = event.data.tenantId;
await step.run("sync-tenant", async () => {
// Your normal business logic here
// e.g. sync tenant settings, recompute permissions, etc.
});
}
);
Key points:
key: "event.data.tenantId"tells Inngest: group concurrency by this tenant.keyLimit: 1(or similar) ensures only one in-flight run per tenant.limit: 100prevents this function from flooding your system overall.
When a tenant suddenly sends 10,000 events, those runs are queued for that tenant’s key and processed up to keyLimit, while other tenants continue flowing.
Think of it as per-tenant virtual queues with limits you express once in code.
Example: one customer can’t starve everyone else
Let’s make it concrete. Imagine you’re processing inbound webhooks from many customers and doing a multi-step sync per event.
Without tenant-based limits
- A single noisy customer floods you.
- Their runs occupy all workers.
- Other tenants’ jobs back up.
- You’re staring at logs and DLQs to figure out what’s stuck.
With Inngest concurrency keys
export const syncCustomerData = inngest.createFunction(
{
id: "sync-customer-data",
concurrency: {
// total in-flight syncs across all customers
limit: 200,
// Group by customer – each gets its own virtual queue
key: "event.data.customerId",
// How much concurrency you allow per customer
keyLimit: 2,
},
},
{ event: "app/customer.sync" },
async ({ event, step }) => {
const customerId = event.data.customerId;
const account = await step.run("fetch-account", async () => {
// Fetch from DB or external API
});
await step.run("sync-resources", async () => {
// Maybe fan out to more steps or APIs
});
await step.run("update-status", async () => {
// Mark sync complete & record metrics
});
}
);
Behavior:
- No tenant can execute more than 2 runs concurrently (
keyLimit: 2). - Even if one customer is constantly triggering syncs, others still get scheduled and executed.
- Ordering per customer is predictable: new runs respect the virtual queue for that key.
You didn’t have to create queues per customer. You just told Inngest the concurrency policy.
Design options: how strict should tenant concurrency be?
You can tune how strict the per-tenant limits are depending on your workload.
1. Strict serialization per tenant (keyLimit: 1)
Best for:
- Flows that mutate shared per-tenant state.
- Syncs where overlapping runs might corrupt data.
- Workloads where “one job at a time” is the safest rule.
concurrency: {
limit: 100,
key: "event.data.spaceId",
keyLimit: 1, // no overlapping runs per space
}
2. Limited parallelism per tenant (keyLimit > 1)
Best for:
- “Embarrassingly parallel” work that’s safe to fan out.
- API call-heavy pipelines where you want to speed up large tenants but not let them dominate.
concurrency: {
limit: 300,
key: "event.data.orgId",
keyLimit: 5, // up to 5 in-flight runs per org
}
3. Tiered or dynamic limits by plan
If you have pricing tiers, you may want “Pro” tenants to get more concurrency.
Model this at the edge of your events:
// Example: map plan -> concurrency keyLimit in your config
const planConcurrency = {
free: 1,
pro: 3,
enterprise: 10,
} as const;
// At build time, generate functions per tier
(Object.entries(planConcurrency) as Array<[string, number]>).forEach(
([plan, keyLimit]) => {
inngest.createFunction(
{
id: `process-events-${plan}`,
concurrency: {
limit: 500,
key: "event.data.tenantId",
keyLimit,
},
},
{ event: `app/${plan}.event` },
async ({ event, step }) => {
// ...
}
);
}
);
Or, if you route all tenants through the same function, you can use a more generic key and encode tier logic in how often you emit events per tenant.
Flow control beyond concurrency: rate limiting & debounce
Concurrency keys ensure how many runs can be in-flight at once, but you may also want to manage how frequently a given tenant triggers work.
Inngest’s flow control layer (which GitBook and Otto use heavily) lets you:
- Rate-limit per tenant key – cap requests per interval.
- Debounce – collapse bursts of events into a single run.
- Apply prioritization across queues of work.
A common pattern: combine per-tenant concurrency with per-tenant rate limits so a misconfigured client can’t flood you with events.
Conceptually, in your function config:
export const syncSpace = inngest.createFunction(
{
id: "sync-space",
concurrency: {
limit: 200,
key: "event.data.spaceId",
keyLimit: 1,
},
// Pseudocode, actual API may differ depending on SDK version:
rateLimit: {
key: "event.data.spaceId",
limit: 60, // 60 runs
window: "1 minute", // per space per minute
},
debounce: {
key: "event.data.spaceId",
window: "10 seconds",
},
},
{ event: "app/space.updated" },
async ({ event, step }) => {
// ...
}
);
This ensures:
- Each space only has one in-flight sync.
- You won’t run more than 60 syncs per space per minute.
- A burst of “space updated” events within 10 seconds collapses into a single run.
The noisy neighbor gets fenced in at both the concurrency and rate layers.
Mechanism → outcome: concurrency keys + rate limiting + debounce = one tenant can’t starve everyone else, even under pathological traffic.
Observability: prove your tenant limits are working
The advantage of expressing concurrency in Inngest instead of infrastructure is that you get observability out of the box.
With Traces, you can:
- Filter runs by tenant (e.g.,
event.data.customerId = "123"). - See runs queued vs. executing, per concurrency key.
- Inspect step inputs/outputs when something is slow or stuck.
- Replay failed workflows without violating concurrency policies.
For example:
- Open Traces in Inngest Cloud.
- Filter by function
sync-customer-data. - Add a filter on
event.data.customerIdfor the noisy tenant. - You’ll see:
- Runs queued behind the tenant’s concurrency key.
- The rest of your tenants’ runs continuing normally.
If you need to throttle or cancel a backlog:
- Use Bulk Cancellation to drop a noisy tenant’s queued runs.
- Update your concurrency/rate limit config in code.
- Redeploy; Inngest applies the new flow control without you reconfiguring any queues or workers.
Putting it all together: a practical recipe
Here’s a pragmatic way to introduce tenant-based concurrency limits into an existing Inngest setup.
1. Identify your “multi-tenant hot paths”
- Webhook processors (e.g., Stripe, GitHub, custom SaaS webhooks).
- Sync jobs (bi-directional syncs, data pipelines).
- AI pipelines (tool-using agents, long-running workflows).
These are the flows where one customer can accidentally starve everyone else.
2. Define a stable tenant key
Choose something that uniquely identifies “ownership”:
customerIdspaceIdorgIdprojectId
Make sure this key is present in your events (e.g., event.data.customerId).
3. Add concurrency config with a key
In each critical function:
concurrency: {
limit: 200, // global cap for this function
key: "event.data.orgId", // multi-tenant isolation
keyLimit: 1, // serialize per org (start here, loosen later)
}
Deploy and watch Traces for a day or two to confirm behavior.
4. Iterate to the right keyLimit and rate limits
- If per-tenant work is safe to parallelize, increase
keyLimit. - If you still see spikes or backlog, add per-tenant
rateLimitanddebounce. - For very large tenants, consider dedicated functions or event types with higher limits.
5. Use Traces & Replay for ongoing operations
Once concurrency is enforced:
- You can safely replay failed runs for a tenant; the same concurrency rules apply.
- You can inspect and debug specific tenants without any ad-hoc trace IDs.
- You can protect SLOs for “everyone else” even when a single customer misbehaves.
Example end-to-end function with tenant-based limits
Here’s a more complete TypeScript example that mirrors real-world SaaS usage:
import { inngest } from "@/inngest/client";
export const syncWorkspace = inngest.createFunction(
{
id: "sync-workspace",
// Flow control: tenant isolation + protection from noisy neighbors
concurrency: {
limit: 300, // max total in-flight syncs
key: "event.data.workspaceId", // each workspace acts like its own queue
keyLimit: 1, // serialize per workspace
},
// (Pseudocode, confirm exact API in docs)
rateLimit: {
key: "event.data.workspaceId",
limit: 120, // 120 syncs
window: "5 minutes", // per workspace
},
debounce: {
key: "event.data.workspaceId",
window: "15 seconds",
},
},
{ event: "app/workspace.updated" },
async ({ event, step }) => {
const workspaceId = event.data.workspaceId;
const workspace = await step.run("load-workspace", async () => {
// load from DB
});
const remoteState = await step.run("fetch-remote-state", async () => {
// call external APIs
});
await step.run("diff-and-apply", async () => {
// compute diff and apply changes
});
await step.run("record-metrics", async () => {
// log metrics, update status
});
}
);
Tenants now have:
- Isolation: one workspace can’t starve others.
- Predictability: runs queue per workspace and execute in order.
- Safety: retries and checkpointing via
step.run()prevent partial state and wasted work.
You get:
- No custom queue infra.
- No dead-letter queue spelunking.
- Ability to query, cancel, or replay tenant workflows directly from Inngest’s UI.
Final thoughts
Tenant-based concurrency limits in Inngest are about expressing how your multi-tenant system should behave instead of wiring infrastructure to approximate it.
By using:
- Concurrency keys (
key) to isolate tenants, - Per-tenant limits (
keyLimit) to prevent starvation, - Global caps (
limit) to protect your platform, - And optionally rate limit + debounce for flow control,
you get per-tenant virtual queues and multi-tenant safety without rebuilding workers, queues, throttlers, and DLQ tooling.
If you want help designing the right concurrency keys and limits for your workload—or you’re migrating from a homegrown queue stack—Get Started with Inngest and we can walk through your architecture together.