September 2, 2026
Renting three phones at once: a feature the backend already had
We shipped multi-device rentals this week. The database had already enforced the limit for over a year — the only thing missing was a UI that let you click twice.
We shipped multi-device rentals this week — pick up to three phones at once, run them side by side, stop them all with one click. It was a small PR. It was small because we had already built it, eighteen months ago, and then forgot to let anyone use it.
The limit was never the backend's problem
Here's the thing that made this fun instead of annoying: when we went looking for where to
add the "max 3 devices" rule, we found it already there. rent_device has raised on a fourth
concurrent rental since Phase 9.1 — v_active_count >= 3 has been sitting in that function
for over a year. The rentals table has no per-user unique index, which means nothing in
Postgres was ever stopping a customer from holding several rows at once.
The entire feature that customers didn't have was a single line in DeviceCard: click a
phone, navigate to /session/[id]. First click won. There was no second click to have — the
click itself left the catalog page.
We went looking at the data before writing anything, and it turns out people had already been trying. Twelve production rentals in our history have start timestamps within the same minute for the same customer — someone opening the catalog in two tabs, or clicking fast enough that the network raced them there. The backend was fine with it. The UI just never gave them a shot.
What we actually built
Two new functions, both intentionally thin:
rent_devices(device_ids uuid[]) -- loops over rent_device, one row at a time
stop_all_rentals() -- loops over stop_rental for the caller's active set
Neither one reimplements billing. consume_credits and both of the reapers that watch for
runaway sessions never got opened for this change — they already operate per-rental, so
looping a rental-scoped RPC three times does exactly what looping it once did, three times.
The advantage of a loop over a bespoke bulk RPC is that a single PostgREST call still wraps
the whole thing in one transaction: request three phones, and either all three lock in or
none do. We got all-or-nothing semantics for free by not building anything clever.
We proved that on the actual rack: three rentals started through rent_devices came back
with an identical start_time down to the microsecond, and later, stop_all_rentals gave
all three an identical end_time. Same transaction, same instant, by construction.
The one place order matters
There's exactly one subtlety in the whole feature, and it's worth stating precisely because it's the kind of thing that looks like premature caution until you remember why it's there.
rent_device takes for update of d on the devices row it's renting — a row lock, held
until the transaction commits. If rent_devices looped over an unsorted list of device
ids, two customers renting overlapping sets in different orders could each grab one lock and
then wait forever on the other's. Classic deadlock, and one that would only show up under
real concurrent load, which is exactly the load this feature exists to handle.
The fix is one line — sort the ids before looping — and it needs a comment nobody should ever delete, because removing it wouldn't fail a single test. It would fail a Tuesday afternoon when two customers rent overlapping racks at the same second.
Two bugs this dragged into the light
Neither of these was caused by multi-rent. Both had clearly been possible for a while; we just hadn't gone looking until this feature made us stare at the catalog and the fleet state side by side.
A phone the catalog said was rentable, and wasn't. wipe_required is designed to
outlive status — a phone can be back to available while still owing a wipe, so the wipe
queue keeps offering it. The rent gate correctly refuses those phones. The catalog had
never heard of this distinction; it rendered raw status and called it a day. So customers
saw a phone marked Available that the backend would flatly refuse to hand them.
The fix was to stop letting every surface answer "is this device rentable" in its own words.
src/lib/deviceAvailability.ts is now the one function anything customer-facing calls —
effectiveDeviceStatus() reports an available + wipe_required phone as sanitizing, and
everything else (the badge, the rent button, the Available count, multi-select) reads off
that, not off the raw column.
Seventeen places where the database had quietly moved past the code. Running a drift
check we hadn't run in a while turned up fourteen functions that were live in production with
bodies that no longer matched what was committed to the repo. If anyone had ever replayed
schema.sql against prod believing it was current, nine admin and trust-and-safety functions
— including every step-up authorization gate — would have reverted to an older version. We
closed it the safe way: captured all fourteen live bodies verbatim as the new baseline,
rather than writing anything that could turn one typo into a real regression.
The actual lesson
The interesting work here wasn't the feature. rent_devices and stop_all_rentals are each
about ten lines. The interesting work was noticing that the backend had been ready to say yes
for over a year, and the only thing standing between customers and a feature they'd already
been trying to use by accident was a page that navigated away one click too early.