3142 words
16 minutes
Promises and Async in Servoy: The New Concurrency Playbook

Promises and Async in Servoy tutorial hero image

This is a Servoy Tutorial on the Promise-based async APIs that landed in Servoy 2025.06 and expanded in 2025.12. If you have been writing Servoy code where every database call blocks the client until it returns, you are about to discover a whole category of performance wins that were not available to you before. If you have been following along with the modern JavaScript series, you already know the language features. This tutorial is about applying them to the one problem every real Servoy application eventually runs into: waiting on the database.

I want to be clear up front. Async is not a magic “make it fast” flag. It is a tool for a specific shape of problem, and if you use it everywhere you will make your code harder to read without making it any faster. The point of this tutorial is to show you when async genuinely helps, when the old synchronous call is still the right answer, and how to handle errors without creating a new category of bugs.

The Problem Async Solves#

Let’s imagine for a moment that you are building a customer dashboard. When the user opens it, you need to load:

  • The last ten orders
  • Outstanding balance
  • Recent support tickets
  • A summary of product returns in the last 90 days

Four queries. In the classic synchronous model, you fire them one at a time, and each one blocks the client until it returns. If each query takes 200 milliseconds, your dashboard takes the better part of a second to load. Your users see a spinner. Your application feels sluggish. The database is idle three out of every four queries because only one is running at a time.

This is the exact shape of problem async APIs were designed for. Independent queries that do not need each other’s results can run in parallel. Eight hundred milliseconds of sequential loading becomes 200 milliseconds of parallel loading. The database does the same amount of work, but the client waits four times less.

Make sense? Let’s look at how to do it.

What Changed in Servoy#

The timeline is short and matters:

  • Servoy 2025.06 brought async Promise APIs to the database layer: databaseManager.getDataSetByQueryAsync(), plugins.rawSQL.executeAsyncSQL(), and plugins.rawSQL.executeAsyncStoredProcedure(). The HTTP plugin had already introduced Promise-returning async APIs before this; 2025.06 extended the same pattern to the data plugins.
  • Servoy 2025.12 completed what Servoy calls the “all async plugins” rollout. Every async plugin (HTTP, HeadlessClient, RawSQL, databaseManager, and the new AI Runtime Plugin) supports both Promise return types and callback variants. Headless Client and REST Client also gained the ability to handle Promise results returned from solution methods.
  • Servoy 2026.03 added JSDoc generic notation for Promise return types, which means the script editor correctly infers the resolved type inside .then() callbacks.

Everything in this tutorial targets 2025.06 or later at minimum. The Promise generic JSDoc examples need 2026.03 for full editor support, but the code itself runs fine on 2025.06.

One thing to keep in mind on the naming. The database side uses the suffix style: getDataSetByQueryAsync. The rawSQL plugin uses the prefix style: executeAsyncSQL. Same idea, different conventions. The HTTP plugin sits in between with executeAsyncRequest. I wish it were uniform. It is not. Copy the names exactly.

The Two Shapes of Async API#

Servoy’s async APIs come in two forms. Both exist on purpose. Pick based on the job.

Promise Form#

databaseManager.getDataSetByQueryAsync(query, 1000)
.then(function(dsResult) {
// handle result
})
.catch(function(oError) {
// handle error
});

Returns a Promise. Chains with .then() and .catch(). Composes with Promise.all(). Type-infers correctly in the editor when you annotate with @return {Promise<JSDataSet>}. This is the form you reach for most of the time.

Callback Form#

plugins.http.createNewHttpClient()
.createGetRequest(sUrl)
.executeAsyncRequest(
function(oResponse) { /* success */ },
function(sErrorMessage) { /* error */ }
);

Takes a success callback and an error callback. No Promise to chain. No composition with other async work. This is simpler when the flow is a single linear “fire off a call and do one thing with the result.” Note that the HTTP plugin’s error callback receives the error message as a String, not an Error object.

When to Choose Which#

I use the Promise form by default. It composes. It centralizes error handling at the end of the chain. The editor understands it. Every modern JavaScript programmer reads it fluently.

I use the callback form in two specific cases: when I am wiring up a legacy handler that already uses callbacks and I do not want to force a style change, or when I am writing a one-off “fire and forget” with a single success path. Both situations are rare in a well-structured codebase.

