Async Callbacks
Return promises to page script and run asynchronous work without blocking the renderer.
On this page
When page script calls an exposed C++ function, the page pauses and waits for that function to return. If that function does slow work (such as reading a file, making a network request, or running a heavy calculation), the page and your UI freeze.
Making the function asynchronous avoids this— the function hands the page a Promise right away, does its work elsewhere, and settles the Promise when finished.
Choosing Between Resolvers and Tasks
You can write an exposed asynchronous function using either a js::Resolver or a js::Task.
Page script cannot tell them apart— both return standard JavaScript Promises, and you can mix both styles freely across your application.
js::Resolver |
js::Task<T> |
|
|---|---|---|
| What it is | A handle that settles one Promise | A C++ coroutine that settles on return |
| How you write it | Take a trailing js::Resolver parameter and call Resolve() or Reject() later |
Return js::Task<T>, using co_await and co_return |
| Where it settles | Any thread | The Renderer's thread (with js::RunOnWorker() for background work) |
| Waiting on page promises | Value::Then() |
co_await js::Await() |
| Programming style | Callback-based | Sequential async function |
When to Use Each Style
js::Resolver— use this when another native system reports the result (such as a worker queue, an OS event, or a background network callback). It fits single-step tasks where an outside system signals completion.js::Task— use this for sequential multi-step operations (such as loading an asset on a worker thread, processing it, and querying the page for input). It reads top to bottom like an asynchronous JavaScript function.
In both styles, if page script calls an exposed C++ function with invalid arguments, the returned Promise rejects with a TypeError and the C++ function never runs.
Working with Resolvers
A js::Resolver lets native code settle a single Promise directly. You can move the resolver onto another thread or pass it to a background job, then call Resolve() or Reject() when the work completes.
Define an exposed C++ function that returns void and takes a js::Resolver as its final parameter:
api["save"] = [](std::string path, js::Resolver done) {
worker_queue.Push([path, done = std::move(done)]() mutable {
if (WriteSave(path))
done.Resolve(true);
else
done.Reject(js::Error::Make(js::ErrorType::Error, "disk full"));
});
};
Page script awaits the returned Promise normally:
const saved = await myApp.save("slot1.dat");
Settling the Promise
Resolvers follow these settlement rules:
- You can call
Resolve()orReject()from any thread. - The value passed to
Resolve()is copied or moved immediately, then converted to JavaScript on the Renderer's thread duringRenderer::Update(). - Only the first call to
Resolve()orReject()takes effect— later calls are ignored. - If a resolver is destroyed before settling, it rejects the Promise automatically so the page never hangs.
Type Restrictions
- A
js::Resolveris move-only and cannot be stored directly in astd::function. Wrap it in astd::shared_ptrif you need to pass it to a copyable callable. - Call-only types cannot be resolved. For non-owning views like
std::span, copy the data into an owning container before resolving. For raw instance pointers, pass an owning holder instead.
Creating Promises Outside an Exposed Function
Native code can also give the page a pending Promise without an exposed function (eg, as a property the page reads). Call Context::MakePromise() to get a js::PendingPromise holding both the page's Promise and a js::Resolver to settle it:
auto [promise, resolver] = ctx.MakePromise();
ctx["ready"] = promise;
// Later, from any thread:
resolver.Resolve(std::string("ok"));
Working with Tasks
A js::Task<T> is a C++ coroutine that returns a JavaScript Promise to the page. It reads from top to bottom, pausing at each co_await without blocking the Renderer's thread. Tasks use standard C++20 coroutines— because the Ultralight C++ API already requires C++20, you don't need extra compiler configuration.
Return js::Task<T> from an exposed C++ function:
api["loadSave"] = [](std::string slot) -> js::Task<SaveData> {
SaveData data =
co_await js::RunOnWorker([slot] { return ReadSaveFromDisk(slot); });
if (!data.valid)
co_return js::Error::Make(js::ErrorType::Error, "save is corrupt")
.WithCode("BAD_SAVE");
co_return data;
};
Page script awaits the returned Promise normally:
const save = await myApp.loadSave("slot1");
JavaScript Equivalents
js::Task maps standard JavaScript asynchronous syntax directly to C++ coroutine keywords:
| JavaScript | C++ |
|---|---|
async function f() { ... } |
[](...) -> js::Task<T> { ... } |
return value; |
co_return value; |
throw new Error("..."); |
co_return js::Error::Make(...); |
await somePromise |
co_await js::Await<T>(promise) (returns js::Result<T>) |
await fn(...) |
co_await js::Await<T>(fn(...)) (returns js::Result<T>) |
| (run on another thread) | co_await js::RunOnWorker([...] { ... }) (returns plain T) |
How a Task Runs
Coroutines run under these execution rules:
- The task body starts immediately when the page calls the exposed C++ function and runs up to the first
co_await. - The coroutine body always runs on the Renderer's thread, both before and after every
co_await. - The Promise settles during a later
Renderer::Update()after the coroutine completes. - Function parameters remain valid for the entire task rather than expiring at the first
co_await— this includesconstreferences,js::CallInfo, and views likestd::string_viewandstd::span.
Reporting Errors
Use co_return js::Error::Make(...) to reject the Promise with an error, as shown in the save-loading example above.
A js::Task<void> cannot co_return an error. To report failures from a void operation, return js::Task<bool> instead, or use a js::Resolver.
When compiled with C++ exceptions enabled, any unhandled exception escaping the coroutine body automatically rejects the page's Promise.
Running Work on a Background Thread
Use co_await js::RunOnWorker(fn) to run a function on a background thread. When the function returns, the coroutine resumes on the Renderer's thread with the function's plain return value rather than a js::Result.
Ultralight runs these functions on a small worker pool shared across the entire library. You should avoid long-running operations that occupy a worker thread for an extended time.
🚧 Keep JavaScript on the Renderer's Thread
Never access JavaScript values or contexts inside the worker function. Types like
js::Valueandjs::Contextwork only on the Renderer's thread. Pass plain C++ data into the worker function and return plain C++ data from it.
Awaiting Page Promises
Inside a task, co_await js::Await<T>() pauses native code until a page Promise settles, returning a js::Result<T>.
Pass a call to a page function directly to js::Await<T>() to invoke it and await its returned Promise:
api["confirm"] = [](js::Value ask) -> js::Task<bool> {
js::Result<bool> answer = co_await js::Await<bool>(ask("Overwrite?"));
co_return answer ? *answer : false;
};
Page script passes a callback that returns a Promise:
const ok = await myApp.confirm((question) => showDialog(question));
What the Result Holds
The returned js::Result<T> holds one of these outcomes:
- The Promise resolves— the value converted to
T. - The Promise rejects— a
js::Errorholding the rejection reason. - Conversion fails— a
TypeErrorif the resolved value cannot convert toT. - The page goes away— a page-gone error if the page navigates or closes before the Promise settles.
- The call fails— an error immediately if the function throws, the function handle is empty, or the page is already gone.
Awaiting Non-Promise Values
Like the await operator in JavaScript, js::Await() also accepts values that are not Promises.
If you pass a primitive value like a number or a string, the task resumes immediately with that value.
Any other object passes through the page's Promise.resolve() first, which can run page script.
Navigation and Cancellation
If the page closes or navigates away while a task is waiting, the task still resumes— awaiting a Promise from that page yields a page-gone error, and operations on the page's values fail safely. When the task finishes, the library discards its final result.
You can't cancel a running task directly. A task stops only when native code destroys the last owning handle of the js::API that exposed the function— while another owning handle to that js::API exists, tasks keep running.
Once that last handle is destroyed, the waiting task doesn't resume after its next co_await of js::RunOnWorker() or js::Await(). The rest of its body never runs, and the page's Promise rejects with a TypeError whose code is "ULJS_DETACHED".
Waiting on Promises Without Coroutines
If you need to wait on a JavaScript Promise without using C++ coroutines, call Value::Then(). It registers a callback that runs on the Renderer's thread during a later Renderer::Update() when the Promise settles.
If the js::Value is not a Promise, Value::Then() returns false immediately.
Call Then() on a js::Value to receive the settlement result:
api["whenReady"] = [](js::Value promise) {
bool waiting = promise.Then([](js::Result<js::Value> result) {
if (result)
LoadLevel(*result);
else if (!result.error().is_page_gone())
Log(result.error().message());
});
if (!waiting)
Log("whenReady expects a Promise");
};
Dispatching Work from Another Thread
To run code on the Renderer's thread from a background thread without returning a Promise, call Context::PostTask(). It schedules a callback to run on the Renderer's thread during a later Renderer::Update().
You can call PostTask() from any thread. If the page closes before the task runs, Ultralight skips the callback safely.
Schedule a callback on the Renderer's thread using PostTask():
ctx.PostTask([](js::Context& live) {
live["progress"] = 0.5;
});
When Completions Arrive
All asynchronous completions arrive on the Renderer's thread during Renderer::Update(). This includes resolved js::Resolver instances, resumed js::Task coroutines, worker results from js::RunOnWorker(), callbacks scheduled with Context::PostTask(), and awaited JavaScript Promises.
👍 Keep Calling Update
You must continue calling
Renderer::Update()in your frame loop, or asynchronous completions will never arrive. See Updating and Rendering for guidance on running the loop.