docs

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

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:

C++
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:

JavaScript
const saved = await myApp.save("slot1.dat");

Settling the Promise

Resolvers follow these settlement rules:

Type Restrictions

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:

C++
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:

C++
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:

JavaScript
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:

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::Value and js::Context work 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:

C++
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:

JavaScript
const ok = await myApp.confirm((question) => showDialog(question));

What the Result Holds

The returned js::Result<T> holds one of these outcomes:

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:

C++
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():

C++
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.