Your First Async Query#

Here is a classic synchronous query, written the way Servoy has always been written:

/**
* Loads the ten most recent orders for a customer.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sCustomerId the customer id
* @return {JSDataSet} the recent orders
*/
function loadRecentOrdersSync(sCustomerId) {
/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrders = datasources.db.myserver.crm_order.createSelect();
qbOrders.result.add(qbOrders.columns.ordh_document_number);
qbOrders.result.add(qbOrders.columns.ordh_order_date);
qbOrders.result.add(qbOrders.columns.ordh_total);
qbOrders.where.add(qbOrders.columns.org_id.eq(globals.org_id));
qbOrders.where.add(qbOrders.columns.cust_id.eq(sCustomerId));
qbOrders.sort.add(qbOrders.columns.ordh_order_date.desc);
/**@type {JSDataSet}*/
const dsOrders = databaseManager.getDataSetByQuery(qbOrders, 10);
return dsOrders;
}

The call site is simple:

/**@type {JSDataSet}*/
const dsOrders = loadRecentOrdersSync(sCustomerId);
application.output('Found ' + dsOrders.getMaxRowIndex() + ' orders');

Nothing wrong with this. It is clear, it works, it returns in whatever time the database takes. When you only have one query to run, this is the right form.

Here is the same thing using the async API:

/**
* Loads the ten most recent orders for a customer, async.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sCustomerId the customer id
* @return {Promise<JSDataSet>} resolves with the recent orders
*/
function loadRecentOrdersAsync(sCustomerId) {
/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrders = datasources.db.myserver.crm_order.createSelect();
qbOrders.result.add(qbOrders.columns.ordh_document_number);
qbOrders.result.add(qbOrders.columns.ordh_order_date);
qbOrders.result.add(qbOrders.columns.ordh_total);
qbOrders.where.add(qbOrders.columns.org_id.eq(globals.org_id));
qbOrders.where.add(qbOrders.columns.cust_id.eq(sCustomerId));
qbOrders.sort.add(qbOrders.columns.ordh_order_date.desc);
return databaseManager.getDataSetByQueryAsync(qbOrders, 10);
}

Three differences worth pointing out:

  • The return type is Promise<JSDataSet> in the JSDoc. On 2026.03, the editor uses that generic to type the value inside any .then() callback that consumes this function. Without the generic, the editor sees “Promise” and does not know what it wraps.
  • databaseManager.getDataSetByQueryAsync(...) replaces getDataSetByQuery(...). The arguments are identical.
  • No intermediate const dsOrders variable. There is nothing to assign yet. The Promise represents future work.

The call site changes more:

loadRecentOrdersAsync(sCustomerId)
.then(function(dsOrders) {
application.output('Found ' + dsOrders.getMaxRowIndex() + ' orders');
})
.catch(function(oError) {
application.output('Load failed: ' + oError.message, LOGGINGLEVEL.ERROR);
});

The .then() callback gets the resolved value. The .catch() handles any failure in the chain. On 2026.03, the editor knows dsOrders is a JSDataSet because of the Promise generic annotation.

If you only ever ran one query, the async version would be strictly worse. More syntax, same performance. The payoff shows up when you have multiple independent queries.

Parallel Queries with Promise.all()#

Back to the dashboard. Four independent queries. None of them needs the result of any of the others. In the synchronous version, they run back-to-back. In the async version, they run at the same time.

Here is the sync version for reference:

/**
* Loads dashboard data for a customer, synchronously.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sCustomerId the customer id
* @return {Object} dashboard data
*/
function loadDashboardSync(sCustomerId) {
/**@type {JSDataSet}*/
const dsOrders = _queryRecentOrders(sCustomerId);
/**@type {JSDataSet}*/
const dsBalance = _queryOutstandingBalance(sCustomerId);
/**@type {JSDataSet}*/
const dsTickets = _queryRecentTickets(sCustomerId);
/**@type {JSDataSet}*/
const dsReturns = _queryRecentReturns(sCustomerId);
return {
orders: dsOrders,
balance: dsBalance,
tickets: dsTickets,
returns: dsReturns
};
}

