Supabase Stress Testing: How to Find Limits Before Your Users Do

Supabase Stress Testing: How to Find Limits Before Your Users Do

Supabase is developer-friendly by design. The PostgreSQL database, authentication, real-time subscriptions, and storage work seamlessly from day one. That ease of setup can obscure the fact that Supabase has real limits — connection counts, query performance under load, RLS policy overhead, storage transfer caps.

Stress testing Supabase means finding those limits before your users generate the load that exposes them.

Supabase's Architecture and Where It Breaks

Understanding where stress typically manifests in Supabase:

Connection limits. Supabase uses PgBouncer connection pooling, but connection limits depend on your plan tier. Free tier: 60 connections. Pro: 200 connections (more with pgBouncer). Under high concurrent traffic, connection exhaustion is the first failure mode.

RLS (Row Level Security) performance. RLS policies are evaluated on every query. Complex policies — especially ones that make subqueries to check user permissions — add latency that compounds under load. A policy that adds 5ms per query adds 500ms for 100 concurrent users hitting the same endpoint.

Real-time subscriptions under load. If many users are subscribed to the same tables, change events fan out to all subscribers. High-frequency writes with many subscribers can overwhelm the real-time system.

Edge Functions cold starts. Supabase Edge Functions have cold start latency. Under load spikes (not sustained load), cold starts can cause initial requests to be slow.

Storage bandwidth. Supabase storage charges for bandwidth and has soft limits on free/pro tiers. A stress test involving file uploads can quickly exhaust bandwidth limits.

Setting Up Connection Pool Stress Testing

Connection exhaustion is the most common Supabase failure under load. Test it explicitly.

Using k6 with the Supabase client:

import { check } from 'k6';
import http from 'k6/http';

const SUPABASE_URL = 'https://your-project.supabase.co';
const ANON_KEY = 'your-anon-key';

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // ramp to 50 concurrent
    { duration: '5m', target: 50 },   // hold
    { duration: '2m', target: 150 },  // push above connection limit
    { duration: '5m', target: 150 },  // hold at stress level
    { duration: '2m', target: 0 },    // recovery
  ],
};

export default function() {
  const response = http.get(
    `${SUPABASE_URL}/rest/v1/products?select=id,name&limit=10`,
    {
      headers: {
        'apikey': ANON_KEY,
        'Authorization': `Bearer ${ANON_KEY}`,
      },
    }
  );
  
  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });
}

Watch for:

  • Response time spikes when you cross the connection limit
  • FATAL: remaining connection slots are reserved errors
  • Error rate increase as connections queue up

The fix for connection exhaustion: Enable PgBouncer transaction mode in your Supabase project settings, or upgrade to a higher tier. Transaction pooling lets thousands of application connections share a smaller pool of database connections.

Testing RLS Policy Performance

RLS policies can have significant performance overhead, especially for complex permission models.

Baseline without RLS:

-- Temporarily disable RLS on a table to measure baseline
ALTER TABLE products DISABLE ROW LEVEL SECURITY;

Run your load test with RLS disabled. Get baseline query times.

Re-enable RLS:

ALTER TABLE products ENABLE ROW LEVEL SECURITY;

Run the same load test. Compare query times. The difference is RLS overhead.

Common RLS performance problems:

-- Slow: correlated subquery evaluated per row
CREATE POLICY "users see own org data" ON products
  USING (
    org_id IN (
      SELECT org_id FROM org_members WHERE user_id = auth.uid()
    )
  );

-- Fast: join to a view or use a function with caching
CREATE POLICY "users see own org data" ON products
  USING (
    org_id = (SELECT current_user_org_id())
  );

Under load, the slow version makes N+1 subqueries. The fast version uses a cached function result. The difference is visible in stress test results.

Testing specific RLS policies:

// k6 test with authenticated users
export default function() {
  // Get auth token for test user
  const authResponse = http.post(
    `${SUPABASE_URL}/auth/v1/token?grant_type=password`,
    JSON.stringify({
      email: `testuser${__VU}@example.com`,
      password: 'testpassword123'
    }),
    { headers: { 'apikey': ANON_KEY, 'Content-Type': 'application/json' } }
  );
  
  const token = JSON.parse(authResponse.body).access_token;
  
  // Query with RLS active
  const response = http.get(
    `${SUPABASE_URL}/rest/v1/user_data?select=*`,
    {
      headers: {
        'apikey': ANON_KEY,
        'Authorization': `Bearer ${token}`,
      }
    }
  );
  
  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 200ms': (r) => r.timings.duration < 200,
  });
}

Run this with 100 virtual users, each with different auth tokens (simulating real users). Measure per-query latency under load.

Stress Testing Real-Time Subscriptions

Real-time subscriptions stress test differently — you need to simulate concurrent subscribers and frequent writes.

// Simulate 100 clients subscribed to a table
// While 10 clients write frequently
export const options = {
  scenarios: {
    subscribers: {
      executor: 'constant-vus',
      vus: 100,
      duration: '10m',
      exec: 'subscribe',
    },
    writers: {
      executor: 'constant-vus',
      vus: 10,
      duration: '10m',
      exec: 'write',
    }
  }
};

export function subscribe() {
  // WebSocket connection to Supabase real-time
  const ws = new WebSocket(`${SUPABASE_REALTIME_URL}/websocket`);
  // Subscribe to changes, hold connection, measure event latency
  sleep(60); // hold for 1 minute
  ws.close();
}

export function write() {
  // Frequent inserts that trigger real-time events
  http.post(
    `${SUPABASE_URL}/rest/v1/messages`,
    JSON.stringify({ content: 'test message', user_id: 'test' }),
    { headers: authHeaders }
  );
  sleep(1); // 1 insert per second per writer = 10 events/second total
}

Watch for:

  • Real-time event delivery latency increasing over time
  • Events being dropped or delayed
  • WebSocket connection errors

Testing Storage Under Load

If your application uses Supabase storage heavily:

export default function() {
  const imageData = open('./test-image.jpg', 'b');
  
  const response = http.put(
    `${SUPABASE_URL}/storage/v1/object/images/test-${Date.now()}.jpg`,
    imageData,
    {
      headers: {
        'apikey': ANON_KEY,
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'image/jpeg',
      }
    }
  );
  
  check(response, {
    'upload successful': (r) => r.status === 200,
    'upload time < 2000ms': (r) => r.timings.duration < 2000,
  });
}

Monitor cumulative bandwidth consumption during the test. Free and Pro tiers have bandwidth limits that can be exhausted in minutes during a load test.

What to Do With Stress Test Results

After running stress tests, you'll typically find one of these limiting factors:

Connection limit hit first: Enable pgBouncer transaction pooling, upgrade your Supabase plan, or add connection-limiting middleware that queues requests rather than opening unlimited database connections.

RLS overhead significant: Optimize your policies. Add indexes on columns used in RLS policy conditions. Use security definer functions with caching for complex permission lookups.

Real-time event backlog: Reduce write frequency for high-subscription tables, or implement client-side deduplication if many subscribers don't need every individual event.

Query performance degrades under load: Add the indexes PostgreSQL's query planner needs. Use EXPLAIN ANALYZE on your slow queries under load. Consider read replicas for analytics queries.

Continuous Behavioral Monitoring After Fixes

Stress testing finds the limits and guides fixes. Continuous behavioral monitoring verifies the fixes hold in production.

HelpMeTest behavioral tests run every 5 minutes against your production app. They verify that core user flows — which depend on your Supabase backend — still work correctly. If a query performance regression appears in production, you'll know within 5 minutes.

Try HelpMeTest — 14-day free trial, plain English test scenarios.

Read more

Start now free