Implementing Conditional Updates in PocketBase
To avoid race conditions when multiple clients attempt to update the same record simultaneously, we need some kind of concurrency control. These concurrency control mechanisms can be implemented with a concurrency primitive called conditional updates (also known as compare-and-swap). Most databases, as well as backend-as-a-service platforms, support this feature in one way or another, for example:
- DynamoDB has conditional put/deletes/updates via
condition-expression - Firebase RTDB has
runTransactionwith optimistic locking - Firestore has conditional updates
- Supabase allows setting
WHEREclause in updates - Redis has
SETNX - MongoDB has
findOneAndUpdate
With proper conditional updates, we can implement these on top of any database system (so you don't need to rely on external services like Redis or RabbitMQ):
- Distributed locks
- Distributed task queues
- Optimistic concurrency control
Unfortunately, in PocketBase, their Web APIs currently do not support conditional updates 1. I previously submitted a feature request for conditional updates. However, the maintainer has indicated that he's not intending to implement this feature in the near future, suggested a few alternatives (which will be discussed here), and have since deleted the issue 2. Thankfully, the feature request is now tracked on the roadmap for future reevaluation.
This note discusses implementation approaches (both failed and working) to achieve conditional updates in PocketBase, despite the lack of built-in support.
Problem with naïve approach: Race conditions
Consider a distributed task queue use case. Let's assume that there are 100 tasks to be done. There are 10 workers trying to pick up tasks concurrently. Each worker tries to pick up a task by looking for an unassigned task, and updating the task to assign it to themselves, before working on it. For simplicity, a task is either unclaimed or claimed by a worker.

With this design, a worker can be implemented like this:
// ⚠️ Warning: Naïve implementation, suffers from race conditions!
// Keep track of how many tasks have been done
let tasksDone = 0
// The entry point for each worker
async function runWorker(workerId) {
while (await workerLoopIteration(workerId)) {}
}
// One iteration of the worker loop
async function workerLoopIteration(workerId) {
// (1) Find an unclaimed task
const task = await pb
.collection('tasks')
.getFirstListItem("status = 'unclaimed'", { requestKey: null })
.catch(() => null)
if (!task) {
console.log(`[Worker ${workerId}] No more unclaimed tasks, exiting`)
return false
}
// (2) Claim it to work on that task
console.log(`[Worker ${workerId}] Claiming task`, task.id)
const updated = await pb
.collection('tasks')
.update(
task.id,
{ status: 'claimed', worker: workerId },
{ requestKey: null }
)
console.log(`[Worker ${workerId}] Claimed task`, updated.id)
// (3) Work on that task...
tasksDone++
await new Promise(process.nextTick)
return true
}
// Simulate 10 workers running concurrently
const workerIds = Array.from({ length: 10 }, () => crypto.randomUUID())
await Promise.all(workerIds.map((id) => runWorker(id)))
console.log('Total tasks done:', tasksDone)Since there are 100 tasks in total, regardless of how many workers are running concurrently, we'd expect that the total number of tasks done will always be 100…
But this is what actually happens:
+ Expected: "Total tasks done: 100"
- Actual: "Total tasks done: 661" ❌There is a sixfold increase in the number of tasks done. This is due to a race condition: During step (1), multiple workers may read the same unclaimed task before any of them updates it in step (2). As a result, multiple workers may end up claiming and working on the same task, resulting in duplicate work.
- Even without the race condition, ALL of the examples in this note are NOT good ways to implement a distributed task queue; it is unsafe and inefficient. However, it is intentionally written this way because (1) it makes race conditions more likely to happen, which is the focus here, and (2) it is simpler to understand.
- Re. unsafe: There is no guarantee that a worker will complete a task after claiming it. If a worker crashes after claiming a task but before completing it, that task will remain claimed indefinitely and will never be picked up by other workers. A more robust implementation would include timeout mechanisms (i.e. using leases instead of locks), retries, and failure handling. By the way, really recommend the How do you cut a monolith in half? article.
- Re. inefficient: If 10 workers start at the exact same time, they would all read the same unclaimed task first, resulting in 9 wasted attempts to claim the same task. One improvement is to query multiple unclaimed tasks at once and then randomly pick one to reduce contention. A further improvement is to let the database pick and assign an unclaimed task to you in one go, further reducing the time gap between (1) and (2).
- For an example of a better way of implementing a task queue atop a database, I recommend reading Do You Really Need Redis? How to Get Away with Just PostgreSQL. While it is written for PostgreSQL, the same principles can be applied to SQLite and/or PocketBase with some adjustments.
Unsuccessful workaround: Using API rules
One way to mitigate this issue is to leverage API rules to restrict updates based on the current state of the record as suggested in this discussion. For example, this rule only allows transitioning a task from unclaimed to claimed status:
@request.body.status='claimed' && status='unclaimed'
The worker will have to be modified to handle the failed update attempt (results in 404 error):
// (2) Claim it to work on that task
console.log(`[Worker ${workerId}] Claiming task`, task.id)
const updated = await pb
.collection('tasks')
.update(
task.id,
{ status: 'claimed', worker: workerId },
{ requestKey: null }
)
// 👇 If API rule rejects the update, treat it as someone else claimed the task
.catch((err) => {
if (err.status === 404) return null // someone else claimed it
throw err
})
// 👇 Don't work on the task if claiming it failed
if (!updated) {
console.log(`[Worker ${workerId}] Failed to claim task`, task.id)
return true // try again
}
console.log(`[Worker ${workerId}] Claimed task`, updated.id)+ Expected: "Total tasks done: 100"
- Actual: "Total tasks done: 374" ❌While this approach reduced the number of duplicate claims, it does not completely eliminate them. This is due to the race condition in PocketBase itself. The API rule is checked separately from the record update operation. Thus, two workers can still pass the API rule check simultaneously before either of them updates the record.
Working solution 1: Using API rules + Batch Web API
The maintainer noted that the Batch Web API performs multiple operations in a single database transaction and further clarified that “the API rules of the collection(s) will be also checked as part of the transaction”.
We can exploit this behavior to implement conditional updates by combining the API rule and the Batch Web API. The worker code is modified as follows:
// (2) Claim it to work on that task
console.log(`[Worker ${workerId}] Claiming task`, task.id)
// 👇 Use the Batch API instead of the normal Update API.
const batch = pb.createBatch()
batch
.collection('tasks')
.update(task.id, { status: 'claimed', worker: workerId })
const result = await batch.send({ requestKey: null }).catch((err) => {
if (err.response?.data?.requests?.[0]?.response?.status === 404) {
return null // someone else claimed it
}
throw err
})
if (!result) {
console.log(`[Worker ${workerId}] Failed to claim task`, task.id)
return true // try again
}
console.log(`[Worker ${workerId}] Claimed task`, updated.id)This time, the output is correct and there is no race condition:
Total tasks done: 100 ✅
However, there are a few caveats to this approach:
- The batch API has to be explicitly enabled and comes with performance implications.
- The API Rules are ignored when the action is performed by an authorized superuser. Thus, this approach only works if the workers are authenticated as normal users with the appropriate permissions. (You can create an auxilirary Auth collection for the workers if needed.)
Working solution 2: Custom endpoints using pb_hooks
PocketBase supports adding JavaScript files (*.pb.js) to pb_hooks directory to extend its behavior. This includes:
- Setting up new routes with
routerAdd - Working with collection records inside a transaction using
$app.runInTransaction, respecting the configured API rules by invoking$app.canAccessRecord.
Here is an example of a hook that implements a custom /api/tasks/claim endpoint which claims a task inside a transaction:
// @ts-check
/// <reference path="../pb_data/types.d.ts" />
routerAdd('POST', '/api/tasks/claim', (e) => {
const body = e.requestInfo().body
const taskId = body.taskId
const workerId = body.workerId
if (!taskId || !workerId)
return e.json(400, { error: 'taskId and workerId required' })
let responseFn = () => e.json(500, { error: 'internal server error' })
$app.runInTransaction((txApp) => {
const tasks = txApp.findCollectionByNameOrId('tasks')
const task = txApp.findRecordById(tasks.id, taskId)
if (!task) {
throw new Error('task not found')
}
// Check authorization using e.app
if (!e.app.canAccessRecord(task, e.requestInfo(), tasks.updateRule)) {
responseFn = () => e.json(403, { error: 'forbidden' })
return
}
// Compare-and-swap
if (task.get('status') !== 'unclaimed') {
responseFn = () => e.json(409, { error: 'task already claimed' })
return
}
task.set('status', 'claimed')
task.set('worker', workerId)
txApp.save(task)
responseFn = () => e.json(200, task)
})
return responseFn()
})The worker code is modified to call this new endpoint using pb.send():
// (2) Claim it to work on that task
console.log(`[Worker ${workerId}] Claiming task`, task.id)
// 👇 Call the custom claim endpoint
const updated = await pb
.send('/api/tasks/claim', {
method: 'POST',
body: { taskId: task.id, workerId },
requestKey: null,
})
.catch((err) => {
if (err.status === 409) return null // someone else claimed it
throw err
})
// 👇 Don't work on the task if claiming it failed
if (!updated) {
console.log(`[Worker ${workerId}] Failed to claim task`, task.id)
return true // try again
}
console.log(`[Worker ${workerId}] Claimed task`, updated.id)Although more code is required, it works correctly even if the workers are authenticated as superusers, because our endpoint directly enforces the atomicity using a transaction without relying on API rules.
Footnotes
The proposal suggested two options: (a) adding a
?filter=...query parameter to the Update record endpoint which works similarly to the existing List/Search records endpoint, or (b) adding an Assert record value endpoint which fails if the record value does not match the expected value to be used as part of the Batch Web API (in which multiple operations are performed in a single database transaction), similar to how JSON Patch works. ↩Per the FAQ and discussion, PocketBase is a hobby non-commercial project built for the maintainer's own use cases. Thus, feature requests may not be prioritized unless they align with the maintainer's personal needs. ↩