Four queries, four round-trips to the database, one after another. If each takes 200 milliseconds, the function returns after 800 milliseconds.

Here is the async version using Promise.all():

/**
* Loads dashboard data for a customer in parallel.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sCustomerId the customer id
* @return {Promise<Object>} resolves with the dashboard data
*/
function loadDashboardAsync(sCustomerId) {
return Promise.all([
_queryRecentOrdersAsync(sCustomerId),
_queryOutstandingBalanceAsync(sCustomerId),
_queryRecentTicketsAsync(sCustomerId),
_queryRecentReturnsAsync(sCustomerId)
]).then(function(aResults) {
/**@type {Array<JSDataSet>}*/
const [dsOrders, dsBalance, dsTickets, dsReturns] = aResults;
return {
orders: dsOrders,
balance: dsBalance,
tickets: dsTickets,
returns: dsReturns
};
});
}

All four queries fire at the same time. The function returns after the slowest one finishes. If the slowest takes 200 milliseconds, the whole thing takes 200 milliseconds. You just got a 4x speedup with a structural change, not a query optimization.

A few things to notice:

  • Promise.all() takes an array of Promises and returns a single Promise that resolves to an array of the results, in the same order as the input array.
  • Array destructuring on the results. const [dsOrders, dsBalance, dsTickets, dsReturns] = aResults; unpacks the four results by position. This is much easier to read than aResults[0], aResults[1], and so on. The destructuring tutorial in this series covers this pattern in more detail.
  • The Hungarian prefix still matches what each element is. The destructured locals use the ds prefix because they are datasets.
  • The @type annotation on the destructuring line covers all four destructured variables. The array is typed as Array<JSDataSet>, and every element inside is a JSDataSet.

The Call Site#

loadDashboardAsync(sCustomerId)
.then(function(oDashboard) {
_renderDashboard(oDashboard);
})
.catch(function(oError) {
application.output('Dashboard load failed: ' + oError.message, LOGGINGLEVEL.ERROR);
plugins.dialogs.showErrorDialog('Error', 'Could not load dashboard.', 'OK');
});

One .catch() at the end handles failure from any of the four queries. If any one of them rejects, the whole Promise.all() rejects, and you get to handle it in one place.

Promise.all() Fails Fast#

There is one thing to understand about Promise.all(). If any of the Promises in the array rejects, the combined Promise rejects immediately with that error. The other queries may still be running in the background. Their results are discarded. This is called “fail fast” semantics.

Most of the time, that is exactly what you want. If you cannot load the balance, there is no point rendering a dashboard with stale balance data. Fail fast, show an error, let the user retry.

If you specifically want to collect all the results regardless of failures, use Promise.allSettled() instead. It returns after every Promise has either resolved or rejected, and gives you an array where each element tells you the outcome of that specific Promise. I use this maybe once a year. Promise.all() is the default.

Error Handling#

Error handling in async code is one of the places where it is easy to build bugs if you are not careful. Here is the pattern I use everywhere.

Event Handler With Promise Chain#

/**
* Button click handler that loads data asynchronously.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {JSEvent} event the event
*/
function onLoadData(event) {
try {
loadDashboardAsync(foundset.cust_id)
.then(function(oDashboard) {
_renderDashboard(oDashboard);
})
.catch(function(oError) {
application.output('Async load failed: ' + oError.message, LOGGINGLEVEL.ERROR);
plugins.dialogs.showErrorDialog('Error', 'Could not load dashboard: ' + oError.message, 'OK');
});
} catch (e) {
application.output('Error in onLoadData: ' + e.message, LOGGINGLEVEL.ERROR);
plugins.dialogs.showErrorDialog('Error', 'An error occurred: ' + e.message, 'OK');
}
}

Two levels of error handling. Let’s break that down because it is the part people usually get wrong.

  • try/catch wraps the whole thing. This handles any synchronous failure that happens while setting up the Promise chain. If loadDashboardAsync() throws before it even returns a Promise, the .catch() at the end of the chain will never run, because there is no chain. The outer try/catch catches that case.
  • .catch() at the end of the chain handles any failure that happens inside the Promise chain. This includes rejections from any of the individual queries inside Promise.all(), and any error thrown inside a .then() callback.

