Skip to content

Worker and Server Functions

An app runs in a browser from the source it runs on the desktop, and the browser has one thread. threading.Thread(...).start(), asyncio.to_thread(...) and switch_map with a plain function raise RuntimeError: can't start new thread, and the handler dies there.

A worker function is the API for that work. On the desktop it runs on a thread the runtime owns; in the browser it runs on a Web Worker, a second Python the page starts beside the app's. The same function marked @nv.server runs on a server instead; on the desktop it runs on a thread all the same. The screen's code is the same on both targets and under both marks, and so is the function's.

Worker Function

flowchart LR
    subgraph browser [Browser]
        direction LR
        screen[Page: the screen] -- "await search(...)" --> worker[Web Worker: search]
    end

Mark the function and await it

# jobs.py
import nuiitivet.material as nv

WORDS = [f"word{n}" for n in range(50_000)]


@nv.worker
def search(query: str) -> list[str]:
    return [word for word in WORDS if query in word]
# app.py
from jobs import search


class SearchScreen(nv.ComposableWidget):
    query = nv.Observable("word1")
    status = nv.Observable("")

    async def search(self) -> None:
        self.status.value = f"{len(await search(self.query.value))} matches"

The function is a plain def at the top level of a module in the app's directory, with every parameter and the return annotated. The screen awaits it like any coroutine: the line after the await runs on the UI thread, with the result, and the screen answers clicks while it waits.

In the browser the page runs one worker, which starts while the page loads; a call made before it is up waits for it, about two seconds. Calls run one at a time, in the order they were made: a call made while another runs waits for it. On the desktop each call gets a thread of its own.

Pass arguments and get the result

Arguments and the result are copied, and the type annotation decides how. The function never sees the caller's objects, on the desktop either: a list appended to inside the function stays as it was in the caller.

Annotation Carries
None, bool, int, float, str, bytes The value; an int passed for a float arrives as a float
datetime, date The value
An Enum subclass The member
A dataclass of these A dataclass of the same class
list[T], tuple[T, ...], tuple[A, B], dict[str, T], Optional[T] The container, of these

A value of another type, including a bool where an int is declared, is refused with the argument named. An annotation outside the table, Any, object, set, a plain list, is refused when the module imports.

Report progress

A parameter annotated nv.WriteOnlyObservable[T] is written by the function and read by the screen:

@nv.worker
def search(query: str, progress: nv.WriteOnlyObservable[float]) -> list[str]:
    matches: list[str] = []
    for index, word in enumerate(WORDS):
        if query in word:
            matches.append(word)
        if index % 500 == 0:
            progress.value = index / len(WORDS)
    return matches

The caller passes an nv.Observable[T] and binds it as usual:

class SearchScreen(nv.ComposableWidget):
    progress = nv.Observable(0.0)

    def build(self) -> nv.Widget:
        return nv.LinearProgressIndicator(value=self.progress)

    async def search(self) -> None:
        matches = await search(self.query.value, self.progress)

Each write lands on the caller's observable on the UI thread. A tight loop can write as often as it likes: the screen sees the latest value per frame. The function cannot read the observable back, and a type checker says so.

Cancel

A parameter annotated nv.CancelToken, with nv.CancelToken() as its default, is set when the caller gives up. The function checks cancel.cancelled and returns:

@nv.worker
def search(
    query: str,
    progress: nv.WriteOnlyObservable[float],
    cancel: nv.CancelToken = nv.CancelToken(),
) -> list[str]:
    for index, word in enumerate(WORDS):
        if cancel.cancelled:
            break
        ...

The caller gives up by cancelling the task that awaits the call:

    async def search(self) -> None:
        self._task = asyncio.ensure_future(search(self.query.value, self.progress))
        try:
            matches = await self._task
        except asyncio.CancelledError:
            self.status.value = "cancelled"

    def cancel(self) -> None:
        if self._task is not None:
            self._task.cancel()

The caller never passes the token; the default is the one that gets set. A function with no CancelToken parameter runs to the end whatever the caller does.

In the browser a running worker function is not told to stop: the page ends the worker it runs on and starts another. The next call waits for that start, about two seconds.

Handle an error

A built-in exception raised in the function, a ValueError, a KeyError, reaches the caller as itself. Any other class reaches it as nv.RemoteError, whose text is the class name and the message:

        except ValueError as error:
            self.status.value = str(error)
        except nv.RemoteError as error:
            self.status.value = str(error)  # "Rejected: not for you"

A ConnectionError says the function never answered: the worker stopped, or the server could not be reached.

Cap the calls that run at once

