This document summarizes key concepts from Node-addon-api documentation for handling threads, promises, and async operations in native addons.
ThreadSafeFunction::New() with initial thread countAcquire() when starting to use itRelease() when doneThreadSafeFunction tsfn = ThreadSafeFunction::New(
env,
callback,
"Resource Name",
0, // Unlimited queue
1, // Initial thread count
[]( Napi::Env ) { // Finalizer
// Clean up after threads
nativeThread.join();
}
);
Thread Management:
Acquire() and BlockingCall()napi_closing status during shutdownRelease() is the last call from a threadShutdown Handling:
Abort() to signal no more calls can be madePromise::Deferred objectsResolve() or Reject()Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
// Store promise for return
Napi::Promise promise = deferred.Promise();
// Later, resolve or reject
deferred.Resolve(result); // or
deferred.Reject(error);
Main Thread Only:
Napi::Env, Napi::Value, or Napi::ReferenceAny Thread:
Acquire(), Release(), BlockingCall()Cause: ThreadSafeFunction not properly released or threads still running
Solution:
Acquire() with Release()napi_closing status gracefullyCause: Async operation fails to resolve/reject promise before shutdown
Solution:
Cause: Object deleted while detached thread still running
Solution:
Execute(): Runs on worker thread (no Node.js API access)OnOK(): Called on main thread when work completes successfullyOnError(): Called on main thread if an error occursclass MyWorker : public Napi::AsyncWorker {
public:
MyWorker(Napi::Function& callback, std::string data)
: AsyncWorker(callback), data_(data) {}
void Execute() override {
// This runs on a worker thread
// Do NOT use any Napi:: methods here
result_ = ProcessData(data_);
}
void OnOK() override {
// This runs on the main thread
Callback().Call({Env().Null(), String::New(Env(), result_)});
}
void OnError(const Napi::Error& error) override {
// This runs on the main thread
Callback().Call({error.Value()});
}
private:
std::string data_;
std::string result_;
};
class ProgressWorker : public Napi::AsyncProgressWorker<int> {
public:
ProgressWorker(Napi::Function& callback, Napi::Function& progress)
: AsyncProgressWorker(callback), progress_callback_(Napi::Persistent(progress)) {}
void Execute(const ExecutionProgress& progress) override {
for (int i = 0; i < 100; i++) {
// Send progress update
progress.Send(&i, 1);
// Do work...
}
}
void OnProgress(const int* data, size_t count) override {
// Called on main thread with progress data
if (!progress_callback_.IsEmpty()) {
progress_callback_.Call({Napi::Number::New(Env(), *data)});
}
}
private:
Napi::FunctionReference progress_callback_;
};
BackupJob runs each sqlite3_backup_step() in its own BackupStep
(Napi::AsyncWorker) and queues the next step from the main thread after the
previous one completes, as node:sqlite's BackupJob does. Do not fold the
steps back into one worker loop:
DatabaseSync waited for most of the
backup: 204 ms of a 208 ms backup of a 128 MB WAL database. Returning to the
main thread between steps bounds that wait to one step (under 1 ms at the
default rate: 100). std::this_thread::yield() or a sleep between steps
does not guarantee the waiting thread gets the mutex.rate: 100 (124–132 ms as one loop) and
580–690 ms at rate: 1 (195–210 ms as one loop), the same as node:sqlite.napi_async_work, so every step creates
a new async resource. FreeEnvironment() disallows JavaScript and then runs
pending async work to completion before it runs env cleanup hooks, so
OnStepComplete() checks whether JavaScript can still run and stops instead
of queuing another step. Creating an async resource there makes any
async_hooks init callback fail as a fatal exception.close() releases the backup through Abandon(), which takes the lock that
Step() holds for a whole step. It waits for a running step (which also
holds the source connection's mutex, so close() waited for it anyway), and
a step that runs afterwards returns without touching SQLite. Without the
lock, a step used a sqlite3_backup that close() had finished, or attached
to a source connection that close() had freed.SQLITE_BUSY or SQLITE_LOCKED is retried from a
timer, 1 ms at first and doubling to 100 ms, rather than queued at once,
which kept one CPU core busy for as long as another connection held the
lock (node:sqlite still does). The timer is a uv_timer_t, not
setTimeout(): fake timers such as Sinon's replace setTimeout on both the
global and node:timers, and a retry scheduled on a fake clock never runs,
even after the fake is uninstalled. The timer callback runs without an
entered V8 context, so ScheduleRetry() creates the next step up front and
the callback only queues it (napi_queue_async_work takes a
node_api_basic_env). Each timer has an async cleanup hook, because a worker
aborts if its loop still has an open handle when it closes, and Node runs the
loop during teardown only while async cleanup hooks are pending. No step is
queued while a retry waits, so BackupJob::CleanupHook deletes the unqueued
step and finishes the job.A thread started with std::thread(...).detach() has these problems:
Anti-pattern Example:
std::thread([work]() {
// Do work
}).detach(); // BAD: Cannot join this thread
Correct Pattern:
// Store thread handle and join in destructor/finalizer
std::thread worker([work]() {
// Do work
});
// Later, in cleanup:
worker.join(); // Wait for thread to complete
Note: Adding arbitrary timeouts or forcing garbage collection in tests is NOT a solution. These are band-aids that mask the underlying design flaw.