If you only have the .catch(), synchronous setup failures get silently swallowed. If you only have the try/catch, async failures propagate into the console as unhandled rejections and your error dialog never fires. You need both.

Always Log, Always Dialog#

Every catch block does two things: logs with application.output() at LOGGINGLEVEL.ERROR, and shows a user-facing dialog. I include the function name in the log for traceability. When you are debugging a production issue three months later and all you have is a log file, you will thank yourself for the function names.

The dialog matters because async failures are invisible to the user by default. The user clicked a button. Nothing happened. They try again. Nothing happens. They escalate to you. If you had shown a dialog, they would have known to try later or to call support.

A Note on Error Object Shapes#

The shape of the error you get inside .catch() is not perfectly uniform across plugins. The database APIs hand you an Error-like object with a .message property, which is what the examples above assume. The HTTP plugin’s .catch() receives the exception message as a String directly, not an Error wrapper. So for HTTP code specifically, log oError itself rather than oError.message. When in doubt, log the value with application.output(JSON.stringify(oError)) once during development and adapt the rest of your code to whatever shape the plugin actually delivers.

Never Swallow Errors Silently#

Do not write this:

// Wrong: silent failure
loadDashboardAsync(sCustomerId)
.then(function(oDashboard) {
_renderDashboard(oDashboard);
});

No .catch(). If the Promise rejects, the error vanishes into the void, the dashboard never renders, and the user has no idea why. Every Promise chain needs a terminating .catch(). No exceptions.

Using the Callback Form#

There are times when callbacks are the simpler choice. Here is the HTTP plugin using the callback form:

/**
* Fires off a webhook notification without blocking.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sPayload the JSON payload to send
*/
function fireWebhook(sPayload) {
try {
/**@type {Object}*/
const oClient = plugins.http.createNewHttpClient();
/**@type {Object}*/
const oRequest = oClient.createPostRequest('https://hooks.example.com/notify');
oRequest.setBodyContent(sPayload, 'application/json');
oRequest.executeAsyncRequest(
function(oResponse) {
application.output('Webhook status: ' + oResponse.getStatusCode());
},
function(sErrorMessage) {
application.output('Webhook failed: ' + sErrorMessage, LOGGINGLEVEL.ERROR);
}
);
} catch (e) {
application.output('Error in fireWebhook: ' + e.message, LOGGINGLEVEL.ERROR);
}
}

A few things worth pointing out. The success callback receives a Response object you can interrogate with getStatusCode(), getResponseBody(), and friends. The error callback receives the error message as a String directly. That is why sErrorMessage here is annotated as a string rather than an object with a .message property. Get the shape wrong and you will end up logging “Webhook failed: undefined.”

No composition. No chaining. Just “fire this off, log whichever way it lands.” The callback form reads the same as the synchronous version with the flow inverted. If you are writing a one-off integration where all you want is success or failure, callbacks are fine. If you are composing this webhook call with three other async operations, use the Promise form instead.

Note that the HTTP plugin also gives you a Promise form via executeAsyncRequest() without arguments. The same underlying operation, two callable shapes. Same for rawSQL.executeAsyncSQL() and the AI plugin’s chat() method. Pick based on what the rest of the code in that function is doing.

A small caveat on Response objects from HTTP. A status code other than 200 is still a successful Promise resolution. The error callback (or .catch()) only fires when the request itself errors out (network failure, DNS issue, timeout). A 500 from the server is a “successful” call that returned a 500. Always check oResponse.getStatusCode() in your success path before assuming the call worked.

The @return {Promise<T>} Generic#

On 2026.03, the script editor understands generic notation on Promise return types. This matters more than it sounds like it should, because without it, every .then() callback is typed as Object and you lose autocomplete on the resolved value.

/**
* Loads the recent orders for a customer.
* @author Gary Dotzlaw
* @since 2026-04-12
* @public
*
* @param {String} sCustomerId the customer id
* @return {Promise<JSDataSet>} resolves with the orders dataset
*/
function loadRecentOrdersAsync(sCustomerId) {
/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrders = datasources.db.myserver.crm_order.createSelect();
qbOrders.result.add(qbOrders.columns.ordh_document_number);
qbOrders.where.add(qbOrders.columns.org_id.eq(globals.org_id));
qbOrders.where.add(qbOrders.columns.cust_id.eq(sCustomerId));
return databaseManager.getDataSetByQueryAsync(qbOrders, 10);
}
// Call site: editor knows dsOrders is a JSDataSet
loadRecentOrdersAsync(sCustomerId).then(function(dsOrders) {
application.output(dsOrders.getMaxRowIndex()); // autocomplete works
});

