Summary
A critical production bug was identified where a Supabase RPC (Remote Procedure Call) function successfully executed in the SQL Editor but failed silently in a React Native EAS production build. The function was designed to increment a counter via SECURITY DEFINER, but despite the user being authenticated and the parameters being valid, the database state remained unchanged. Crucially, no errors were surfaced in the client-side try/catch block and no errors appeared in the PostgREST logs, leading to a “silent failure” scenario.
Root Cause
The root cause was a silent serialization mismatch between the JavaScript client and the PostgREST interface, specifically triggered by how the EAS/Hermes engine handles object property ordering or type coercion compared to the development environment.
- Silent Type Mismatch: The
uidparameter sent from the React Native client was being interpreted as a generic object or a string with invisible whitespace/formatting due to the production minification process. - PostgREST Routing: When the payload structure does not strictly match the expected PostgreSQL function signature, PostgREST may sometimes fail to route the request to the specific function, resulting in a
200 OKor a404that the Supabase client library misinterprets if the response body is empty. - Minification/Obfuscation: In EAS production builds, the Hermes bytecode compiler and Terser minifier can alter the way object keys are passed to the underlying fetch call, leading to a mismatch in the expected JSON schema required by the RPC.
Why This Happens in Real Systems
In distributed systems, the “Contract” between the client and the server is fragile.
- Environment Divergence: Development environments (Expo Go) use different JS engines than production environments (Hermes/EAS). A bug that is “invisible” in Dev becomes “persistent” in Prod.
- The “Silent Success” Fallacy: Many API wrappers are designed to return a successful response if the HTTP status is in the 2xx range, even if the payload sent resulted in a no-op (no operation) at the database level.
- Abstraction Leaks: High-level SDKs like
supabase-jsabstract away the raw HTTP request. While this improves DX (Developer Experience), it hides the actual network payload, making it difficult to see that theuidsent to the server was actually{ "uid": "123" }instead of"123".
Real-World Impact
- Data Integrity Loss: Users believe they are performing actions (incrementing counts, submitting forms) that are never persisted.
- Degraded User Trust: Since no error is thrown, the UI remains in a “success” state, leading to confusion when users refresh and see their data is missing.
- Increased MTTR (Mean Time To Recovery): Because the logs show no errors, engineers spend hours debugging the Database and RLS policies when the fault actually lies in the Client-side serialization.
Example or Code (if necessary and relevant)
To debug this, we must bypass the abstraction and inspect the raw network traffic or use a more robust logging mechanism for the RPC response.
const debugRpcCall = async (userId) => {
console.log('Attempting RPC with UID:', userId, 'Type:', typeof userId);
const { data, error, status, statusText } = await supabase.rpc('increment_translation_count', {
uid: String(userId),
});
if (error) {
console.error('RPC Error:', error);
return;
}
// In silent failures, the error object might be null,
// but the status or data might indicate a mismatch.
if (status !== 200 || !data) {
console.warn(`Unexpected Response: Status ${status} ${statusText}`);
console.log('Received Data:', data);
} else {
console.log('Success:', data);
}
};
How Senior Engineers Fix It
Senior engineers move from assuming success to verifying the contract.
- Strict Type Casting: Never trust the client-side type. Explicitly cast parameters (e.g.,
String(userId)) before passing them to the SDK to ensure minification doesn’t alter the primitive type. - Enhanced Observability: Implement Sentry or LogRocket to capture the actual network request/response bodies in production.
- Defensive Database Design: Modify the PostgreSQL function to return the updated row or a specific status code. If the function returns
NULLinstead of the expected value, the client can explicitly trigger an error. - Contract Testing: Use tools to ensure that the JSON schema produced by the mobile client matches the expected arguments of the PostgreSQL function.
Why Juniors Miss It
- Reliance on
try/catch: Juniors assume that if a function doesn’t throw an exception, the operation was successful. They miss the fact that logical failures are not runtime exceptions. - The “Works on My Machine” Trap: They assume the Expo Go environment is an identical proxy for the EAS production build.
- Debugging the Wrong Layer: When a write fails, a junior’s first instinct is to check the Database Permissions (RLS) or the SQL Syntax, whereas a senior looks at the Data Transport Layer (The Bridge).