Testing the limits of Supabase + Postgres in a serverless environment
Comparing Postgres performance on serverless environments vs traditional servers using Supabase and different ORM setups.
This post compares how Postgres performs in a serverless environment against a traditional server.
The database is hosted on Supabase, so the test also compares a direct ORM connection against Supabase’s JS client.
The four setups:
- Cloudflare Workers + Supabase JS
- Cloudflare Workers + Drizzle ORM
- Express + Supabase JS
- Express + Drizzle ORM
Assumptions going into the test
- A serverless environment is ephemeral, so it opens a new database connection on every request. That can become a bottleneck.
- Supabase’s JS client avoids this: it calls their REST endpoint, which sits behind a pool that already holds its connections and can handle a million at once.
- A traditional Node server opens the connection once and reuses it for every request.
- So which is faster: querying over a connection you already hold, or going through Supabase’s REST layer?
Let’s start by setting up the project.
- Create a new Supabase project and run it
supabase init && supabase startIf all goes well, the Supabase dashboard is available at http://127.0.0.1:54323
- Create a new table from the SQL Editor in the dashboard
CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT, email TEXT);
ALTER TABLE users DISABLE ROW LEVEL SECURITY;- Populate the table with 100,000 mock users
INSERT INTO users (name, email)SELECT 'User ' || gs AS name, 'user' || gs || '@example.com' AS emailFROM generate_series(1, 100000) AS gs;- Create the Express server
import express from "express";import { createClient } from "@supabase/supabase-js";import { drizzle } from "drizzle-orm/postgres-js";import postgres from "postgres";import { pgTable, serial, text } from "drizzle-orm/pg-core";
const app = express();const port = 3000;
const supabase = createClient( "http://127.0.0.1:54321", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0");
const client = postgres( "postgresql://postgres:postgres@127.0.0.1:54322/postgres");
const usersTable = pgTable("users", { id: serial("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull(),});export const db = drizzle(client, { schema: { usersTable, },});
app.get("/", (req, res) => { res.send("Hello World!");});
app.get("/supabasejs", async (req, res) => { try { const { data, error } = await supabase.from("users").select().limit(100); if (error) return res.status(400).send(error.message); res.json(data); } catch (e) { console.log(e); res.status(500).send(e.message); }});
app.get("/drizzle", async (req, res) => { try { const users = await db.query.usersTable.findMany({ limit: 100, }); res.json(users); } catch (e) { console.log(e); res.status(500).send(e.message); }});
app.listen(port, () => { console.log(`Example app listening on port ${port}`);});- Create the Cloudflare Worker
import { createClient } from "@supabase/supabase-js";import { drizzle } from "drizzle-orm/postgres-js";import postgres from "postgres";import { pgTable, serial, text } from "drizzle-orm/pg-core";
const usersTable = pgTable("users", { id: serial("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull(),});export default { async fetch(request, env, ctx): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/supabasejs") { const supabase = createClient( "http://127.0.0.1:54321", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", ); const { data, error } = await supabase.from("users").select().limit(100); if (error) return new Response(error.message, { status: 500 });
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json", }, }); } else if (url.pathname === "/drizzle") { const client = postgres( "postgresql://postgres:postgres@127.0.0.1:54322/postgres", );
const db = drizzle(client, { schema: { usersTable, }, }); const users = await db.query.usersTable.findMany({ limit: 100, }); return new Response(JSON.stringify(users), { headers: { "content-type": "application/json", }, }); } return new Response("Hello World!"); },} satisfies ExportedHandler<Env>;After starting both servers, four routes are available for testing.
- http://localhost:3000/supabasejs: Express using Supabase JS
- http://localhost:3000/drizzle: Express using Drizzle ORM
- http://localhost:8787/supabasejs: Cloudflare Worker using Supabase JS
- http://localhost:8787/drizzle: Cloudflare Worker using Drizzle ORM
Load testing the routes
The test script hits each route with autocannon.
import { stdout, stderr } from "node:process";import autocannon, { printResult } from "autocannon";
function print(result) { const printedResult = printResult(result); stdout.write(printedResult); return printedResult;}
const urls = [ "http://localhost:8787/supabasejs", "http://localhost:8787/drizzle", "http://localhost:3000/supabasejs", "http://localhost:3000/drizzle",];
async function loadTest(url) { return new Promise((resolve, reject) => { const instance = autocannon( { url, connections: 100, pipelining: 100, duration: 60, }, (err, result) => { if (err) { reject(err); } else { const printedResult = print(result); resolve({ result, printedResult, responseCounts }); } }, );
let completedRequests = 0; const responseCounts = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, other: 0, };
instance.on("response", (client, statusCode) => { completedRequests += 1; if (statusCode >= 200 && statusCode < 300) { responseCounts["2xx"] += 1; } else if (statusCode >= 300 && statusCode < 400) { responseCounts["3xx"] += 1; } else if (statusCode >= 400 && statusCode < 500) { responseCounts["4xx"] += 1; } else if (statusCode >= 500 && statusCode < 600) { responseCounts["5xx"] += 1; } else { responseCounts.other += 1; }
console.log(`Completed Requests for ${url}: ${completedRequests}`); console.log( `2xx: ${responseCounts["2xx"]}, 3xx: ${responseCounts["3xx"]}, 4xx: ${responseCounts["4xx"]}, 5xx: ${responseCounts["5xx"]}, other: ${responseCounts.other}`, ); }); });}
async function runTests() { const results = []; for (const url of urls) { console.log(`Running load test for ${url}`); try { const result = await loadTest(url); results.push(result); console.log(`Completed load test for ${url}\n`); } catch (error) { stderr.write(`Error during load test for ${url}: ${error.message}\n`); } } summarizeResults(results);}
function summarizeResults(results) { console.log("\nSummary of Results:\n"); results.forEach(({ result, printedResult, responseCounts }) => { console.log(`Results for ${result.url}:`); console.log(printedResult); console.log(`Response Codes Summary for ${result.url}:`); console.log( `2xx: ${responseCounts["2xx"]}, 3xx: ${responseCounts["3xx"]}, 4xx: ${responseCounts["4xx"]}, 5xx: ${responseCounts["5xx"]}, other: ${responseCounts.other}`, ); console.log("\n"); });}
runTests().catch(console.error);Results
Each route ran for 60 seconds with 100 connections and 100 pipelined requests per connection.
Conclusion
The biggest bottleneck is connection establishment in the serverless environment. Express + Drizzle, which opens one connection and reuses it, served 213k requests with a 2.7-second median latency and no errors. Cloudflare Workers + Drizzle, which reconnects on every request, served 30k requests with a 17-second median latency and 7k timeouts.
Going through Supabase’s pooled REST endpoint softens the penalty: Workers + Supabase JS finished with zero errors, though its median latency was still around 11 seconds.
The full source code is at bimsina/postgres-perf-test.
Log dumps
Results for CF Workers + Supabase:
| Stat | 2.5% | 50% | 97.5% | 99% | Avg | Stdev | Max |
|---|---|---|---|---|---|---|---|
| Latency | 1795 ms | 10858 ms | 11629 ms | 11682 ms | 10067.33 ms | 2468.93 ms | 11839 ms |
| Stat | 1% | 2.5% | 50% | 97.5% | Avg | Stdev | Min |
|---|---|---|---|---|---|---|---|
| Req/Sec | 570 | 752 | 901 | 1,031 | 900.52 | 79.41 | 570 |
| Bytes/Sec | 3.34 MB | 4.41 MB | 5.28 MB | 6.04 MB | 5.28 MB | 465 kB | 3.34 MB |
Req/Bytes counts sampled once per second.
Number of samples: 60
64k requests in 60.07s, 317 MB read
Response Codes Summary for http://localhost:8787/supabasejs:
2xx: 54031, 3xx: 0, 4xx: 0, 5xx: 0, other: 0
Results for CF Workers + Drizzle:
| Stat | 2.5% | 50% | 97.5% | 99% | Avg | Stdev | Max |
|---|---|---|---|---|---|---|---|
| Latency | 1089 ms | 17000 ms | 34263 ms | 36654 ms | 17420.1 ms | 10019.33 ms | 42703 ms |
| Stat | 1% | 2.5% | 50% | 97.5% | Avg | Stdev | Min |
|---|---|---|---|---|---|---|---|
| Req/Sec | 0 | 0 | 210 | 433 | 218.29 | 178.32 | 3 |
| Bytes/Sec | 0 B | 0 B | 1.08 MB | 2.31 MB | 1.13 MB | 929 kB | 17.6 kB |
Req/Bytes counts sampled once per second.
Number of samples: 60
11302 2xx responses, 1795 non 2xx responses
30k requests in 60.06s, 68 MB read
7k errors (7k timeouts)
Response Codes Summary for http://localhost:8787/drizzle:
2xx: 11302, 3xx: 0, 4xx: 0, 5xx: 1795, other: 0
Results for Express.js + Supabase:
| Stat | 2.5% | 50% | 97.5% | 99% | Avg | Stdev | Max |
|---|---|---|---|---|---|---|---|
| Latency | 105 ms | 10024 ms | 12764 ms | 13425 ms | 7111.44 ms | 4314.4 ms | 20199 ms |
| Stat | 1% | 2.5% | 50% | 97.5% | Avg | Stdev | Min |
|---|---|---|---|---|---|---|---|
| Req/Sec | 0 | 0 | 551 | 3,431 | 976.75 | 1,094.45 | 5 |
| Bytes/Sec | 0 B | 0 B | 1.57 MB | 6.23 MB | 2.12 MB | 2.07 MB | 9.69 kB |
Req/Bytes counts sampled once per second.
Number of samples: 60
19477 2xx responses, 39123 non 2xx responses
84k requests in 60.6s, 128 MB read
15k errors (15k timeouts)
Response Codes Summary for http://localhost:3000/supabasejs:
2xx: 19477, 3xx: 0, 4xx: 39123, 5xx: 0, other: 0
Results for Express.js + Drizzle:
| Stat | 2.5% | 50% | 97.5% | 99% | Avg | Stdev | Max |
|---|---|---|---|---|---|---|---|
| Latency | 2299 ms | 2715 ms | 4045 ms | 4304 ms | 2891.94 ms | 467.38 ms | 4636 ms |
| Stat | 1% | 2.5% | 50% | 97.5% | Avg | Stdev | Min |
|---|---|---|---|---|---|---|---|
| Req/Sec | 763 | 919 | 3,601 | 3,837 | 3,382.24 | 617.51 | 763 |
| Bytes/Sec | 4.6 MB | 5.54 MB | 21.7 MB | 23.1 MB | 20.4 MB | 3.72 MB | 4.6 MB |
Req/Bytes counts sampled once per second.
Number of samples: 60
213k requests in 60.03s, 1.22 GB read
Response Codes Summary for http://localhost:3000/drizzle:
2xx: 202910, 3xx: 0, 4xx: 0, 5xx: 0, other: 0
Combined Results Comparison
| Metric | CF Workers + Supabase | CF Workers + Drizzle | Express.js + Supabase | Express.js + Drizzle |
|---|---|---|---|---|
| Latency (ms) | ||||
| 2.5% | 1795 | 1089 | 105 | 2299 |
| 50% | 10858 | 17000 | 10024 | 2715 |
| 97.5% | 11629 | 34263 | 12764 | 4045 |
| 99% | 11682 | 36654 | 13425 | 4304 |
| Avg | 10067.33 | 17420.1 | 7111.44 | 2891.94 |
| Stdev | 2468.93 | 10019.33 | 4314.4 | 467.38 |
| Max | 11839 | 42703 | 20199 | 4636 |
| Req/Sec | ||||
| 1% | 570 | 0 | 0 | 763 |
| 2.5% | 752 | 0 | 0 | 919 |
| 50% | 901 | 210 | 551 | 3601 |
| 97.5% | 1031 | 433 | 3431 | 3837 |
| Avg | 900.52 | 218.29 | 976.75 | 3382.24 |
| Stdev | 79.41 | 178.32 | 1094.45 | 617.51 |
| Min | 570 | 3 | 5 | 763 |
| Bytes/Sec | ||||
| 1% | 3.34 MB | 0 B | 0 B | 4.6 MB |
| 2.5% | 4.41 MB | 0 B | 0 B | 5.54 MB |
| 50% | 5.28 MB | 1.08 MB | 1.57 MB | 21.7 MB |
| 97.5% | 6.04 MB | 2.31 MB | 6.23 MB | 23.1 MB |
| Avg | 5.28 MB | 1.13 MB | 2.12 MB | 20.4 MB |
| Stdev | 465 kB | 929 kB | 2.07 MB | 3.72 MB |
| Min | 3.34 MB | 17.6 kB | 9.69 kB | 4.6 MB |
| Total Requests | 64k in 60.07s | 30k in 60.06s | 84k in 60.6s | 213k in 60.03s |
| Total Data | 317 MB read | 68 MB read | 128 MB read | 1.22 GB read |
| Response Codes | ||||
| 2xx | 54031 | 11302 | 19477 | 202910 |
| 3xx | 0 | 0 | 0 | 0 |
| 4xx | 0 | 0 | 39123 | 0 |
| 5xx | 0 | 1795 | 0 | 0 |
| Other | 0 | 0 | 0 | 0 |
| Errors | 0 | 7k timeouts | 15k timeouts | 0 |