all projects

case study 02

Flowboard

Live demo

Shared Kanban that stays correct when two people drag cards at once.

Flowboard screenshot 1
Flowboard screenshot 2
Flowboard screenshot 3
Flowboard screenshot 4
Flowboard screenshot 5
01 / 05

Kanban looks simple until two people drag cards in the same column at the same moment. Each move is a read followed by writes, and under Postgres's default READ COMMITTED two concurrent moves can both read the same order and then overwrite each other: a 500 from the unique constraint, or a card stranded at a temporary position. Flowboard is a shared board (owner plus invited members) built to get that part right.

  1. 01
    Dense integer positions instead of fractional indexing or LexoRank. Every column is always 0..n-1, enforced by a unique constraint and checkable in a test. The cost is rewriting the column on each move. At Kanban sizes that is cheap (measured below); I would switch to fractional keys for lists with thousands of items or many concurrent writers.
  2. 02
    A parent-row lock instead of SERIALIZABLE. Each position-changing write starts with SELECT … FOR UPDATE on its column (or board, for column reordering). Serializable isolation is also correct, but it surfaces conflicts as errors the client has to retry. A row lock makes Postgres do the waiting, so the code stays a plain transaction.
  3. 03
    Locks taken in id order. A cross-column move locks both columns sorted by id, so A→B and B→A at the same time cannot deadlock.
  4. 04
    409 instead of a silent revert. The card's column is re-read after the lock is held. If a teammate moved it first, the API returns 409 and the board refetches and says so.
  1. 01
    Race tests for crossing moves in both directions, concurrent column reorders, and a same-card stale move. That last test holds both column locks until both requests are queued, so the race happens on every run, and asserts exactly one 200 and one 409.
  2. 02
    A demo/no-locks branch removes only the FOR UPDATE clauses. Its CI run fails: crossing moves return 500 and both stale moves return 200 (a lost update). no-locks CI run
  3. 03
    A move on a 30-card column, including the access check, lock and full rewrite: p50 13 ms (p95 17 ms). Between two 30-card columns: p50 27 ms (p95 31 ms). Measured with pnpm bench over 200 runs, Postgres 16 in Docker.
  4. 04
    Full reasoning: docs/ordering.md. writeup
  1. 01
    I first claimed the same-column race test proved the locks were needed. It passes without them. The tests that actually fail without locks are the crossing-moves and stale-move tests, and the writeup now says so.
  2. 02
    No realtime transport: other members see changes on refetch, not live. That's a deliberate scope choice.
  3. 03
    Authorization is per board; there is no workspace or tenant layer above boards.
  • NestJS
  • PostgreSQL
  • Prisma
  • Next.js
  • TypeScript
  • Docker

Built on Invariant, my design languageSet in Sora, Rethink Sans and Geist Mono