Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions docs/06-concepts/14-scheduling/06-reactive-future-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Reactive Future Calls

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have started adding frontmatter description to all pages for SEO.
Also, use sentence case for headings.

Suggested change
# Reactive Future Calls
---
description: React to database row changes in near real-time using Serverpod's reactive future calls, which trigger your handler when inserts, updates, or deletes match a condition.
---
# Reactive future calls


Reactive future calls let you react to database changes in near real-time. When a row is inserted, updated, or deleted, Serverpod can automatically and asynchronously invoke your code with the changed data. This is useful for scenarios like sending notifications when an order is confirmed, syncing data to external systems, or triggering workflows based on state changes.

Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The users don't need to know how things work under the hood. What they need to know here is that the rolled-back changes never produce events. We can skip directly to the point.

Suggested change
Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events.
Because the trigger runs in the same transaction, rolled-back changes never produce events.


## Creating a reactive future call

Every model with a database table gets a generated intermediate class that you extend. For example, if you have a `Trip` model, Serverpod generates `TripReactiveFutureCall`:

```dart
import 'package:serverpod/serverpod.dart';
import 'src/generated/protocol.dart';

class NotifyPassengersAboutTripConfirmation extends TripReactiveFutureCall {
@override
WhereExpressionBuilder<TripTable> get where =>
(t) => t.status.equals('Confirmed');

@override
Future<void> react(Session session, List<Trip> objects) async {
for (final trip in objects) {
// Send notifications to passengers
}
}
}
```

The `where` getter defines a condition that becomes a `WHEN` clause on the PostgreSQL trigger. Only changes matching this condition will create outbox entries. In the example above, the trigger only fires when the `status` column equals `'Confirmed'`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioning 'outbox' is an implementation details of no concern to our users.

Could we boil these two paragraphs down to the essentials? Perhaps something like:

The react method is your handler. It's called with every row that matches the condition. The where getter filters which changes reach the handler.


The `react` method is called with all matching rows from a single outbox scan. Rows are batched together for efficiency.

## Detecting column changes

Standard expressions filter on the new row's values, but sometimes you need to react when a specific column *changes*. The `hasChanged()` method generates a trigger condition that compares the old and new values of a column:

```dart
class OnSensorHeightChanged extends SensorReactiveFutureCall {
@override
WhereExpressionBuilder<SensorTable> get where =>
(t) => t.sensorHeight.hasChanged();

@override
Future<void> react(Session session, List<Sensor> objects) async {
// Handle sensor height changes
}
}
```

This produces the PostgreSQL trigger condition `OLD."sensorHeight" IS DISTINCT FROM NEW."sensorHeight"`.

:::info
When `hasChanged()` is used in the condition, the trigger is restricted to `UPDATE` operations only. This is because `OLD` row values are not available for `INSERT` triggers, and `NEW` values are not available for `DELETE` triggers.
:::

You can compose `hasChanged()` with other expressions using `&` (AND) and `|` (OR):

```dart
class OnCriticalSensorChange extends SensorReactiveFutureCall {
@override
WhereExpressionBuilder<SensorTable> get where =>
(t) => t.sensorHeight.hasChanged() &
(t.sensorTemperature.hasChanged() |
t.sensorTemperature > 100.0);

@override
Future<void> react(Session session, List<Sensor> objects) async {
// React when height changes AND (temperature changes OR exceeds 100)
}
}
```

## Code generation

After creating your reactive future call class, run code generation:

```bash
$ serverpod generate

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will update this to match the new serverpod start workflow in a different PR.

```

Your reactive future calls are automatically discovered and registered alongside regular future calls. No manual registration is needed.

## How it works

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many technical details here that the user probably does not need to concern themselves with.

Consider just saying something like for the whole ## How it works section:

How it works

Reactive future calls use a scan interval. The same interval as regular future calls, configured via futureCall.scanInterval. Serverpod polls for new entries at this interval, so handlers fire in near real-time, rather than instantly.

Rows that accumulate between scans are batched together and delivered to react in a single call. This is why react receives a List rather than a single object.


When the server starts, the following happens automatically:

1. PostgreSQL triggers are created (or replaced) for each registered reactive future call.
2. Orphaned triggers from previously registered handlers are dropped.
3. An outbox scanner starts polling for new entries.

When data changes in a watched table:

1. The trigger evaluates the `WHEN` condition.
2. If matched, the trigger inserts a row into the outbox table within the same transaction.
3. The outbox scanner picks up new entries on its next poll.
4. Entries are grouped by handler and dispatched to the `react` method.
5. Processed entries are deleted from the outbox.

:::info
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this names futureCall.scanInterval but doesn't link to the configuration page that documents it. A reader who wants to actually set the value has to navigate the sidebar manually.

Suggested change
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration.
The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. See [Configuration](configuration) for the full set of future-call options.

:::

## Transaction safety

Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this section talks about transaction commit/rollback semantics without linking to the transactions docs. A reader unfamiliar with session.db.transaction has to find it themselves.

Suggested change
Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees:
Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees (see [Transactions](../database/transactions) for the underlying API):


- If a transaction is rolled back, the outbox entry is also rolled back and `react` is never called.
- If a transaction is committed, the outbox entry is guaranteed to exist and will be processed.

```dart
// This will NOT trigger react — the transaction is rolled back
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});

// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
Comment on lines +113 to +119

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside session.db.transaction, the example inserts a row without passing the transaction object. In the transactions docs, DB ops must receive transaction: transaction to actually be part of the transaction; otherwise this example may commit independently and would trigger the reactive call, contradicting the comment. Update the insert to include the transaction parameter.

Suggested change
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
await Trip.db.insertRow(
session,
Trip(status: 'Confirmed'),
transaction: transaction,
);
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(
session,
Trip(status: 'Confirmed'),
transaction: transaction,
);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we really need this change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When fact-checking this, I find the suggestion by Copilot to be correct.

Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.

You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.

Comment on lines +113 to +119

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue in the committed-transaction example: Trip.db.insertRow(...) should receive transaction: transaction to ensure the insert is performed within the transaction being committed (consistent with the Transactions docs) and to make the example behavior correct.

Suggested change
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction);
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this change either

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we should apply it.

});
Comment on lines +111 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: em-dashes in code comments. The style guide discourages them in running text, and comments read as prose to the reader. Swap for periods or colons.

Suggested change
// This will NOT trigger react — the transaction is rolled back
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react — the transaction is committed
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
});
// This will NOT trigger react. The transaction is rolled back.
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
throw Exception('Something went wrong');
});
// This WILL trigger react. The transaction is committed.
await session.db.transaction((transaction) async {
await Trip.db.insertRow(session, Trip(status: 'Confirmed'));
});

```

## Migrations

When you add your first reactive future call, a new migration is needed to create the outbox table. Run:

```bash
$ serverpod create-migration
```

Reactive triggers are managed at runtime, not through migrations. They are created fresh each time the server starts, so schema changes from migrations won't cause stale trigger references.
Loading