Optimizing your rate limit usage 

This guide covers strategies for keeping your app within its rate limits. Most rate limit problems come from a few code patterns that run slower database queries, or make more requests, than the work needs. Each section shows one pattern, explains why it is expensive, and shows a cheaper version.

To lower your bill instead, see optimizing your bill. Many of the same changes help with both. A few trade one for the other, and each section says so where that happens.

How rate limits work 

Each environment has two main rate limits:

  • The database rate limit counts time. Each database query consumes at least the number of milliseconds it took to run, and long-running queries are charged at higher rates, so one slow query can consume more than hundreds of fast ones. This is the limit most apps reach first.
  • The request rate limit counts requests. Every request to your app's API counts, whether it comes from a browser, an external system, or your own action code calling api.something().

A few features have their own smaller limits, such as computed views, outbound email, and Gadget-managed OpenAI keys. See read the error for the full list.

Both limits use a leaky bucket algorithm. Requests fill the bucket, and capacity drains out at a steady rate. When the bucket is full, Gadget rejects new work with a GGT_TOO_MANY_REQUESTS error until enough has drained. This means your app can burst above its average rate for a moment, but cannot sustain it.

development environments have much lower limits than production environments. Load tests and large imports reach the limits much sooner in development.

Find out which limit you are hitting 

Before changing code, confirm which limit your app is exhausting. The database rate limit and the request rate limit have different fixes.

Read the error 

When the request rate limit rejects a request, the response has HTTP status 429, the error code GGT_TOO_MANY_REQUESTS, and a retry-after header with the number of seconds to wait. The other limits use the same GGT_TOO_MANY_REQUESTS code. The database rate limit and the computed view limit do not set the header. When one of them is hit inside an action, the code arrives in the action's errors and not as an HTTP 429 response. See handle 429 errors in external clients for how to retry. The error message tells you which limit was hit:

Error message containsLimit
Database usage rate limit exceededDatabase rate limit. This is by far the most common rate limit error
This application has run too many actionsRequest rate limit, reached while running actions that have no custom code, most often from a bulk action
This application has received too many requestsRequest rate limit, reached by a request from outside your app
Background action can't proceed as rate limit usage is too highRequest rate limit. Gadget did not start a background action attempt because usage was near the limit. The attempt fails and is retried
View query rate limit exceedComputed view rate limit
This background action payload is too largeLarge request body cost
Email send rate limit exceededOutbound email limit. Send fewer emails per window, or use your own email transporter
Observability system usage rate limit exceededLogs and operations dashboard queries. Wait a moment and query less often
Gadget OpenAI proxy has received too many requestsGadget-managed OpenAI keys. Wait, send fewer requests, or use your own OpenAI API key

To see which of these your app is returning, open the errors chart on the Overview tab of the operations dashboard and break it down by Message. Break it down by Source to find the action, route, or webhook topic that ran into the limit.

Read the charts 

The operations dashboard shows both limits over time. The request rate limit usage chart on the Overview tab shows usage against the surge threshold and the hard limit. Its Request type breakdown shows what is consuming the limit. Above the surge threshold, eligible requests run on surge compute. At the hard limit, requests are rejected. The database rate limit usage chart on the Database tab shows database time against your limit. Pair it with database bytes read, broken down by Model, to find which model's queries do the most work.

Each request type has a different fix:

Reduce database rate limit usage 

Fix your slowest queries first 

The database rate limit counts milliseconds of query time. A lookup by id or by an indexed field takes a millisecond or two. A query that aggregates many records, or reads through a large model without an index, can take seconds. One slow query can consume more than thousands of quick lookups.

Make your slowest queries faster before removing fast ones. This reduces database rate limit usage the most.

Skip computed fields you are not displaying 

A computed field runs its aggregation every time it is selected. A computed field that sums across a related model turns every list page into many aggregation queries.

For example, product has a computed field totalRevenue that sums price times quantity across the product's line items. A list page that selects it aggregates line items for every product:

web/routes/products.jsx
JavaScript
// INEFFICIENT: 50 products, each aggregating thousands of line items, on every page load const [{ data: products }] = useFindMany(api.product, { first: 50, select: { id: true, title: true, status: true, imageUrl: true, totalRevenue: true, }, });
// INEFFICIENT: 50 products, each aggregating thousands of line items, on every page load const [{ data: products }] = useFindMany(api.product, { first: 50, select: { id: true, title: true, status: true, imageUrl: true, totalRevenue: true, }, });

For a merchant with 2 million line items, that query can take most of a second on every page load. Select only what the page shows:

