Actors or server functions?#

SignalX gives you two ways to run code on the server from a component, and they are not rivals. A server function is the default. An actor is what you reach for when calls about the same thing need identity, ordering or memory.

The rule of thumb#

Use a server function until you need identity, ordering or memory between calls. Then use an actor.

Most endpoints never need any of the three. Fetching a product, submitting a form, running a search — these have no "which one" to be serial about, and a serverFn handles them with less machinery and no activation to keep warm.

Side by side#

The same feature, both ways. A counter that must never lose an increment:

TypeScript
// As a server function — correctness lives in the database
export const increment = serverFn(async (rq, id: string) => {
    const row = await db.query(
        'UPDATE counters SET n = n + 1 WHERE id = $1 RETURNING n', [id]);
    return row.n;
});
TypeScript
// As an actor — correctness lives in the model
export const Counter = defineActor({
    type: 'Counter',
    state: () => ({ n: 0 }),
    methods: (ctx) => ({
        async increment() {
            ctx.state.n += 1;   // the only writer, one turn at a time
            await ctx.save();
            return ctx.state.n;
        },
    }),
});

The server function is correct because the database serialized the update. The actor is correct because only one turn runs at a time and the actor is the only writer. The first is simpler; the second stops needing the database to arbitrate every read, and keeps working when the operation is not expressible as one statement.

Flip the example to "increment, then call a payment API, then decide" and the server-function version needs a lock, a transaction boundary or an idempotency key. The actor version is the same six lines.

What pushes you toward an actor#

  • Identity. The work is about one cart, one room, one device, one game — and two calls about the same one must not interleave.
  • Ordering. Later calls must observe earlier ones. Serial turns give you that for free.
  • Memory between calls. Keeping state hot avoids re-reading it on every request, and lets you hold things that do not belong in a row — an in-flight batch, a connection, a timer.
  • Timers and reminders. ctx.timer and durable reminders let an object wake itself up. A stateless function has nowhere to put that.
  • Long-running work. Tasks and jobs survive the request that started them.

What pushes you toward a server function#

  • No identity. A list, a search, a report.
  • Read-heavy and cacheable. A serverFn can be cached at the edge without turn ordering in the way. Actors have cacheable reads too, but the trade is sharper.
  • Fan-out over many rows. An actor is one object; a query is better at touching thousands.
  • Nothing to keep warm. An activation has a lifetime and a memory cost. If there is no state worth keeping, that cost buys nothing.

They are the same wire#

This matters more than it looks: an actor call is a synthesized server function. The actor endpoint speaks the serverFn protocol verbatim, so everything you already configured applies unchanged — the origin policy, the serialize codec, ServerFnError masking, body size caps, onError, and the per-request scope.

@sigx/actors peer-depends on @sigx/server for exactly this reason. Actors are a layer on server functions, not an alternative stack.

Three practical consequences:

  1. Policies are the same policies. An actor's authorize takes core's ServerPolicy, so one you wrote for your server functions works on an actor unchanged — and your app's default policy covers both. What an actor adds is op.resource, which carries the actor's type and key, so a policy can decide per instance. See Authorization.
  2. Your types cross both boundaries identically. Date, Map, Set, bigint and your own registered types round-trip through the same codec.
  3. The fail-closed rule applies to both. A server function and an actor that each declare nothing, with no server app configured, both deny — so configuring the app once is what makes either of them callable.

Using both together#

The common shape in a real app is not a choice at all:

TypeScript
// A server function does the query — no identity involved.
export const listRooms = serverFn(async (rq) => db.rooms.findMany());

// An actor owns the thing that has identity and ordering.
export const RoomActor = defineActor({ type: 'Room', /* … */ });

A server function may call an actor, and an actor may call another actor. What an actor must not do is call back into itself through a server function — that is a cycle, and the deadlock detector will say so.

One wrinkle worth knowing when you mix them: a write performed by a server function does not invalidate an actor read on the page, because nothing told the page it went stale. Declare it by hand with cells.invalidate([actorKey(RoomActor, room)]) — see Reads and writes in components.

Next steps#