Applies to: Chat | All SDKs | All platforms
Affected endpoint: QueryChannels
HTTP status: 429 Too Many Requests
Quick Answer (2 minutes)
- API budget rate limiting is a new layer of protection that limits the total database execution time your app consumes, measured in milliseconds per minute. This is separate from the existing request-count rate limits.
- Only expensive queries (those that cannot use database indexes efficiently) consume budget. Simple, well-optimized queries are free.
- Currently applies to the
QueryChannelsendpoint only. - When your budget is exhausted, you receive HTTP 429 with the error code
rate_limit_exceededand the message "Query budget exceeded. Please reduce query frequency or complexity." - Check
X-Budget-Remaining-Msin everyQueryChannelsresponse to monitor your remaining budget before you hit the limit. - The fastest fix: simplify your query filters to use indexed fields and reduce filter complexity. See Optimizing Your Queries below.
How API Budget Rate Limiting Works
Why a new kind of rate limit?
Traditional rate limits count requests per minute. But not all requests are equal — a simple QueryChannels call that filters by type might take 1ms of database time, while a complex query with full-text search, multiple $or branches, and sorting by a custom field might take 500ms or more. Under request-count limits alone, a single app running expensive queries can consume disproportionate database resources while staying within its request quota.
API budget rate limiting solves this by measuring actual database execution time in milliseconds. Each app gets a time budget per minute. Simple queries that use indexes efficiently are free and don't consume any budget. Only expensive queries that require sequential scans or complex joins are metered.
The two types of rate limits
| Request-Count Rate Limit (existing) | API Budget Rate Limit (new) | |
|---|---|---|
| What it measures | Number of API requests per minute | Database execution time (ms) per minute |
| Scope | All endpoints | Currently QueryChannels only |
| What counts | Every request counts equally | Only expensive (non-optimized) queries consume budget |
| Headers |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
|
X-Budget-Limit-Ms, X-Budget-Used-Ms, X-Budget-Remaining-Ms
|
| Error response | {"code": 9, "message": "You have exceeded the rate limit..."} |
{"code": "rate_limit_exceeded", "message": "Query budget exceeded..."} |
| Recovery | Wait for the rate limit window to reset | Wait for budget to free up, and/or simplify your queries |
Both limits apply independently. A request can pass the request-count limit but be denied by the budget limit, or vice versa. You need to stay within both.
Budget Response Headers
Every QueryChannels response — both successful (200) and denied (429) — includes these headers:
| Header | Description | Example |
|---|---|---|
X-Budget-Used-Ms |
Total database time (ms) consumed in the current sliding window. | 45200 |
X-Budget-Limit-Ms |
Your total budget (ms) for this endpoint per window. | 200000 |
X-Budget-Remaining-Ms |
Budget remaining before requests will be denied. | 154800 |
Retry-After |
Seconds until budget frees up. Only present on 429 responses. | 60 |
The budget uses a sliding window, so budget frees up continuously as older usage expires — there is no hard reset cliff. The Retry-After header indicates the full window duration as a conservative estimate.
429 Error Response Format
When your budget is exhausted, you receive:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-Budget-Used-Ms: 200000
X-Budget-Limit-Ms: 200000
X-Budget-Remaining-Ms: 0
Retry-After: 60
{
"code": "rate_limit_exceeded",
"message": "Query budget exceeded. Please reduce query frequency or complexity.",
"request_id": "abc123..."
}How to distinguish budget 429s from request-count 429s:
- Budget denials include
X-Budget-Used-Ms/X-Budget-Limit-Ms/X-Budget-Remaining-Msheaders and the message contains "Query budget exceeded". - Request-count denials include
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Resetheaders and use error code9.
What Makes a Query "Expensive"?
The system classifies each QueryChannels query as either optimized (free) or not-optimized (consumes budget). The classification happens after all system defaults are applied, so it evaluates the actual query that runs against the database.
Optimized queries (free — no budget consumed)
A query is optimized when it uses only indexed filter fields, stays within complexity limits, and sorts on an indexed field. These queries are efficient and do not consume any budget.
Indexed filter fields:
cidtypelast_message_atlast_updatedcreated_atupdated_atmembershiddenhas_unreadteamdisabled
Indexed sort fields:
-
last_updated(default) last_message_atcreated_atupdated_at
Example of an optimized (free) query:
// This query is FREE — it uses indexed fields only
const channels = await client.queryChannels(
{
type: 'messaging',
members: { $in: ['user-123'] },
},
{ last_message_at: -1 }, // indexed sort field
{ limit: 20 }
);Not-optimized queries (consume budget)
A query is classified as not-optimized if it triggers any of the following rules. The first matching rule determines the classification:
| Rule | What triggers it | Why it's expensive |
|---|---|---|
| Restricted operators | Using $ne, $nin, or $nor in filters |
Forces the database to read the entire index (anti-join) |
| Full-text search | Using $q, $autocomplete, $any, or $contains
|
Requires full-text search vector or ILIKE pattern matching |
| Complex logical operators | More than one user-specified $and or $or block |
Nested join plans cannot be flattened by the query optimizer |
| Too many AND branches |
$and with more than 3 child conditions |
Too many join conditions for the database optimizer to handle efficiently |
| Too many OR branches |
$or with more than 2 child conditions |
Each branch requires a separate index scan |
| Non-indexed filter field | Filtering on a field not in the indexed list above (e.g., custom data fields) | JSONB extraction bypasses B-tree indexes, causing sequential scans |
Large $in arrays
|
$in with more than 3 values |
Query planner switches from index scan to sequential scan |
| Non-indexed sort | Sorting on a field not in the indexed sort list above | Requires full materialization and in-memory sorting |
Example of a not-optimized (metered) query:
// This query CONSUMES BUDGET — it uses custom fields and $or with 3+ branches
const channels = await client.queryChannels(
{
$or: [
{ 'custom_priority': 'high' }, // custom field = not indexed
{ 'custom_priority': 'urgent' },
{ 'custom_priority': 'critical' }, // 3 $or branches = exceeds limit
],
},
{ last_message_at: -1 },
{ limit: 20 }
);Optimizing Your Queries
The most effective way to reduce budget consumption is to restructure your queries to be classified as optimized. Here are concrete steps:
-
Use only indexed filter fields.
Filter ontype,cid,members,team,last_message_at,last_updated,created_at,updated_at,hidden,has_unread, ordisabled.
Avoid: Filtering on custom data fields (e.g.,custom.priority,custom.category). If you need to filter on custom attributes, consider encoding them as channeltypeorteamvalues instead. -
Sort on indexed fields only.
Uselast_updated,last_message_at,created_at, orupdated_atfor sorting. If you don't specify a sort, the system defaults tolast_updatedwhich is indexed.
Avoid: Sorting on custom fields ormember_count. -
Avoid
$ne,$nin, and$nor.
These operators require scanning the entire index. Restructure your filter to use positive matching instead.
Instead of:{ type: { $ne: 'archived' } }
Use:{ type: { $in: ['messaging', 'team', 'support'] } } -
Keep
$inarrays small (3 or fewer values).
If you need to match more than 3 values, consider making multiple smaller queries or restructuring your data model. -
Limit
$orto 2 branches and$andto 3 branches.
Complex logical operators prevent the query optimizer from using indexes efficiently. Simplify your filter logic or split into multiple queries. -
Avoid combining full-text search with complex filters.
If you use$qor$autocomplete, keep the rest of the filter simple. Full-text search queries always consume budget. -
Use predefined filters for common query patterns.
Predefined filters are pre-optimized query templates that run efficiently. If your app repeatedly runs the same complex query, you can set and manage this on the Stream Dashboard: Setting up predefined filters
Before and after examples
| Before (consumes budget) | After (free) | Change |
|---|---|---|
{ status: { $ne: 'archived' } } |
{ type: 'messaging' } |
Replaced $ne on custom field with positive match on indexed field |
{ $or: [{ team: 'a' }, { team: 'b' }, { team: 'c' }] } |
{ team: { $in: ['a', 'b'] } } (2 queries if needed) |
Reduced $or branches; used $in with ≤3 values |
sort: { member_count: -1 } |
sort: { last_message_at: -1 } |
Switched to indexed sort field |
{ 'custom.priority': 'high' } |
{ team: 'high-priority' } |
Moved custom data into indexed team field |
Warning Emails
When your app's budget utilization exceeds 80% of the limit, or when a request is denied, Stream sends a warning email to the organization's registered email address. These emails include:
- Which app and endpoint exceeded the threshold
- Current usage vs. budget limit
- A link to your Stream Dashboard
Warning emails are rate-limited to avoid flooding your inbox — you will receive at most one email per organization per 15 minutes.
If you receive a warning email, review the Optimizing Your Queries section above to reduce your budget consumption before you start getting 429 denials.
Handling Budget 429 Errors in Code
JavaScript / Node.js
async function queryChannelsWithBudgetAwareness(client, filter, sort, options) {
try {
const response = await client.queryChannels(filter, sort, options);
// Monitor budget headers proactively
// (Available via response headers in server-side SDK)
return response;
} catch (error) {
if (error.status === 429) {
const message = error.message || '';
if (message.includes('Query budget exceeded')) {
// BUDGET rate limit — your queries are too expensive
console.warn('API budget exceeded. Consider simplifying your query filters.');
console.warn('Tips: Use indexed fields only, avoid $ne/$nin, limit $or branches.');
// Wait for the Retry-After period
const retryAfter = parseInt(error.headers?.['retry-after'] || '60', 10);
await new Promise(resolve = setTimeout(resolve, retryAfter * 1000));
// Retry — but consider simplifying the query first!
return client.queryChannels(filter, sort, options);
} else {
// REQUEST-COUNT rate limit — too many requests
const resetAt = error.headers?.['x-ratelimit-reset'];
const waitMs = resetAt
? Math.max(0, resetAt * 1000 - Date.now()) + 100
: 60000;
await new Promise(resolve = setTimeout(resolve, waitMs));
return client.queryChannels(filter, sort, options);
}
}
throw error;
}
}Proactive budget monitoring
// Check budget usage before it becomes a problem
function checkBudgetHealth(responseHeaders) {
const usedMs = parseInt(responseHeaders['x-budget-used-ms'] || '0', 10);
const limitMs = parseInt(responseHeaders['x-budget-limit-ms'] || '1', 10);
const remainingMs = parseInt(responseHeaders['x-budget-remaining-ms'] || '0', 10);
const utilization = usedMs / limitMs;
if (utilization 0.8) {
console.warn(
`Budget warning: ${Math.round(utilization * 100)}% used ` +
`(${usedMs}ms / ${limitMs}ms). ${remainingMs}ms remaining. ` +
`Consider simplifying QueryChannels filters.`
);
}
return { usedMs, limitMs, remainingMs, utilization };
}Common Causes of Budget Exhaustion
-
Filtering on custom data fields.
Any filter on fields outside the indexed list (e.g.,custom.status,custom.category,custom.priority) forces a sequential scan.
Fix: Use indexed fields liketype,team, ormembersto encode your categorization. See Optimizing Your Queries. -
Using
$autocompleteor$q(full-text search) at high frequency.
Full-text search queries always consume budget and are relatively expensive.
Fix: Debounce autocomplete calls by at least 300ms. Cache recent search results. -
Complex filter trees with many
$or/$andbranches.
Each additional branch multiplies the database work.
Fix: Simplify to at most 2$orbranches and 3$andconditions. Split into multiple simpler queries if needed. -
Using
$ne/$ninoperators.
Negation operators require scanning the entire index.
Fix: Replace with positive matching using$eqor$in. -
Sorting on non-indexed fields.
Sorting bymember_count, custom fields, or any field not in the indexed sort list requires full materialization.
Fix: Sort bylast_updated,last_message_at,created_at, orupdated_at. -
High-frequency polling with expensive queries.
Even moderate polling frequency combined with complex queries burns budget fast.
Fix: Use WebSocket events for real-time updates instead of pollingQueryChannels. Use simpler filters when polling is unavoidable. -
Large
$inarrays.
Using$inwith more than 3 values causes the query planner to fall back to sequential scans.
Fix: Limit$into 3 or fewer values. Make multiple queries if you need to match many values.
Frequently Asked Questions
Does this affect all API endpoints?
No. Currently, API budget rate limiting only applies to the QueryChannels endpoint. All other endpoints are subject to request-count rate limits only. Additional endpoints may be added in the future.
What is my budget limit?
The default budget is 200,000 ms (200 seconds) of database time per minute for QueryChannels. This is a generous limit that accommodates normal usage patterns. Your actual limit is reflected in the X-Budget-Limit-Ms response header. Enterprise customers may have custom limits.
If my queries are all "optimized," will I ever hit this limit?
No. Optimized queries do not consume any budget at all. If all your QueryChannels calls use indexed filter fields, stay within complexity limits, and sort on indexed fields, your budget consumption will be zero regardless of request volume.
How is budget different from the per-query cap?
The system caps the cost charged for any single query at 3,000 ms. Even if one query takes 10 seconds, only 3,000 ms is deducted from your budget. This prevents a single slow query from exhausting your entire budget window.
Does the budget reset at a fixed time?
No. The budget uses a sliding window (default: 60 seconds). Budget frees up continuously as older usage ages out, rather than resetting all at once. This means you won't experience a "cliff" where all budget disappears and returns simultaneously.
Do server-side and client-side calls share the same budget?
Yes. All calls to QueryChannels from all platforms (server-side, web, iOS, Android) draw from the same budget pool by default.
I received a warning email. What should I do?
Review your QueryChannels usage patterns. The email means you've crossed 80% of your budget. Check the Optimizing Your Queries section to identify and fix expensive queries before you start getting 429 denials.
Can I increase my budget?
Enterprise customers can request custom budget limits. Contact Stream Sales or your account representative to discuss your requirements.
Predefined Filters
Predefined filters are pre-optimized query templates created via the API. If your application repeatedly runs the same complex query pattern, a predefined filter can run it efficiently without consuming budget. Predefined filters are set up through the CreatePredefinedFilter / GetPredefinedFilters API endpoints. Contact support if you need help setting these up for high-traffic query patterns.
Diagnostics Checklist
If you need to escalate a budget rate limit issue, copy this template, fill it out, and include it in your support request:
API Budget Rate Limit Issue Diagnostics ======================================== 1. App ID (from Dashboard): 2. Which QueryChannels filter are you using? (paste the filter JSON) 3. Which sort field are you using? 4. What are the X-Budget-Used-Ms and X-Budget-Limit-Ms header values? 5. How many QueryChannels requests per minute is your app making? 6. Are you using any of these operators? $ne, $nin, $nor, $q, $autocomplete 7. Are you filtering on custom data fields? If yes, which ones? 8. How many $or branches in your filter? How many $and conditions? 9. Are you using WebSocket events or polling QueryChannels for updates? 10. SDK + version: 11. Platform (iOS / Android / Web / React Native / Flutter / Node.js): 12. Environment: [ ] Development [ ] Production 13. Did you receive a warning email? [ ] Yes [ ] No 14. Paste the full 429 error response body:
Escalation
- Review your query filters. Work through the What Makes a Query Expensive section and the Optimizing Your Queries guide above. Most budget issues are resolved by switching to indexed fields and simplifying filter logic.
- Check the status page. Visit status.getstream.io and confirm all systems are operational.
-
Monitor your budget proactively. Check the
X-Budget-Remaining-Msheader in everyQueryChannelsresponse. If utilization is consistently above 50%, review and optimize your query patterns. - Ask AI. Use the AI assistant in the Stream Docs for specific questions about optimizing your queries.
- File a support ticket. If you are on a paid plan and still hitting budget limits after optimizing your queries, include the completed diagnostics checklist above. We can review your query patterns and discuss custom budget limits if needed.
Related Links
- QueryChannels Documentation
- Rate Limits Documentation
- Channel Query Filters Reference
- API Error Codes Reference
- Stream Status Page
- Stream Dashboard
- Predefined Filters
- Contact Stream Sales — for custom budget limits on Enterprise plans
Comments
0 comments
Please sign in to leave a comment.