Preventing duplicate NetSuite records from double clicks: a client lock plus a server idempotency check

Published on
-
6 mins read
Authors

"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 / 6
Userslow networkClient scriptpayment pageSuiteletcreate endpointNetSuiterecords1Click Generate2POST (A)3Click again4POST (B)5Create PR-16Create PR-2

1. Click Generate

The first click sends request A.

Click an arrow to jump to that step
Each click is a separate, valid request. Nothing on either side knew the first one was still running.

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.