I lost an hour to this one before the cause clicked, and I'm writing it down because the symptom is genuinely confusing: a migration script that ran two queries kept dropping the second one - sometimes silently, sometimes with a Connection closed error - even though the exact same code worked perfectly inside a Bun.serve() server. The code looked right. The queries were correct. And yet the second one simply didn't run.
The difference isn't the SQL but the event loop, and once you understand that, the fix is immediate.
The Problem
Bun's native sql uses a connection pool internally, and the pool's idle connections don't register persistent I/O on the event loop. Long-lived handles like Bun.serve() keep the loop alive because they hold an open server socket, but an idle pool connection does not, which means that in a standalone script your first query runs and its promise resolves, the event loop sees no more pending I/O, the process exits, and the SQL connection is torn down mid-execution before the second query ever gets the chance to run.
The Fix
There are two clean options, and which one fits depends on what the script needs to do.
Option A - Keep the event loop alive with a dummy timer, then exit manually:
import { sql } from "bun";
const db = sql`mysql://user:pass@localhost:3306/mydb`;
const stay_alive = setInterval(() => {}, 2_147_483_647);
async function run() {
const users = await db`SELECT id, name FROM users`;
console.log(users);
const orders = await db`SELECT id, total FROM orders`;
console.log(orders);
clearInterval(stay_alive);
await db.end();
process.exit(0);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Option B - Use sql.reserve() to hold a dedicated, persistent connection:
import { sql } from "bun";
const db = sql`mysql://user:pass@localhost:3306/mydb`;
async function run() {
const conn = await db.reserve();
try {
const users = await conn`SELECT id, name FROM users`;
console.log(users);
const orders = await conn`SELECT id, total FROM orders`;
console.log(orders);
} finally {
conn.release();
await db.end();
process.exit(0);
}
}
run();
reserve() is the right call when you need transaction-level connection affinity, which is the case for anything wrapped in BEGIN / COMMIT, because it holds a single dedicated connection for the life of the block. The setInterval approach is simpler for fire-and-forget scripts that just run a few queries and quit, where you don't need that guarantee. Both workarounds become unnecessary the moment this code moves inside Bun.serve(), because the server socket holds the loop open for as long as the process runs and the problem simply doesn't arise.