React Native EAS Production Bug: Supabase RPC Silent Failures

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 uid parameter 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 OK or a 404 that 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-js abstract away the raw HTTP request. While this improves DX (Developer Experience), it hides the actual network payload, making it difficult to see that the uid sent 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 NULL instead 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).

Leave a Comment