web/routes/products.jsx
JavaScript
// EFFICIENT: no aggregation runs, the query takes a few milliseconds const [{ data: products }] = useFindMany(api.product, { first: 50, select: { id: true, title: true, status: true, imageUrl: true }, });
// EFFICIENT: no aggregation runs, the query takes a few milliseconds const [{ data: products }] = useFindMany(api.product, { first: 50, select: { id: true, title: true, status: true, imageUrl: true }, });

Select totalRevenue only on the product detail page, where it aggregates for 1 product instead of 50.

See computed field performance for more details.

Pre-aggregate values that are read often 

If a value is read far more often than it changes, compute it at write time and store it in a normal field. Reading a stored number costs a millisecond. Recomputing it from millions of records on every read does not.

This trades read cost for write cost. Every line item write now makes one more request and one more database write, and both count against your rate limits and your bill. It pays off when the value is read many times for each time it changes. For a value that changes constantly and is read rarely, keep the computed field and select it only where you display it.

api/models/orderLineItem/actions/create.js
JavaScript
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ api, params, record }) => { applyParams(params, record); await save(record); // runs in the same transaction as the save, so the line item and the total are saved together if (!record.productId) return; await api.internal.product.update(record.productId, { _atomics: { totalRevenue: { increment: (record.price ?? 0) * (record.quantity ?? 0) }, }, }); };
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ api, params, record }) => { applyParams(params, record); await save(record); // runs in the same transaction as the save, so the line item and the total are saved together if (!record.productId) return; await api.internal.product.update(record.productId, { _atomics: { totalRevenue: { increment: (record.price ?? 0) * (record.quantity ?? 0) }, }, }); };

Apply the opposite change on every other path that touches a line item, or the total drifts:

api/models/orderLineItem/actions/delete.js
JavaScript
import { deleteRecord } from "gadget-server"; export const run: ActionRun = async ({ api, record }) => { await deleteRecord(record); if (!record.productId) return; await api.internal.product.update(record.productId, { _atomics: { totalRevenue: { decrement: (record.price ?? 0) * (record.quantity ?? 0) }, }, }); };
import { deleteRecord } from "gadget-server"; export const run: ActionRun = async ({ api, record }) => { await deleteRecord(record); if (!record.productId) return; await api.internal.product.update(record.productId, { _atomics: { totalRevenue: { decrement: (record.price ?? 0) * (record.quantity ?? 0) }, }, }); };

In the update action, apply the difference between the previous and current values, which record.changes() gives you. If the update moved the line item to a different product, decrement the full previous contribution from the old product and increment the full current contribution on the new one instead. Keep these writes in run, not onSuccess, so the line item and the total are saved together.

Atomic increments happen inside the database in a single small write, so concurrent line items never overwrite each other's totals. See atomic updates and pre-aggregating data.

Keep indexes on the fields you query 

Filter and sort indexes are what let the database find matching records without reading every record. On a small model the difference is invisible. On a model with millions of records, a query that cannot use an index can take seconds and use a large share of your budget on its own.

Apps on framework version 1.5.0 or later can toggle indexes per field. Gadget only allows filtering and sorting on fields that have their index enabled, so you cannot accidentally run an unindexed filter through the API. Every enabled index costs storage and slows down writes. Keep indexes on the fields your code filters or sorts by, and disable the rest.

Use the fields indexed for sort and filter table on the operations dashboard to see how often each index is used. An index that has never been used is a candidate for disabling.

json fields can be indexed too, and indexed JSON filters are fast for simple lookups. If a filter inside a JSON field stays slow even with its index enabled, move that value to its own field with a proper type.

Add filters and limits to large queries 

Every filter you add is fewer records for the database to examine. Every first you set is fewer records to return. Unbounded queries are the most common source of slow queries in Shopify apps. Code that was fast on a test store with 100 orders runs against 500,000 orders in production.

api/actions/lowStockReport.js
JavaScript
export const run: ActionRun = async ({ api }) => { // INEFFICIENT: reads every variant for every shop, one page at a time, then filters in JavaScript const lowStock = []; for await (const variant of api.shopifyProductVariant.iterateAll()) { if ((variant.inventoryQuantity ?? 0) < 5) lowStock.push(variant); } return lowStock; };
export const run: ActionRun = async ({ api }) => { // INEFFICIENT: reads every variant for every shop, one page at a time, then filters in JavaScript const lowStock = []; for await (const variant of api.shopifyProductVariant.iterateAll()) { if ((variant.inventoryQuantity ?? 0) < 5) lowStock.push(variant); } return lowStock; };
api/actions/lowStockReport.js
JavaScript
export const params = { shopId: { type: "string" }, }; export const run: ActionRun = async ({ api, params }) => { if (!params.shopId) throw new Error("shopId is required"); // EFFICIENT: the database does the filtering using indexes, and returns only what you need const lowStock = []; for await (const variant of api.shopifyProductVariant.iterateAll({ filter: { shopId: { equals: params.shopId }, inventoryQuantity: { lessThan: 5 }, }, select: { id: true, title: true, inventoryQuantity: true }, })) { lowStock.push(variant); } return lowStock; };
export const params = { shopId: { type: "string" }, }; export const run: ActionRun = async ({ api, params }) => { if (!params.shopId) throw new Error("shopId is required"); // EFFICIENT: the database does the filtering using indexes, and returns only what you need const lowStock = []; for await (const variant of api.shopifyProductVariant.iterateAll({ filter: { shopId: { equals: params.shopId }, inventoryQuantity: { lessThan: 5 }, }, select: { id: true, title: true, inventoryQuantity: true }, })) { lowStock.push(variant); } return lowStock; };

Check that the filter and sort index on inventoryQuantity is enabled before using this query. Gadget rejects filters on fields whose index is disabled, and Shopify models only index some fields by default. See configuring field indexing.

Store computed view results that do not need to be live 

Running a computed view makes one request. Its query time is charged to a separate computed view limit of 60 seconds per 10-second window, not to the database rate limit. Adding database capacity does not raise this budget. A dashboard view that takes 2 seconds and runs on every page load for every merchant exhausts that limit at 30 page loads per 10 seconds.

If the numbers on a dashboard do not need to be live, store the result and only run the view again when the stored copy is stale:

api/actions/getDashboardStats.js
JavaScript
const MAX_AGE_MS = 15 * 60 * 1000; export const run: ActionRun = async ({ api, connections }) => { const shopId = connections.shopify.currentShopId; if (!shopId) throw new Error("getDashboardStats must be called by a Shopify merchant"); const snapshot = await api.dashboardSnapshot.maybeFindFirst({ filter: { shopId: { equals: shopId } }, }); // most page loads stop here, with one small read and no computed view run if (snapshot && Date.now() - snapshot.updatedAt.getTime() < MAX_AGE_MS) { return { ordersToday: snapshot.ordersToday, revenueToday: snapshot.revenueToday, }; } // the stored copy is missing or stale, so run the view once and store the result for later page loads const stats = await api.shopDashboardStats({ shopId }); // upserting on shop requires a uniqueness validation on the dashboardSnapshot.shop field await api.internal.dashboardSnapshot.upsert({ on: ["shop"], shop: { _link: shopId }, ordersToday: stats.ordersToday, revenueToday: stats.revenueToday, }); return { ordersToday: stats.ordersToday, revenueToday: stats.revenueToday }; };
const MAX_AGE_MS = 15 * 60 * 1000; export const run: ActionRun = async ({ api, connections }) => { const shopId = connections.shopify.currentShopId; if (!shopId) throw new Error("getDashboardStats must be called by a Shopify merchant"); const snapshot = await api.dashboardSnapshot.maybeFindFirst({ filter: { shopId: { equals: shopId } }, }); // most page loads stop here, with one small read and no computed view run if (snapshot && Date.now() - snapshot.updatedAt.getTime() < MAX_AGE_MS) { return { ordersToday: snapshot.ordersToday, revenueToday: snapshot.revenueToday, }; } // the stored copy is missing or stale, so run the view once and store the result for later page loads const stats = await api.shopDashboardStats({ shopId }); // upserting on shop requires a uniqueness validation on the dashboardSnapshot.shop field await api.internal.dashboardSnapshot.upsert({ on: ["shop"], shop: { _link: shopId }, ordersToday: stats.ordersToday, revenueToday: stats.revenueToday, }); return { ordersToday: stats.ordersToday, revenueToday: stats.revenueToday }; };

The dashboard calls api.getDashboardStats() in place of the view. Page loads that arrive in the same moment the copy goes stale can each run the view once before the first one stores its result. That is still a small fraction of one run per page load. Upserting with on requires a uniqueness validation on the shop field, so add one when you create the model.

Refresh when a merchant loads the page instead of on a schedule. A scheduled job that refreshes each shop once per 15 minutes makes 96 view reads and 96 writes per shop per day, billed whether or not anyone opens the dashboard.

Computed views are still the cheapest way to aggregate data. The limit only matters when the same view runs on every page load. See use computed views instead of pagination for more details.

Limit the concurrency of heavy background work 

Spreading work out does not make it cheaper. It lowers the peak, and the peak is what hits the limit. A scheduled job that enqueues 250 heavy actions with no concurrency limit lets Gadget start dozens at once, and they can fill the bucket before Gadget's throttle reacts. The same 250 actions run two at a time stay under the limit. Set maxConcurrency from what one action costs on the Database rate limit usage chart.

api/actions/nightlyReconcile.js
JavaScript
export const run: ActionRun = async ({ api }) => { const shops = await api.shopifyShop.findMany({ select: { id: true }, first: 250 }); // RISKY: 250 heavy actions with no concurrency limit, so dozens can start at once await Promise.all( shops.map((shop) => api.enqueue(api.reconcileShop, { shopId: shop.id })) ); };
export const run: ActionRun = async ({ api }) => { const shops = await api.shopifyShop.findMany({ select: { id: true }, first: 250 }); // RISKY: 250 heavy actions with no concurrency limit, so dozens can start at once await Promise.all( shops.map((shop) => api.enqueue(api.reconcileShop, { shopId: shop.id })) ); };
api/actions/nightlyReconcile.js
JavaScript
import type { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, trigger, signal }) => { // trigger.id only exists when this action runs as a background action, and it is the same on every retry if (trigger.type !== "background-action") throw new Error("enqueue nightlyReconcile with api.enqueue"); // SAFER: the same actions, two at a time for await (const shop of api.shopifyShop.iterateAll({ select: { id: true } })) { if (signal.aborted) return; await api.enqueue( api.reconcileShop, { shopId: shop.id }, { id: `${trigger.id}-${shop.id}`, onDuplicateID: "ignore", queue: { name: "nightly-reconcile", maxConcurrency: 2 }, priority: "low", } ); } }; export const options: ActionOptions = { timeoutMS: 5 * 60 * 1000, };
import type { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, trigger, signal }) => { // trigger.id only exists when this action runs as a background action, and it is the same on every retry if (trigger.type !== "background-action") throw new Error("enqueue nightlyReconcile with api.enqueue"); // SAFER: the same actions, two at a time for await (const shop of api.shopifyShop.iterateAll({ select: { id: true } })) { if (signal.aborted) return; await api.enqueue( api.reconcileShop, { shopId: shop.id }, { id: `${trigger.id}-${shop.id}`, onDuplicateID: "ignore", queue: { name: "nightly-reconcile", maxConcurrency: 2 }, priority: "low", } ); } }; export const options: ActionOptions = { timeoutMS: 5 * 60 * 1000, };

One background action per shop makes sense here because each reconcile is heavy, runs once a night, and can fail on its own. Each enqueue and run is billed, so for light work, loop inside one action or batch many records into each action.

A concurrency queue does not make a heavy action cheaper. If one action's queries are slow enough, even maxConcurrency: 2 can hit 429 errors.

Count the requests your code makes 

The most common cause of request rate limit problems is code that makes one request per record instead of one per batch. For example, a Shopify app might record a statistic for each line item when an order is created:

api/models/shopifyOrder/actions/create.js
JavaScript
export const onSuccess: ActionOnSuccess = async ({ api, record, trigger }) => { if (trigger.type !== "shopify_webhook") return; const lineItems = trigger.payload?.line_items ?? []; // INEFFICIENT: one request per line item for (const lineItem of lineItems) { await api.lineItemStat.create({ order: { _link: record.id }, productId: String(lineItem.product_id), quantity: lineItem.quantity, }); } };
export const onSuccess: ActionOnSuccess = async ({ api, record, trigger }) => { if (trigger.type !== "shopify_webhook") return; const lineItems = trigger.payload?.line_items ?? []; // INEFFICIENT: one request per line item for (const lineItem of lineItems) { await api.lineItemStat.create({ order: { _link: record.id }, productId: String(lineItem.product_id), quantity: lineItem.quantity, }); } };

For an order with 40 line items, this loop makes 40 requests, and each one runs a create action. A flash sale that delivers 100 orders in 10 seconds becomes more than 4,000 requests from this one action. If you only need the records created, use one Internal API bulk call instead:

api/models/shopifyOrder/actions/create.js
JavaScript
export const onSuccess: ActionOnSuccess = async ({ api, record, trigger }) => { if (trigger.type !== "shopify_webhook") return; const lineItems = trigger.payload?.line_items ?? []; // EFFICIENT: one request for all line items await api.internal.lineItemStat.bulkCreate( lineItems.map((lineItem) => ({ order: { _link: record.id }, productId: String(lineItem.product_id), quantity: lineItem.quantity, })) ); };
export const onSuccess: ActionOnSuccess = async ({ api, record, trigger }) => { if (trigger.type !== "shopify_webhook") return; const lineItems = trigger.payload?.line_items ?? []; // EFFICIENT: one request for all line items await api.internal.lineItemStat.bulkCreate( lineItems.map((lineItem) => ({ order: { _link: record.id }, productId: String(lineItem.product_id), quantity: lineItem.quantity, })) ); };

This reduces the work to one request per order, and skips running an action for each line item. The same flash sale now makes 100 requests instead of more than 4,000.

Gadget retries webhook-triggered actions when they fail, so this code can run twice for one order. In a real app, store the Shopify line item id with a uniqueness validation so a retry does not create duplicates.

What counts as a request 

These consume the request rate limit:

  • A request to one of your HTTP routes
  • A request to your Public API or Internal API, from anywhere
  • Each api.* call inside an action or route, including save(record)
  • Each attempt of a background action when it starts running, including retries

Running an action also makes a few requests of its own, so calling an action consumes more than a plain read.

These do not consume the request rate limit:

  • Enqueuing a background action with api.enqueue, unless the payload is over 10MB. Enqueues and background action runs are still billed as platform credits
  • Frontend assets and files served from Gadget's CDN

Requests with bodies over 10MB consume more than one request. See request rate limit exemptions and large request body costs for more details.

Reduce request rate limit usage 

Use bulk operations instead of looping 

Do not call api inside a for loop. Each iteration makes at least one request. Gadget provides a bulk version of every model action, and the Internal API provides bulk operations that skip action code. If your action code needs to run for each record, use the Public API bulk action:

api/actions/archiveOldProducts.js
JavaScript
export const run: ActionRun = async ({ api }) => { const staleProducts = await api.product.findMany({ filter: { lastSoldAt: { lessThan: "2025-01-01T00:00:00Z" } }, select: { id: true }, first: 250, }); // EFFICIENT: one call for up to 250 products, with no separate request per record await api.product.bulkUpdate( staleProducts.map((product) => ({ id: product.id, status: "archived" })) ); };
export const run: ActionRun = async ({ api }) => { const staleProducts = await api.product.findMany({ filter: { lastSoldAt: { lessThan: "2025-01-01T00:00:00Z" } }, select: { id: true }, first: 250, }); // EFFICIENT: one call for up to 250 products, with no separate request per record await api.product.bulkUpdate( staleProducts.map((product) => ({ id: product.id, status: "archived" })) ); };

If you only need the data changed, use the Internal API. api.internal.<model>.bulkCreate inserts many records in one request, and api.internal.<model>.deleteMany deletes every record matching a filter in one request:

api/actions/cleanupExpiredInvites.js
JavaScript
export const run: ActionRun = async ({ api }) => { // VERY EFFICIENT: 1 request, no action code, however many records match await api.internal.invite.deleteMany({ filter: { expiresAt: { lessThan: new Date().toISOString() } }, }); };
export const run: ActionRun = async ({ api }) => { // VERY EFFICIENT: 1 request, no action code, however many records match await api.internal.invite.deleteMany({ filter: { expiresAt: { lessThan: new Date().toISOString() } }, }); };

The Internal API skips access control and tenancy checks as well as action code. Only use it when you do not need them.

See public API vs internal API for more details.

A Public API bulk action still runs the action once for each record. It saves the per-record requests, not the per-record work.

Use larger pages or iterateAll 

By default, findMany returns 50 records per page. If you need to read many records, set first to the maximum of 250, or use iterateAll. This reduces 10,000 records from 200 requests to 40:

api/actions/exportCustomers.js
JavaScript
export const run: ActionRun = async ({ api }) => { // INEFFICIENT: 200 requests for 10,000 customers let page = await api.customer.findMany({ first: 50 }); while (true) { for (const customer of page) { // process each customer } if (!page.hasNextPage) break; page = await page.nextPage(); } };
export const run: ActionRun = async ({ api }) => { // INEFFICIENT: 200 requests for 10,000 customers let page = await api.customer.findMany({ first: 50 }); while (true) { for (const customer of page) { // process each customer } if (!page.hasNextPage) break; page = await page.nextPage(); } };
api/actions/exportCustomers.js
JavaScript
export const run: ActionRun = async ({ api }) => { // EFFICIENT: 40 requests for the same customers, and less code for await (const customer of api.customer.iterateAll({ select: { id: true, email: true }, })) { // process each customer } };
export const run: ActionRun = async ({ api }) => { // EFFICIENT: 40 requests for the same customers, and less code for await (const customer of api.customer.iterateAll({ select: { id: true, email: true }, })) { // process each customer } };

iterateAll fetches 250 records per page by default, and stops early if you break. Pass a smaller first if those pages are too large for your action. See pagination for details.

A list page that fetches a parent record and then fetches each related record separately makes one request per record. This is often called the N+1 problem, and it is the most common source of surprise External traffic from frontends.

web/routes/orders.jsx
React
import { useFindMany, useFindOne } from "@gadgetinc/react"; // INEFFICIENT: 1 request for the orders, then 1 request per order for its customer const OrderRow = ({ order }) => { const [{ data: customer }] = useFindOne(api.customer, order.customerId); return ( <tr> <td>{order.number}</td> <td>{customer?.email}</td> </tr> ); }; export const OrdersPage = () => { const [{ data: orders }] = useFindMany(api.order, { first: 50 }); return ( <table> {orders?.map((order) => ( <OrderRow key={order.id} order={order} /> ))} </table> ); };
import { useFindMany, useFindOne } from "@gadgetinc/react"; // INEFFICIENT: 1 request for the orders, then 1 request per order for its customer const OrderRow = ({ order }) => { const [{ data: customer }] = useFindOne(api.customer, order.customerId); return ( <tr> <td>{order.number}</td> <td>{customer?.email}</td> </tr> ); }; export const OrdersPage = () => { const [{ data: orders }] = useFindMany(api.order, { first: 50 }); return ( <table> {orders?.map((order) => ( <OrderRow key={order.id} order={order} /> ))} </table> ); };

With 50 orders on screen, every page load makes 51 requests. If 100 merchants open the page in the same 10 seconds, that is over 5,000 requests.

web/routes/orders.jsx
React
import { useFindMany } from "@gadgetinc/react"; // EFFICIENT: 1 request for the orders and their customers together export const OrdersPage = () => { const [{ data: orders }] = useFindMany(api.order, { first: 50, select: { id: true, number: true, customer: { email: true }, }, }); return ( <table> {orders?.map((order) => ( <tr key={order.id}> <td>{order.number}</td> <td>{order.customer?.email}</td> </tr> ))} </table> ); };
import { useFindMany } from "@gadgetinc/react"; // EFFICIENT: 1 request for the orders and their customers together export const OrdersPage = () => { const [{ data: orders }] = useFindMany(api.order, { first: 50, select: { id: true, number: true, customer: { email: true }, }, }); return ( <table> {orders?.map((order) => ( <tr key={order.id}> <td>{order.number}</td> <td>{order.customer?.email}</td> </tr> ))} </table> ); };

The same 100 page loads now make 100 requests. Relationship fields in select work for belongs to, has one, and has many relationships. See using a relationship in the API for the syntax.

Mutate record instead of calling update 

Inside a model action, the record is already loaded. Calling api.<model>.update on it makes a second request, runs the update action again, and can loop forever.

api/models/product/actions/update.js
JavaScript
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ api, record }) => { // INEFFICIENT: an extra request and a second run of this same update action await api.product.update(record.id, { slug: record.title.toLowerCase().replaceAll(" ", "-"), }); };
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ api, record }) => { // INEFFICIENT: an extra request and a second run of this same update action await api.product.update(record.id, { slug: record.title.toLowerCase().replaceAll(" ", "-"), }); };

Set the field on record before saving instead. Use record.changed to do the work only when the relevant field changed:

api/models/product/actions/update.js
JavaScript
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); // EFFICIENT: 0 extra requests, and no second action run if (record.changed("title")) { record.slug = record.title.toLowerCase().replaceAll(" ", "-"); } await save(record); };
import { applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); // EFFICIENT: 0 extra requests, and no second action run if (record.changed("title")) { record.slug = record.title.toLowerCase().replaceAll(" ", "-"); } await save(record); };

Move bursts into background actions 

Foreground requests all share one bucket. A button that updates 5,000 products inside one action makes 5,000 requests back to back. The action can run long enough to time out, and every other visitor competes with it until it finishes.

api/actions/applyTagsToAllProducts.js
JavaScript
// INEFFICIENT: 5,000 foreground requests, one after another, while the merchant waits export const run: ActionRun = async ({ api }) => { for await (const product of api.product.iterateAll({ select: { id: true } })) { await api.product.applyTags(product.id); } };
// INEFFICIENT: 5,000 foreground requests, one after another, while the merchant waits export const run: ActionRun = async ({ api }) => { for await (const product of api.product.iterateAll({ select: { id: true } })) { await api.product.applyTags(product.id); } };

Enqueue the work instead. Enqueuing does not consume the request rate limit unless the payload is over 10MB. The action returns immediately, and Gadget throttles background actions so they use most of your spare capacity without starving foreground traffic. Each enqueue and each background action run is billed as platform credits, so handle 100 products in each background action instead of one:

api/actions/applyTagsToAllProducts.js
JavaScript
import { randomUUID } from "node:crypto"; // EFFICIENT: the merchant's click does one enqueue, and the rest is paced by Gadget export const run: ActionRun = async ({ api }) => { await api.enqueue( api.applyTagsToProducts, { runId: randomUUID() }, { priority: "low" } ); };
import { randomUUID } from "node:crypto"; // EFFICIENT: the merchant's click does one enqueue, and the rest is paced by Gadget export const run: ActionRun = async ({ api }) => { await api.enqueue( api.applyTagsToProducts, { runId: randomUUID() }, { priority: "low" } ); };
api/actions/applyTagsToProducts.js
JavaScript
import type { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, params }) => { // each run handles one page of 100 products, starting after the cursor it was given const page = await api.product.findMany({ select: { id: true }, first: 100, after: params.cursor, }); if (page.length === 0) return; // one bulk call runs applyTags for every product on the page await api.product.bulkApplyTags(page.map((product) => product.id)); // enqueue the next page last, with an id so a retry does not enqueue it twice if (page.hasNextPage) { await api.enqueue( api.applyTagsToProducts, { runId: params.runId, cursor: page.endCursor }, { id: `${params.runId}-${page.endCursor}`, onDuplicateID: "ignore", priority: "low", } ); } }; export const params = { runId: { type: "string" }, cursor: { type: "string" }, }; export const options: ActionOptions = { // 100 action runs can take longer than the default timeout timeoutMS: 2 * 60 * 1000, };
import type { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, params }) => { // each run handles one page of 100 products, starting after the cursor it was given const page = await api.product.findMany({ select: { id: true }, first: 100, after: params.cursor, }); if (page.length === 0) return; // one bulk call runs applyTags for every product on the page await api.product.bulkApplyTags(page.map((product) => product.id)); // enqueue the next page last, with an id so a retry does not enqueue it twice if (page.hasNextPage) { await api.enqueue( api.applyTagsToProducts, { runId: params.runId, cursor: page.endCursor }, { id: `${params.runId}-${page.endCursor}`, onDuplicateID: "ignore", priority: "low", } ); } }; export const params = { runId: { type: "string" }, cursor: { type: "string" }, }; export const options: ActionOptions = { // 100 action runs can take longer than the default timeout timeoutMS: 2 * 60 * 1000, };

A catalog of 5,000 products takes 50 enqueues and 50 runs this way. One background action per product takes 5,000 runs. Enqueue one background action per record only when each record needs its own retries, for example when each one calls an external API. Use bulk enqueuing to submit them in one call.

Each run enqueues the next one, so only one page is in progress at a time. If a run fails, Gadget retries it and reads the same page again, so no product is skipped. The same products can be tagged twice, so write applyTags so that running it twice has the same result as running it once.

See choosing how to distribute work and deduplicating enqueued background actions for more details.

Background actions consume the same two rate limits as foreground requests. Gadget adjusts how many run at once based on how full the buckets are, to leave room for foreground requests. To help the throttle:

  • Use a concurrency queue for actions that are known to be heavy, so they do not all start at once.
  • Enqueue bulk work with priority: "low" so it yields to user-facing background actions.
  • Reserve priority: "high" for work that a user is waiting on. High-priority background actions can use surge compute above the surge threshold, which is billed at a higher rate.

Use realtime queries instead of polling 

A frontend that fetches again on a timer consumes the request rate limit whether or not anything changed. For example, 500 open browser tabs that each poll once per 2 seconds make 2,500 requests per 10 seconds.

web/routes/dashboard.jsx
React
import { useEffect } from "react"; import { useFindMany } from "@gadgetinc/react"; // INEFFICIENT: 1 request per 2 seconds for each open tab export const Dashboard = () => { const [{ data }, refetch] = useFindMany(api.fulfillmentJob, { filter: { status: { equals: "pending" } } }); useEffect(() => { const timer = setInterval(() => refetch(), 2000); return () => clearInterval(timer); }, [refetch]); return <JobList jobs={data} />; };
import { useEffect } from "react"; import { useFindMany } from "@gadgetinc/react"; // INEFFICIENT: 1 request per 2 seconds for each open tab export const Dashboard = () => { const [{ data }, refetch] = useFindMany(api.fulfillmentJob, { filter: { status: { equals: "pending" } } }); useEffect(() => { const timer = setInterval(() => refetch(), 2000); return () => clearInterval(timer); }, [refetch]); return <JobList jobs={data} />; };

Use a realtime query instead. Each tab opens one connection, which consumes 1 request, and each realtime query on it consumes 1 more. A tab that reconnects, for example after a deploy or a network drop, consumes both again. After that, a change to a matching record re-runs the query once for each open tab, at most once per 500ms. Re-runs do not consume the request rate limit:

web/routes/dashboard.jsx
React
import { useFindMany } from "@gadgetinc/react"; // EFFICIENT: 2 requests to open, then none while connected export const Dashboard = () => { const [{ data }] = useFindMany(api.fulfillmentJob, { filter: { status: { equals: "pending" } }, live: true, }); return <JobList jobs={data} />; };
import { useFindMany } from "@gadgetinc/react"; // EFFICIENT: 2 requests to open, then none while connected export const Dashboard = () => { const [{ data }] = useFindMany(api.fulfillmentJob, { filter: { status: { equals: "pending" } }, live: true, }); return <JobList jobs={data} />; };

Each re-run consumes the database rate limit and is billed as a read. A realtime query is cheaper than polling when matching records change less often than you would poll. If they change more often, 500 open tabs can run up to 1,000 database queries per second. In that case, poll at a slow interval instead, and stop when the tab is hidden.

Handle 429 errors in external clients 

The built-in api object in your action code makes up to 5 attempts at a rate limited request. It waits 500ms before the first retry and longer before each one after. Clients outside Gadget, such as a Node.js script using your generated API client, a serverless function, or a partner's integration, do not retry rate limited requests.

A client that does not retry turns one busy moment into a burst of failures. A client that retries immediately makes the exhaustion worse. Wait for the number of seconds in the retry-after header, or back off exponentially when the header is missing:

sync-products.ts, a Node.js script outside Gadget
JavaScript
import { Client } from "@gadget-client/your-app-slug"; const api = new Client({ authenticationMode: { apiKey: process.env.GADGET_API_KEY }, }); async function withRateLimitRetry<T>( operation: () => Promise<T>, attempt = 1 ): Promise<T> { try { return await operation(); } catch (error: any) { // a whole-request rejection arrives as a GraphQL error, and a rejected action reports the code directly const isRateLimited = error?.code === "GGT_TOO_MANY_REQUESTS" || error?.graphQLErrors?.some( (graphQLError: any) => graphQLError.extensions?.code === "GGT_TOO_MANY_REQUESTS" ) || error?.response?.status === 429; if (!isRateLimited || attempt >= 5) throw error; // wait for retry-after when the server sent it, otherwise 1s, 2s, 4s, then 8s const retryAfterSeconds = Number(error?.response?.headers?.get?.("retry-after")); const delayMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 1000 * 2 ** (attempt - 1); await new Promise((resolve) => setTimeout(resolve, delayMs)); return await withRateLimitRetry(operation, attempt + 1); } } const products = await withRateLimitRetry(() => api.product.findMany({ first: 250 }) );
import { Client } from "@gadget-client/your-app-slug"; const api = new Client({ authenticationMode: { apiKey: process.env.GADGET_API_KEY }, }); async function withRateLimitRetry<T>( operation: () => Promise<T>, attempt = 1 ): Promise<T> { try { return await operation(); } catch (error: any) { // a whole-request rejection arrives as a GraphQL error, and a rejected action reports the code directly const isRateLimited = error?.code === "GGT_TOO_MANY_REQUESTS" || error?.graphQLErrors?.some( (graphQLError: any) => graphQLError.extensions?.code === "GGT_TOO_MANY_REQUESTS" ) || error?.response?.status === 429; if (!isRateLimited || attempt >= 5) throw error; // wait for retry-after when the server sent it, otherwise 1s, 2s, 4s, then 8s const retryAfterSeconds = Number(error?.response?.headers?.get?.("retry-after")); const delayMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 1000 * 2 ** (attempt - 1); await new Promise((resolve) => setTimeout(resolve, delayMs)); return await withRateLimitRetry(operation, attempt + 1); } } const products = await withRateLimitRetry(() => api.product.findMany({ first: 250 }) );

Only wrap reads and operations that are safe to run twice. A 429 can arrive after part of a write has already saved, so a blind retry can repeat it. For writes, make the action safe to run twice first. For example, add a uniqueness validation and call the upsert meta action instead of create. See side effects and idempotency for more details.

When a bulk action is only partly rate limited, the client throws a GadgetErrorGroup instead. Its errors list holds one error per rejected record and its results list holds the records that succeeded. Retry only the rejected records, so the ones that succeeded do not run twice.

To make fewer requests from external clients, send batches to a bulk action or a custom HTTP route that accepts an array, rather than one request per item.

When to add capacity 

If the Request rate limit usage or Database rate limit usage chart still shows usage reaching the limit line after these fixes, your app needs more capacity. You can upgrade your plan, or add execution capacity units to raise the limits for your production environment.

The charts show how full each bucket is, so usage never rises above the limit line. If usage sits on the line during your busy periods and you see 429 errors at the same time, add one unit. Watch the same busy period again, and add more if usage still reaches the line. You can add up to 10 units from your app's Settings > Add-ons page. Contact Gadget support if you need more than that or if you want to discuss custom limits.

Size for your real peak, not your average. Rate limits are enforced over windows of a few seconds, not minutes, so a flash sale, a nightly sync, or a big import is what you are buying capacity for. Look at a full week on the chart so you catch the busiest window.

Was this page helpful?