Why your Supabase mutations lie about their errors
Enforcing Error Contracts in Supabase JS: Preventing Silent Mutation Failures
Current Situation Analysis
The Supabase JavaScript client adopts a Result-style return pattern for database operations, returning { data, error } rather than throwing exceptions on failure. This design choice aligns with functional programming conventions but creates a significant cognitive mismatch for developers accustomed to standard asynchronous exception handling.
The core pain point is silent mutation failure. When a developer performs a mutation (insert, update, delete, upsert) and ignores the return value, the application continues execution as if the operation succeeded. Database constraints, permission violations, and network failures are swallowed silently. This leads to:
- Data Inconsistency: Downstream logic operates on stale or incorrect state because the mutation failed but was not handled.
- Masked Root Causes: Subsequent operations may fail, generating error messages that point to secondary issues while hiding the primary constraint violation.
- Testing Blindness: Integration tests may pass green because no uncaught exception is thrown, even though the database state is incorrect.
Evidence from production environments shows that "bare await" patterns on Supabase clients are a leading cause of elusive bugs. A mutation failing with a CHECK constraint violation (PostgreSQL error code 23514) can be ignored, allowing the code to proceed to a DELETE operation that fails on a foreign key constraint. The user sees the foreign key error, while the actual business logic violation remains hidden in the logs, if logged at all.
WOW Moment: Key Findings
The choice of error handling strategy directly correlates with error visibility and the accuracy of root cause diagnosis. The following comparison highlights the trade-offs between common implementation patterns.
| Strategy | Error Visibility | Root Cause Accuracy | Implementation Effort | Risk of Cascading Failure |
|---|---|---|---|---|
| Bare Await | None | Low (Masked) | Low | Critical |
| Explicit Destructure | High | High | Medium | Low |
| throwOnError() | High | High | Low | Low |
Why this matters: The "Bare Await" pattern offers the lowest implementation effort but introduces critical risk by guaranteeing zero error visibility. In contrast, throwOnError() provides high visibility and accuracy with minimal effort, making it the superior default for most transactional flows. Explicit destructuring remains valuable when granular error branching is required without try/catch overhead.
Core Solution
To eliminate silent failures, teams must enforce strict error contracts. This involves selecting an appropriate handling pattern and automating compliance via linting.
1. Strategy Selection
Pattern A: Explicit Destructuring Use this pattern when you need to inspect error codes or handle specific failure modes without interrupting control flow with exceptions.
interface MutationResult {
data: InventoryItem | null;
error: PostgrestError | null;
}
async function updateStock(
client: SupabaseClient,
sku: string,
quantity: number
): Promise<MutationResult> {
const { data, error } = await client
.from('warehouse_items')
.update({ stock_count: quantity })
.eq('sku_code', sku);
if (error) {
// Handle specific error codes or log details
if (error.code === '23514') {
console.error('Stock constraint violation:', error.message);
}
return { data: null, error };
}
return { data, error: null };
}
Pattern B: throwOnError() Use this pattern for standard transactional flows where any failure should halt execution and trigger centralized error handling. This aligns Supabase behavior with standard JavaScript exception conventions.
async function removeExpiredSession(
client: SupabaseClient,
sessionId: string
): Promise<void> {
try {
await client
.from('active_sessions')
.delete()
.eq('session_id', sessionId)
.throwOnError();
// Proceed only if deletion succeeded
await auditLog.record('session_removed', { sessionId });
} catch (err) {
// Centralized error handler receives the error
logger.error('Failed to remove session', { err, sessionId });
throw err;
}
}
2. Architecture Decisions
- Default to
throwOnError(): For 90% of use cases,throwOnError()reduces boilerplate and ensures errors propagate to error boundaries or middleware. It prevents the "silent continue" anti-pattern by design. - Reserve Destructuring for Granular Logic: Use explicit destructuring only when you need to branch based on error types (e.g., retry on network error vs. abort on constraint violation) or when integrating with systems that expect result objects rather than exceptions.
- Enforce Atomicity: Complex multi-step mutations should be wrapped in PostgreSQL functions or Supabase RPC calls with
SECURITY DEFINER. This ensures atomicity and provides a single point of error propagation, reducing the surface area for silent failures in the client.
Pitfall Guide
1. The Bare Await Anti-Pattern
Explanation: Awaiting a Supabase mutation without capturing the result. The error object is discarded, and execution continues.
Fix: Always destructure { error } or chain .throwOnError(). Implement linting to detect bare awaits on mutation methods.
2. Partial Destructuring
Explanation: Destructuring only data and ignoring error.
// β Error is still lost
const { data } = await client.from('users').insert(newUser);
Fix: Always destructure error alongside data, or use _ to explicitly ignore it if intentional (though rarely safe for mutations).
// β Explicit capture
const { data, error } = await client.from('users').insert(newUser);
3. Masked Constraint Violations
Explanation: A mutation fails due to a database constraint, but the error is ignored. A subsequent operation fails on a different constraint, and the UI displays the secondary error, misleading debugging efforts.
Fix: Fail fast. Use throwOnError() or immediate error checks to halt execution at the first sign of failure. Log the full error chain in production.
4. Testing Blindness
Explanation: Unit and integration tests pass because no exception is thrown, even though mutations fail. Tests may assert on data without checking error.
Fix: Update test assertions to verify error is null for successful operations. Mock database responses to simulate constraint violations and verify error handling paths.
5. Over-Reliance on UI Feedback
Explanation: Assuming the UI will surface errors. If errors are swallowed, the UI may show stale data or incorrect success states, leading to user confusion. Fix: Implement server-side logging for all mutation errors. Use error tracking services (e.g., Sentry) to capture unhandled errors, even in Result-style patterns.
6. Ignoring Network vs. Database Errors
Explanation: Treating all errors identically. Network errors may be transient and retryable, while database errors (e.g., unique violations) are permanent. Fix: Inspect error codes. Network errors often have specific codes or messages. Implement retry logic for transient failures and user feedback for permanent violations.
7. RPC Bypass Without Error Propagation
Explanation: Using Supabase RPC functions but not handling errors returned by the RPC call itself.
Fix: Treat RPC calls like any other mutation. Use throwOnError() or destructure results. Ensure RPC functions raise exceptions or return error objects consistently.
Production Bundle
Action Checklist
- Audit Codebase: Search for
await supabasepatterns and verify all mutations handle errors. - Configure Linting: Add ESLint rules to flag bare awaits on Supabase mutators.
- Refactor Critical Paths: Convert bare awaits to
throwOnError()or explicit destructuring in high-risk modules. - Update Tests: Ensure integration tests assert on
errorstates and simulate failure scenarios. - Implement Error Tracking: Configure error monitoring to capture Supabase errors, including those from Result patterns.
- Review RPC Functions: Verify all RPC calls propagate errors correctly and use atomic transactions where needed.
- Document Patterns: Establish team guidelines for error handling, recommending
throwOnError()as the default.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Standard CRUD Operations | throwOnError() |
Minimizes boilerplate; ensures errors propagate to error boundaries. | Low |
| Complex Transactions | PostgreSQL RPC / throwOnError() |
Ensures atomicity; single point of error handling; reduces client-side complexity. | Medium |
| Granular Error Handling | Explicit Destructure | Allows branching on error codes without try/catch overhead. | Medium |
| High-Risk Mutations | RPC with SECURITY DEFINER |
Enforces database-level constraints; prevents silent failures; auditability. | High |
| Legacy Code Refactor | Incremental Linting + throwOnError() |
Gradual adoption; reduces risk of regression; enforces consistency. | Low |
Configuration Template
Use this ESLint configuration to enforce error handling contracts. This rule detects bare awaits on Supabase mutation methods and requires developers to handle errors explicitly.
// eslint-plugin-supabase-errors.js
module.exports = {
rules: {
'no-bare-supabase-mutation': {
meta: {
type: 'problem',
docs: {
description: 'Prevent bare awaits on Supabase mutations to ensure errors are handled.',
category: 'Possible Errors',
},
schema: [],
},
create(context) {
const MUTATION_METHODS = new Set(['insert', 'update', 'delete', 'upsert']);
return {
AwaitExpression(node) {
const parent = node.parent;
// Flag if the await result is not used (ExpressionStatement)
if (parent.type === 'ExpressionStatement') {
const callExpr = node.argument;
if (callExpr.type === 'CallExpression') {
// Walk the callee chain to detect Supabase mutations
let current = callExpr.callee;
while (current) {
if (
current.type === 'MemberExpression' &&
MUTATION_METHODS.has(current.property?.name)
) {
context.report({
node,
message:
'Bare await on Supabase mutation detected. ' +
'Destructure { error } or use .throwOnError() to handle errors.',
});
break;
}
current = current.object;
}
}
}
},
};
},
},
},
};
Usage in .eslintrc.js:
module.exports = {
plugins: ['supabase-errors'],
rules: {
'supabase-errors/no-bare-supabase-mutation': 'error',
},
};
Quick Start Guide
- Install ESLint Plugin: Add the custom rule to your ESLint configuration to detect bare awaits immediately.
- Run Linter: Execute
eslint . --fixto identify violations. Review flagged files and applythrowOnError()or explicit destructuring. - Refactor Mutations: Update mutation calls to use the chosen pattern. Prioritize critical paths and high-traffic endpoints.
- Update Tests: Modify test suites to assert on
errorstates. Add negative test cases for constraint violations and network failures. - Deploy and Monitor: Deploy changes and monitor error tracking dashboards for new insights. Verify that root cause accuracy improves and silent failures are eliminated.
