Preventing duplicate NetSuite records from double clicks: a client lock plus a server idempotency check
- Published on
- -6 mins read
- Authors
- Name
- Andy Nur (Andy)
- @andynur
"The system created the same payment request three times." When a user reports this, the first instinct is to blame the user. But if the page lets a slow request be submitted again, the duplicate is a bug in the page.
In this article, I'll show how this happened on a custom NetSuite payment page and how I fixed it on both the client and the server. Client details are anonymized and the code is simplified.
We'll cover:
- How a Suitelet-backed page creates duplicates on a slow network
- A client-side submit lock, including NetSuite's mirrored buttons
- A server-side idempotency check, which is the real guarantee
Prerequisites
You should know SuiteScript 2.1 client scripts and Suitelets, and how a client script can call a Suitelet with N/https. If idempotency is new to you, my article on NetSuite concurrency limits and idempotent upserts covers the idea in an integration context.
The setup
Users create a payment request from a custom page that lists a purchase order's invoices. They fill in the invoice details and click Generate. A client script sends the data to a Suitelet, which creates the payment request record and returns its ID.
On a fast connection, the response comes back in about a second. On a slow one, it can take much longer, with no visible feedback. So users click again.
How two clicks became two records
Step 1 / 61. Click Generate
The first click sends request A.
The page already had a uniqueness rule (invoice number plus vendor), but only in the client script, before the request was sent. Both requests passed that check, because neither record existed yet.
There was also an earlier fix for this from years before: a "Please wait" dialog shown on click. It told the user to wait, but nothing actually stopped the handler from running again.
Fix 1: a client-side submit lock
The first layer stops the second request from being sent at all. A flag at the top of the submit handler ignores clicks while a request is in flight:
let isSubmitting = false
function create() { if (isSubmitting) return const payload = collectForm() if (!payload) return
isSubmitting = true setGenerateButtonsHidden(true)
jQuery .post(suiteletUrl, JSON.stringify(payload), (data) => { if (data.stat === 'ERR') { unlock() showError(data.message) return } // Success navigates to the new record, so the lock never needs releasing window.open(data.url, '_self') }) .fail(unlock) // network drop: let the user retry}
function unlock() { isSubmitting = false setGenerateButtonsHidden(false)}The flag is the real guard. Hiding the button is feedback, so the user can see the request was sent.
One NetSuite detail: NetSuite renders a second copy of the form's buttons below the sublist, with the ID prefixed by secondary. Its click proxies to the real button. Hiding only the top Generate button leaves the bottom one clickable, so hide both. Hiding works better than only disabling here: the "Please wait" overlay dims the whole page, so a greyed-out button looks the same as an active one.
function setGenerateButtonsHidden(hidden) { ;['custpage_btn_generate', 'secondarycustpage_btn_generate'].forEach((id) => { const btn = document.getElementById(id) if (!btn) return btn.disabled = hidden btn.style.display = hidden ? 'none' : '' })}Not enough on its own
A client lock is a UX fix. It doesn't help with two open tabs, a browser retry, or any other path that reaches the Suitelet twice. The server must still check.
Fix 2: a server-side idempotency check
The second layer is the real guarantee. Before creating anything, the Suitelet looks for an active payment request with the same invoice number and vendor, using the same rule the client already used. If one exists, it returns that record instead of creating a new one:
function onRequest(context) { const data = JSON.parse(context.request.body)
const existingId = findExistingPaymentRequest(data.invoiceNumber, data.vendorId) if (existingId) { context.response.write(JSON.stringify({ id: existingId, duplicate: true })) return }
const id = createPaymentRequest(data) context.response.write(JSON.stringify({ id, duplicate: false }))}
function findExistingPaymentRequest(invoiceNumber, vendorId) { let found = null search .create({ type: 'customrecord_payment_request', filters: [ ['custrecord_pr_invoice_number', 'is', invoiceNumber], 'AND', ['custrecord_pr_vendor', 'anyof', vendorId], 'AND', ['isinactive', 'is', 'F'], ], }) .run() .each((r) => { found = r.id return false }) return found}Returning the existing record, instead of an error, matters. From the user's point of view, the request succeeded. The page shows the payment request either way.
Is this race-proof?
Not completely. Two requests that arrive at exactly the same moment can both search, find nothing, and both create. NetSuite has no unique constraints on custom record fields, so there's no database-level lock to rely on.
In practice, the client lock removes almost all concurrent requests, and the server check catches the rest that arrive even slightly apart. In Sandbox, repeated rapid clicks on a throttled connection now always produce exactly one record. If you need a stronger guarantee, set the record's externalid from the uniqueness key: NetSuite enforces unique external IDs per record type, so the second create fails.
Conclusion
Duplicates from double clicks are a design problem, not a user problem. Fix them in two layers:
- Client: lock the submit while a request is in flight, and remember NetSuite's mirrored buttons
- Server: check for an existing record with the same business key, and return it if found
The client layer makes the page feel right. The server layer makes the data right.