Without the generic, you would need an explicit annotation inside the callback to get autocomplete:

// Without the generic on the declaration, annotate at the call site
loadRecentOrdersAsync(sCustomerId).then(function(dsOrders) {
/**@type {JSDataSet}*/
const dsTyped = dsOrders;
application.output(dsTyped.getMaxRowIndex());
});

That is annoying, and annoyance leads to skipped annotations, and skipped annotations lead to typos. Add the generic on the function that returns the Promise. The editor takes care of the rest.

When Async Is the Wrong Choice#

Async is not a universal upgrade. Here are the cases where the synchronous version is the right answer.

One query, in sequence. If the function only runs one query, and the rest of the logic depends on its result, the async version adds syntax without removing any wait. Use the synchronous call.

Transactions that span multiple queries. Servoy transactions are tied to the current thread. Mixing sync and async inside a transaction is a recipe for “transaction timed out” errors and inconsistent state. When you are inside a databaseManager.startTransaction() block, stick with synchronous calls.

Simple CRUD inside form event handlers. The user clicks Save. You save one record. You show a confirmation. Making that async buys you nothing and complicates the flow. Save the record synchronously.

Small datasets loaded on form show. If the form’s onShow handler loads a single small foundset and renders it, the sync call is simpler and the user will never notice the difference.

The rule of thumb: reach for async when you have multiple independent operations whose results are all needed together, or when you are calling out to something slow (an HTTP endpoint, an LLM, a big SQL aggregate against millions of rows) and the client can do other useful work while waiting. Otherwise, stay sync.

The House-Style Position#

Applying the conventions from this series to async code:

Keep:

  • Hungarian notation on every variable, including inside .then() callbacks
  • @type JSDoc annotations on every variable, including destructured ones
  • Full JSDoc headers on every function
  • try/catch at invocation boundaries
  • Named function declarations at scope level, arrow functions for inline callbacks only
  • org_id tenant isolation in every query

Adopt for async:

  • @return {Promise<T>} generics on every Promise-returning function (full editor support on 2026.03)
  • Promise.all() with destructured results for parallel independent queries
  • .catch() at the end of every Promise chain (never leave a chain un-terminated)
  • Promise form by default, callback form when the flow is trivially linear
  • Promise.allSettled() when you genuinely want all results regardless of failures (rare)

The async features improve throughput on the specific shape of problem they were designed for. They do not replace synchronous code. They complement it. Your goal is to use each where it actually helps, not to rewrite the entire codebase because you can.

What Comes Next#

This tutorial covered the language mechanics of Promises and the three core data APIs: databaseManager.getDataSetByQueryAsync(), plugins.rawSQL.executeAsyncSQL(), and plugins.http.executeAsyncRequest(). There is more in the pipeline:

  • The AI Runtime Plugin uses Promises for every chat completion and embedding call. The .chat() method returns a Promise resolving to a ChatResponse. Streaming responses use the callback form instead. The four-part AI Runtime Plugin series already published earlier in this site uses these exact patterns end to end.
  • The typed Query Builder covers the typed QBColumn subclasses that shipped in 2025.09 and pair well with async query patterns.
  • Batch processing patterns covers how to parallelize large data jobs with Promise.all() while keeping memory usage under control.

Each of these builds on the Promise patterns you have now. Once you have the habit of returning Promises from async-capable functions, handling errors with .catch(), and composing parallel work with Promise.all(), the rest of the Servoy async ecosystem reads like a natural extension of the same playbook.

That concludes this Servoy tutorial on Promises and async. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.

Promises and Async in Servoy: The New Concurrency Playbook
https://dotzlaw.com/insights/servoy-tutorial-18-promises-and-async/
Author
Gary Dotzlaw
Published at
2026-05-03
License
CC BY-NC-SA 4.0
← Back to Insights