---
name: postgrest-silent-failures
description: "Find and prevent the class of bug where a Supabase or PostgREST app returns success and does nothing. Load this skill when a save does not save but shows no error, when a scheduled job or sweep looks healthy but produces no work, when a user reports 'it will not save' or 'nothing happened' with a clean console, when a column renders as undefined or a fallback value, when debugging a 400 or a 300 from PostgREST, when writing any loop over customer or job records, when adding a cron function or a poller, and before trusting that a form, webhook or intake endpoint is still wired to anything after a rebuild. Also load it when reviewing supabase-js queries, because an update that matches no rows and a select that omits a column both come back as success. Verify by calling, never by reading - this skill is how."
license: MIT
metadata:
author: Anthony Limpert
organization: Clickflame
version: "1.0.0"
date: September 2026
abstract: A field guide to silent failure in PostgREST and Supabase applications, written from production outages. Covers zero row updates that return 200, columns used but not selected, stored timestamps that break URLs, ambiguous embeds, session defaults that resolve null on the service key, poison pill sweeps, and endpoints that quietly 404 after a rebuild. Includes the verification moves that catch each one.
---
# PostgREST Silent Failures
There is a family of bugs in PostgREST applications that never throws, never
logs, and never shows a red box. The request succeeds. The page renders. The
number has a confident format. And nothing happened.
Every pattern below comes from a production outage that ran for days or weeks
while every dashboard said fine. They share one cause:
**The error path produced a value that was indistinguishable from a legitimate
quiet result.** Zero. An empty list. No output. No change. Each one is a
plausible reading of a healthy system, so nobody looked.
The defense is the same every time and it is worth stating once, up front:
**verify by calling, not by reading.** Most of these were invisible in the
source and obvious on the first real invocation.
## An update that matches no rows returns 200
```js
await sb.from('vehicles').update(patch).eq('id', id)
```
With no `.select()`, this cannot tell a save from a no-op. PostgREST answers an
update that matched **zero rows** with a **200 and an empty list**, so `error`
is null and every screen carries on as though it worked.
A service advisor pressed save three times on a vehicle and it never saved, with
nothing on screen to say why. That row had `account_id = null`, the policy is
`account_id = current_account_id()`, null equals nothing, so the row was
invisible to the whole tenant and the update matched nothing.
**How to write it so it cannot lie:**
```js
const { data, error } = await sb.from('vehicles')
.update(patch).eq('id', id).select('id')
if (error) throw error
if (!data?.length) throw new Error(`nothing updated for id ${id}`)
```
Wrap that in a `mustUpdate()` helper and use it for every write that has to
land. Name the id in the error, because the next person to see it will be
looking at a toast, not a stack trace.
**When somebody says "it will not save" and there is no error, suspect RLS
invisibility before you suspect the form.** The one line check is
`select count(*) from
where account_id is null`.
## Session defaults resolve to null on the service key
The reason that row was orphaned is worth its own heading, because it manufactures
invisible rows at a steady rate and nothing reports it.
`vehicles.account_id` defaulted to `current_account_id()`, which reads the
**caller's session**. A server side job running on the service key has no
session, so the default resolved to `null`. Every other insert in that file
passed the tenant id explicitly and the vehicle insert did not. The job ticked
every three minutes. Twenty eight rows were orphaned before anyone noticed.
**A `current_account_id()` style default is not a safety net on any server side
insert. Always pass the tenant id explicitly.** The default exists for the
browser client and it is silently absent everywhere else.
Repair for what already leaked out:
```sql
update vehicles v
set account_id = c.account_id
from customers c
where c.id = v.customer_id
and v.account_id is null;
```
## A column used but not selected is undefined, not an error
PostgREST returns exactly the columns you named. A missing one is `undefined`,
so `a || b` quietly takes `b`, and the screen looks plausible.
A quotes list showed "closed" on all fifty quotes, including ones raised that
morning. The data was fine, the expiry column had 29 days left on it. The page's
`select()` simply never asked for that column, so the value was `undefined`, the
countdown took its fallback of `created_at`, which is always in the past, and
the day count went negative. The one screen whose entire job was to say which
quotes were still alive was reporting that every one was dead.
Two habits close this:
1. **Whenever a render reads `row.something`, check `something` is in that
query's `select()`** - especially where a `||` fallback would absorb the
miss. A sibling page reading the same table is not evidence; its query is a
different query.
2. **Make fallbacks sane defaults.** Falling back to `created_at` for an
*expiry* makes every row expired. The honest fallback was `created_at + 30
days`, which is the rule the business actually applies. A fallback that is
not a sane default converts a missing value into a confident wrong answer.
## A stored timestamp in a URL becomes a 400
Postgres spells a timestamp `2026-08-29T21:30:59.446007+00:00`. In a URL a `+`
decodes as a **space**, so interpolating a stored timestamp straight into a
PostgREST filter produces an invalid timestamp and a **400**.
A scheduled function did exactly that with a watermark it had read back from a
settings table. It failed every five minutes for three days, 589 times on the
worst day, and no inbound lead was answered in that window.
**Why nobody noticed, and this is the important half:** the function caught the
error, logged it, and returned 200. A scheduled function that fails quietly is
indistinguishable from one with nothing to do.
**And it works perfectly in testing.** `new Date().toISOString()` ends in `Z`
and has no `+`. The bug only appears once a real value has been written and read
back, which is to say only in production.
```js
const floor = new Date(Date.parse(stored)).toISOString()
```
Normalize every timestamp that came out of the database before it goes into a
URL. `encodeURIComponent` also works but does not fix a malformed stored value.
## HTTP 300 is an ambiguous embed, not a redirect
`PGRST201`. It means two foreign keys join the two tables, so PostgREST refuses
to guess which one a bare embed meant.
```
jobs_enquiry_id_fkey jobs.enquiry_id -> enquiries.id
enquiries_converted_job_fk enquiries.converted_job_id -> jobs.id
```
`jobs?select=...,enquiries(notes)` answers 300 forever. Name the constraint:
```
jobs?select=...,enquiries!jobs_enquiry_id_fkey(notes)
```
**What it cost:** a referral task returned "could not read the board (300)" on
all 146 runs between being switched on and being found. It had never once
succeeded. It went straight from off to broken, so no work was ever handed out,
and the board showed a task running on schedule.
Any pair of tables where one points back at the other is exposed. Check before
adding an embed, and confirm a suspect one in a single call rather than
reasoning about it. The bare form answers 300, the named form answers 200. That
takes ten seconds and settles it.
## A task with zero successes is a failing task
This is the check that would have caught the previous one on day one, and it
generalizes past PostgREST.
A scheduled task that has run 146 times and succeeded 0 times is not quiet. It
is broken. But every dashboard that counts *runs* rather than *outcomes* shows
it as healthy, and a task that went straight from off to broken has no better
past to be compared against.
```sql
select task, count(*) as runs,
count(*) filter (where ok) as wins,
max(started_at) as last_run,
min(note) as sample_note
from scheduled_runs
group by task
order by wins, runs desc;
```
**Read the zero row first.** Then read the tasks whose `note` is always the same
short word, because a task writing the same note on every tick is a task that is
not doing its job.
The related trap: **a task whose enable row was never inserted returns "off"
forever.** If your code starts with `const a = await automation(key); if
(!a?.enabled) return "off"`, then a missing row and a disabled row are the same
value. One task ran every fifteen minutes for weeks writing `off`, with no
switch anywhere in the product to turn it on, because its key had never been
added to the settings screen either. Two earlier debugging sessions blamed a
missing API key that was never the problem.
**A new scheduled task needs three things, not one:** the task file, its row in
the settings table, and its key in whatever list the settings screen renders
from.
## A failing scheduled function is retried, so one bad record multiplies
This is the part people do not expect. A scheduled function that returns 500 is
**retried by the platform**, so a single unprocessable record does not fail
once. It fails on every retry, of every tick, until somebody intervenes.
That turns an ordinary uncaught exception into an outage with a heartbeat. One
record whose model year could not be parsed produced **431 failures over 14
hours, three per tick**, and four real customers behind it in the queue never
got a price. The record itself was unremarkable: a motorhome described by engine
and transmission rather than by year, against a NOT NULL `year` column.
```js
for (const lead of leads) {
try { await handle(lead) }
catch (e) { await skip(lead.id, String(e?.message ?? e)); continue }
}
```
**Every loop over leads, jobs or customers needs a per item catch that records
the reason and continues.** If you already have a `skip(id, reason)` for
deliberate skips, failures should use it too, so both land in the same place
somebody already looks.
**The tell in the logs is a flat failure rate that matches a cron exactly.**
Three every three minutes, never varying, is not a flaky dependency. That
cadence names the function without your reading any code:
```sql
select toStartOfMinute(timestamp) as m, count(*)
from postgres_logs
where event_message ilike '%null value in column%'
group by m order by m;
```
Grouping `postgres_logs` by `event_message` is the cheapest health check this
stack has, and it finds outages nobody has reported yet.
## After a rebuild, callers post into a 404
When a server is rebuilt or cut over, callers keep posting to routes that only
the old deployment had. Every one of them fails quietly, because the caller's
error branch says something reassuring.
**The non-obvious half is CORS, not the missing route.** An `application/json`
body **always** triggers a preflight. If the server allows only some origins,
that preflight falls through to the router and answers **404**, which is
indistinguishable from a route that does not exist. So a site can have two
independent breaks producing one symptom, and fixing the route alone proves
nothing because the POST still never leaves the browser.
What that combination costs when it goes unnoticed: a "get a quote by text"
form returning 404 on every submission, hitting its catch branch, and telling
the customer *"That did not go through. You can also call."* No lead, no record
that anyone tried, **37 pages funnelling into it, dead for eight days.**
**Sweep for a fourth instance like this:**
```bash
grep -rn "api\.yourdomain\.com/api/" ./site-repos
grep -rhno '"/api/[a-z0-9/-]*"' server/routes/*.ts server/index.ts | sort -u
```
Compare the two lists. Anything in the first that is not in the second is dead.
**How to verify a public intake route without creating real work:** give the
endpoint a honeypot field, and fill it. A body of `{"bot":"x"}` returns
`{ok:true, skipped:"bot"}` before anything is written, while still exercising
routing, CORS and JSON parsing. To prove the whole path including the preflight,
drive the real form in a browser with the honeypot set.
Do not test with a fake phone number. Spam checks usually do not catch `555`,
and if a sweep picks the row up it will really try to text it.
## Two entry points into one handler
If a handler is reachable from both a webhook and a scheduled sweep, and nothing
keeps them apart, it will run twice.
A customer who texted while a sweep was in flight got **two model calls and two
sends**, 74 milliseconds to 5 seconds apart, some byte identical and some the
same question reworded because the two runs read the thread at different
moments. Sub-100ms gaps ruled out overlapping scheduler ticks. The race was
inside one tick.
**A module level `Set` is a real lock only while both callers live in one
process.** If the scheduler and the HTTP listener start from the same entry
file, it holds. The moment that runs on more than one replica it silently stops
working, and the lock has to move into the database as a claim taken with a
conditional update.
```js
const busy = new Set()
async function alone(key, fn) {
if (busy.has(key)) return 'held'
busy.add(key)
try { return await fn() } finally { busy.delete(key) }
}
```
Make the held count visible in the sweep's own note. A rising number of holds is
information; silence looks identical to having nothing to do.
**Before adding any "poke" route that calls an existing sweep, check whether
that handler already has a second caller.** This shape tends to appear several
times in one codebase, because each one seemed like a small optimization on the
day.
## The verification moves, collected
When you are handed one of these, reach for these before reading source:
- **Call it.** Most of these were invisible in the code and obvious on the first
real invocation. A green unit test over the parts is not a test of the whole.
- **Ask for outcomes, not runs.** Zero successes in a task's entire history is a
failing task, not a quiet one.
- **Group the Postgres logs by `event_message`.** A flat rate matching a cron
names the broken function for you.
- **Add `.select()` to any write that has to land**, and check the returned
length.
- **Read the query's `select()` next to the render that consumes it.**
- **Confirm a suspect embed or filter with one before-and-after call** rather
than reasoning about the URL.
- **Ask what the error path returns.** If it returns zero, an empty list, or
nothing at all, you cannot tell it from success, and that is the bug whatever
else is going on.
A `.catch(() => 0)` is a lie with a default. If a sweep, a poller or a monitor
cannot do its job, it has to say so somewhere a person is actually looking.
---
*Written from production incidents on a multi-tenant Supabase application, by
[Clickflame](https://clickflame.com). Companion skill: `supabase-rls-audit`, for
the same class of bug on the database side.*