# Side effects (/docs/schema/hooks/side-effects)

---
title: Side effects
description: The `after*` write hooks run inside the transaction that made the write, so what you may safely do in one depends on whether the work can join that transaction or has to wait for the commit.
kind: guide
package: questpie
---

An email sent from a hook that later rolls back is an email about a row that
does not exist. QUESTPIE gives you one tool for each half of the problem. One is
for work that belongs to the transaction. The other is for work that must not
start until the commit lands.

## Which hooks are inside

`afterChange`, `afterDelete`, `beforePurge` and `afterPurge` run inside the
transaction that performed the write. Their `ctx.db` is that transaction, and
throwing from one rolls the row back along with everything joined to it.

`afterRead` runs after the transaction has closed. So do `onAfterCommit`
callbacks, which is the whole point of them.

## What joins the transaction

| Work                                                            | Joins |
| --------------------------------------------------------------- | ----- |
| Writes through `ctx.db`                                         | yes   |
| Nested CRUD through `ctx.collections` and `ctx.globals`         | yes   |
| `ctx.queue.<job>.publish()`                                     | yes   |
| `ctx.channels.publish()`                                        | yes   |
| Email, `fetch`, `ctx.realtime.notify()`, a second db connection | no    |

A job published inside a transaction either goes to the broker in that
transaction or lands in a dispatch table that drains after the commit. Either
way a rolled-back write never dispatches it. Awaiting the publish directly is
correct. Channels write their event to the same ledger transaction and wake
subscribers afterwards.

## What has to wait

`ctx.onAfterCommit(cb)` queues a callback on the outermost transaction. Nothing
runs until that transaction commits.

```ts title="src/questpie/server/collections/posts.ts"
.hooks({
	afterChange: async ({ data, operation, queue, onAfterCommit }) => {
		if (operation !== "create") return;

		// Joins the transaction.
		await queue.indexPost.publish(
			{ postId: data.id },
			{ idempotencyKey: `index-post:${data.id}` },
		);

		// Cannot, so it waits.
		onAfterCommit(async () => {
			await fetch("https://hooks.example.com/post-created", {
				method: "POST",
				body: JSON.stringify({ id: data.id }),
			});
		});
	},
})
```

Inside a transaction the callbacks run in order once it commits, and the
operation waits for them. Called outside any transaction, `onAfterCommit` runs
the callback immediately without awaiting it.

<Callout type="warn" title="Do not use it as a second chance to fail">
	A callback cannot roll anything back and cannot fail the request. Work that
	has to succeed belongs in a durable job, which retries.
</Callout>

The framework indexes search this way. Its own `afterChange` handler schedules
the index write inside `onAfterCommit`, so a document is only ever indexed from
data that survived.

## At most once

`onAfterCommit` is at-most-once. Your callback runs once, or it never runs.

The callback lives only in the memory of the process that ran the write. Nothing
records that it is owed. Two things lose it. It throws, and the error goes to
the console with nothing to retry it. Or the process dies between the COMMIT and
the callback finishing. That window is small, but it is not zero.

Neither case leaves a trace. A lost callback looks exactly like one that never
fired. So the search indexing above is best effort too. Lose that callback and
the row stays out of the index until the next write to it.

Use `onAfterCommit` for work you can afford to lose, or work a later pass can
reconcile.

## Related

- [Hooks](/docs/schema/hooks) for the stages themselves.
- [Jobs](/docs/code/jobs) for the queue that hooks publish to.
- [Channels](/docs/client/channels) for publishing a typed event from a hook.
