NetSuite concurrency limits: retries, backoff, and idempotent upserts that do not create duplicates

Published on
-
9 mins read
Authors

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

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

running 429 now waiting to retry done other integrations

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:

netsuite-client.js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Allows at most `size` tasks at the same time, the rest wait in FIFO order
export 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:

sync-orders.js
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 / 7
Order syncyour middlewareNetworkload balancerNetSuiteREST record API1POST /salesOrder2Commit SO-50013204 + Location4Timeout5POST /salesOrder (retry)6Commit SO-50027PUT /salesOrder/eid:WEB-1001

1. POST /salesOrder

The sync sends a create request for web order #1001.

Click an arrow to jump to that step
The client cannot tell 'never arrived' from 'arrived, response lost'. Only an idempotent write makes both cases safe.

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=item
Content-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-1001 can 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=item so 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:

  1. Pool requests to the share you allocated in Integration Governance.
  2. Retry only 429 and 5xx, with exponential backoff and full jitter, and a new auth header each time.
  3. 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.