NetSuite concurrency limits: retries, backoff, and idempotent upserts that do not create duplicates
- Published on
- -9 mins read
- Authors
- Name
- Andy Nur (Andy)
- @andynur
Every NetSuite account has a concurrency limit: the number of web service and RESTlet requests it processes at the same time. The limit is for the whole account. Your order sync, the 3PL connector, the BI extract, and now the AI Connector all share it.
When a request arrives and every slot is busy, NetSuite rejects it with HTTP 429 Too Many Requests. How your integration reacts to that 429 decides whether a busy hour is a non-event or a page at 2 AM.
This post covers three habits that make NetSuite integrations boring in the best way: a client-side pool, retry with backoff and jitter, and idempotent writes.
In this article:
- Where the limit comes from
- See it: the concurrency simulator
- A pool plus backoff in Node.js
- The retry that creates a duplicate order
- Idempotent upserts with external IDs
Where the limit comes from
The account limit depends on your service tier, and each SuiteCloud Plus license raises it. You can see the current numbers at Setup > Integration > Integration Management > Integration Governance.
On the same page you can allocate part of the limit to specific integration records. Allocated slots are reserved for that integration. Everything else shares the rest. This is the single most useful setting for a busy account: give your order sync a guaranteed share, so a heavy report extract cannot starve it.
Note
Requests from the NetSuite AI Connector Service count against the same pool. One question to an AI assistant can turn into several tool calls in a few seconds.
See it: the concurrency simulator
The simulator sends a batch of requests to an account with a fixed number of slots. Some slots are taken by "other integrations", which sometimes spike by one. Try each strategy with the same settings, then compare the table that appears under the controls.
Concurrency limit simulator
A batch of requests hits an account with a fixed number of concurrent slots. Other integrations use some of them.
Account slots
Your requests
0/40
Done
0
429 responses
0
Time (ticks)
What you should see with the defaults (limit 5, 40 requests, 1 slot used elsewhere):
- Fire all, retry at once finishes, but sends hundreds of rejected requests. In a real account those 429s also hit every other integration, because they all wait on the same slots.
- Backoff + jitter cuts the 429s a lot, but it is slower. Requests sleep while slots are free.
- Client pool + backoff is as fast as the naive version, with close to zero 429s. The pool keeps you under your share, and backoff handles the spikes you cannot predict.
A pool plus backoff in Node.js
You do not need a queue library for this. A small semaphore and a retry loop are enough:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Allows at most `size` tasks at the same time, the rest wait in FIFO orderexport function createPool(size) { let active = 0 const waiting = [] const next = () => { if (active >= size || waiting.length === 0) return active++ const { task, resolve, reject } = waiting.shift() task() .then(resolve, reject) .finally(() => { active-- next() }) } return (task) => new Promise((resolve, reject) => { waiting.push({ task, resolve, reject }) next() })}
const RETRYABLE = new Set([429, 502, 503, 504])
export async function withRetry(doRequest, { maxAttempts = 6, baseMs = 500, capMs = 30_000 } = {}) { for (let attempt = 1; ; attempt++) { let res try { res = await doRequest() } catch (networkError) { if (attempt >= maxAttempts) throw networkError } if (res && !RETRYABLE.has(res.status)) return res if (res && attempt >= maxAttempts) return res
// Full jitter: a random wait between 0 and the exponential ceiling const retryAfter = Number(res?.headers.get('retry-after')) * 1000 const ceiling = Math.min(capMs, baseMs * 2 ** attempt) await sleep(retryAfter || Math.random() * ceiling) }}Combine them, and size the pool to the share you allocated in Integration Governance:
import { createPool, withRetry } from './netsuite-client.js'
const pool = createPool(Number(process.env.NS_CONCURRENCY ?? 4))
await Promise.all( orders.map((order) => pool(() => withRetry(async () => { // Build headers inside the callback: TBA needs a new nonce on every attempt const headers = await authHeaders('PUT', urlFor(order)) return fetch(urlFor(order), { method: 'PUT', headers, body: JSON.stringify(toSalesOrder(order)) }) }) ) ))Build the auth header per attempt
With TBA, a retry that resends the same Authorization header fails with a used nonce error. Build
the header inside the retried function, as above. OAuth 2.0 bearer tokens can be reused, but check
expiry on each attempt.
The retry that creates a duplicate order
Retries are safe only when the request is idempotent: sending it twice has the same effect as sending it once. A POST that creates a record is not. Step through the failure below:
How a timeout turns into a duplicate sales order
Step 1 / 71. POST /salesOrder
The sync sends a create request for web order #1001.
Idempotent upserts with external IDs
The REST record API supports upsert by external ID. Send a PUT to the record URL with eid: and your own key. If no record has that external ID, NetSuite creates one. If one exists, NetSuite updates it.
PUT /services/rest/record/v1/salesOrder/eid:WEB-1001?replace=itemContent-Type: application/json
{ "entity": { "id": "1234" }, "otherRefNum": "WEB-1001", "item": { "items": [{ "item": { "id": "567" }, "quantity": 2, "rate": 19.5 }] }}Rules that make this work:
- Use a key from the source system, such as the Shopify order ID with a prefix. Never generate it at request time.
- Set the same external ID on every retry. That is the whole point.
- External IDs are unique per record type.
WEB-1001can exist once as a sales order and once as a customer. - Replace the line items. When the upsert updates an existing record, sublist lines without a line ID are added, not matched. Pass
replace=itemso a retry replaces the item lines instead of appending a second copy.
Same idea in RESTlets and SuiteScript
If you write to NetSuite through a RESTlet, do the same thing manually: look up the record by external ID first, then create or update. In a Map/Reduce script, use the external ID as the map key so a restarted stage does not write twice.
Conclusion
Concurrency limits are not a bug in your integration. They are a shared resource, and the account has many tenants. Three habits handle almost every case:
- Pool requests to the share you allocated in Integration Governance.
- Retry only 429 and 5xx, with exponential backoff and full jitter, and a new auth header each time.
- Upsert by external ID, so a retry can never create a duplicate.
The code above is small enough to copy into any project. Its retry helper is also in the snippets section.