@nv.worker(parallel=3)  # up to three calls at once
def search(query: str, progress: nv.WriteOnlyObservable[float]) -> list[str]:
    ...

The number is the function's ceiling: three calls of search run at once and the fourth waits for one of them to return. On the desktop each runs on a thread from a pool; in the browser, on a Web Worker, so the page starts up to three. Without a number a thread is started per call and the page runs one call at a time.

The pool is everyone's. Every numbered function shares one pool, as large as the largest number among them and, in the browser, never larger than the machine's cores. A function that fills the pool makes another wait, within its own ceiling.

Run calls in a process

@nv.worker(process=True)  # a child process instead of a thread
def search(query: str, progress: nv.WriteOnlyObservable[float]) -> list[str]:
    ...

On the desktop the calls run in a child process, one at a time; with parallel=N as well, in up to N child processes. Reach for it when the work is pure Python that should use another core, when a loop that never checks its token must still be stoppable, or when native code may crash and must not take the window with it. The browser ignores the flag: there every call already runs in a Web Worker.

A Python starts when a call needs it. The first call waits for the first one, about 0.3 seconds on the desktop and about two in the browser; the second concurrent call waits for the second. warm=True starts them with the app instead:

@nv.worker(parallel=3, process=True, warm=True)  # the three start with the app
def search(query: str, progress: nv.WriteOnlyObservable[float]) -> list[str]:
    ...

The warmed Pythons belong to the pool, so every function uses them. Each one holds memory for as long as the app runs: tens of megabytes on the desktop, 60 to 100 in the browser.

Cancelling a call ends the Python running it, on both targets; the function's CancelToken is never set, so a function marked process needs none. The pool starts another Python when a call needs it. On a thread, cancelling sets the token as it always does.

A child process imports the function's module by name, and your entry script the way multiprocessing does, so app.run() stays under if __name__ == "__main__":. An app frozen with PyInstaller calls multiprocessing.freeze_support() first under that line. Under python -m nuiitivet.dev run, a saved edit reaches the pool at its next call: an idle Python is replaced at once, a busy one after its call.

Server Function

A server function does the same work on a server instead of in the browser.

flowchart LR
    subgraph browser [Browser]
        direction LR
        screen[Page: the screen] -- "await search(...)" --> send[search: the call]
    end
    send -- "one request" --> server[Server: search]

Mark the function and await it

# jobs.py
import nuiitivet.material as nv

WORDS = [f"word{n}" for n in range(50_000)]


@nv.server
def search(query: str) -> list[str]:
    return [word for word in WORDS if query in word]

The screen imports and awaits it as it does a worker function, and the rest is written the same way: the arguments and the result, progress, cancel, and the errors. A function moves between the two marks without a change to its body or its callers.

Choose between the two

A worker function runs in the browser, on the user's machine. A server function runs on a server, one request per call. What the table lists follows from that:

@nv.worker @nv.server
Start-up A second Python boots with the page, in parallel; a call made before it is up waits, about two seconds The server process is already running
A call About a millisecond, no network One round trip to the server
Cancel The worker is ended and another starts; the next call waits for it, about two seconds The function stops where it checks the token
Computing resources The user's machine, within what a browser tab may hold The server's
Libraries What Pyodide runs: pure Python, and the packages Pyodide ships. Each one is downloaded with the page Anything installed on the server, imported in a module kept there
Deployment The site alone, on any file server A Python process beside the site
Security The function's code and data are in the browser, where anyone can read them The same, unless the module is kept on the server

Mark the work @nv.worker unless a row above says otherwise.

Keep a module on the server

Your function's module goes to the browser with the rest of the app. Two kinds of module should not: one that imports a package the browser's Python does not have, psycopg2 say, which stops the app from starting in the browser; and one that holds a key you do not want anyone to read. Put nv.server_only() at the top of such a module and it stays on the server. For a package, the call goes in __init__.py and covers every module in it:

# backend/__init__.py
import nuiitivet.material as nv

nv.server_only()  # nothing under here reaches the browser
myapp/
├── app.py          # the screens; python -m nuiitivet.web run app.py
├── jobs.py         # @nv.worker and @nv.server functions the browser may see
└── backend/
    ├── __init__.py # nv.server_only()
    └── search.py   # @nv.server functions that import the server's libraries

Full samples

samples/web/worker_functions/ and samples/web/server_functions/ are the same app: a search for the words within one edit of the query, with progress, a Cancel button, and a button to click while it runs. The first marks search in jobs.py as a worker function; the second marks it as a server function. Search for word1, cancel a search, clear the field to see the ValueError, and look for secret to see the RemoteError.

Next Steps