Answer the question first
In Neovim’s single‑threaded Lua environment an expensive CPU function will block the UI unless it is offloaded. Two common ways to avoid that are:
- libuv work queue (exposed via
vim.loop.queue_work or uv_queue_work) – the heavy function runs on libuv’s thread pool, but the completion callback is scheduled back on the main loop.
- External job (via
vim.job_start or libuv child_process) – the function runs in a separate process, so the main loop never sees the blocking work.
When the libuv queue overhead surpasses the job‑start cost
Practical benchmarks on typical Neovim builds show a rough boundary around 10 ms of pure Lua work:
- Tasks that finish in ≈10 ms or less are usually cheaper with
uv_queue_work. The cost of queuing, executing on a worker thread, and re‑scheduling the result is lower than the cost of serialising arguments, spawning a child, and deserialising the reply.
- Tasks that take longer than ≈10 ms – especially if they are invoked frequently – start to dominate the libuv thread pool. Because the pool is shared across all async operations, a long job can starve I/O callbacks and cause noticeable input lag.
- When the work is truly multi‑core intensive or you need isolation (e.g., to avoid corrupting shared Lua state), the overhead of
vim.job_start is justified even for shorter tasks because it keeps the main loop entirely free.
These numbers vary with hardware: on a single‑core machine the threshold may be lower, while on a multi‑core system it can be higher. A safe rule of thumb is to measure the task’s runtime; if it regularly exceeds 10 ms or you observe UI jitter, switch to vim.job_start.
Recommended patterns for state synchronization with external processes
When using vim.job_start, the parent and child communicate over MessagePack. To keep state consistent and avoid races:
- Send immutable snapshots – copy the Lua tables you need and treat them as read‑only in the child.
- Attach version tags – include a monotonically increasing number or hash so the parent can discard stale replies.
- Compress large payloads – use
zlib or similar before serialising to reduce MessagePack size.
- Batch small jobs – bundle several lightweight computations into one job to amortise spawn overhead.
- Graceful cancellation – keep a handle to the job and call
job:close() when the user aborts. The child should handle SIGTERM and exit cleanly.
- Isolation of mutable state – the child cannot touch the parent’s Lua state. If the parent must share updates, expose an RPC endpoint that the child can query.
Practical verification steps
- Measure the task duration:
local start = vim.loop.hrtime()
-- heavy Lua code
local elapsed = (vim.loop.hrtime() - start) / 1e6
print('task took', elapsed, 'ms')
- If
elapsed > 10, try moving the function to vim.job_start and observe UI latency with vim.inspect(vim.fn.getcharinfo()) before and after.
- Check the libuv thread‑pool size (default 4 on most systems). On Windows you can set
UV_THREADPOOL_SIZE to increase it if you have many short jobs.
Missing diagnostic detail
To give a more precise recommendation for your case, I need to know:
- What is the typical runtime of each CPU‑bound Lua task you plan to run?
- How frequently will those tasks be invoked (per second, per user action, etc.)?
Provide those numbers, and I can refine the threshold and suggest an optimal UV_THREADPOOL_SIZE or job‑spawning strategy.