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 start
If 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 email
FROM 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 |