How we work · 2026
Published · 4 min read
The scarcest thing we own is a database connection
On tunyt the resource that has hurt us most isn't CPU, memory or instances. It's database connections. Why Postgres makes them scarce, the one equation that explains every rule we have, and four decisions that fall out of it.
When people talk about scaling a backend, they talk about CPU, memory and instances.
On tunyt, the resource that has hurt us most is much smaller: database connections. Once I started looking at the whole system through that one lens, a lot of arguments stopped being opinions and turned into arithmetic.
Why connections are scarce
Postgres gives every connection its own server process. That's great for isolation, but it makes connections heavy. Each one costs memory on the database server, and past a certain number Postgres spends more effort managing connections than doing your work.
So our pool is small on purpose, and every instance of the API and every worker shares one fixed budget. When the budget is used up it doesn't matter how fast your code is, because requests wait in line.
The one equation
connections in use ≈ requests per second × seconds each request holds a connection
That's Little's law. You can lower the rate, or you can lower how long each request holds a connection. Every decision below is really about the second one.
Decision 1: never hold a connection while waiting on someone else
A transaction holds a connection from the moment it opens until it commits. Call an outside service inside it and you hold that connection for the whole round trip. For us, outside services means payments, SMS, email and LLM providers.
Run the equation. If a payment call normally takes 300 ms, that's already a long hold. On a day the provider takes three seconds, every request doing this holds a connection ten times longer, so you need ten times the connections for the same traffic. The pool fills, and requests that have nothing to do with payments start queueing too. At that point your uptime is partly decided by someone else's latency, and I'm not willing to give that away.
// Don't: the connection is held for the provider's whole latency.
await db.$transaction(async (tx) => {
const order = await tx.order.create({ data });
await payments.capture(order.id); // slow, and outside our control
await tx.order.update({ where: { id: order.id }, data: { paid: true } });
});
// Do: write intent, call out, then record the result.
const order = await db.order.create({ data: { ...data, status: "PENDING" } });
const result = await payments.capture(order.id);
await db.order.update({ where: { id: order.id }, data: { status: result.ok ? "PAID" : "FAILED" } });
This has a real cost. The second version introduces a new state, PENDING, and now you have to handle orders that get stuck in it. That's more code and more thinking. I'll take that trade every time, because a stuck order is a problem for one person and a full pool is a problem for everyone.
Decision 2: every list query has a ceiling
An unbounded findMany was marked P0 in our audit, the highest severity we have. It wasn't slow on the day we found it, and that's exactly why it was P0.
The danger of an unbounded query isn't today. It's the day the table grows, when it pulls every row into memory in one go and holds a connection the whole time. Tables grow fastest on your busiest days, so it goes off at the worst possible moment.
Now every list endpoint paginates, and every internal fetch has an explicit take. The number depends on the query. There is always a number.
Decision 3: ask once, not N times
Our payment flow needs several tickets at once. Looking them up one by one in a loop would be N round trips and N separate turns on the pool, for something the database can answer in one. So it asks once.
// One query for every id, then a Map to put them back in order.
const tickets = await db.ticket.findMany({ where: { id: { in: ids } } });
const byId = new Map(tickets.map((t) => [t.id, t]));
const ordered = ids.map((id) => byId.get(id));
The Map matters. IN doesn't promise to return rows in the order you asked for, and code that quietly assumes it does will pass every test and lie to you in production.
Decision 4: an index ships with its query
Any new filter has to be covered by an index before it ships. If the index is missing, it goes in the same pull request, not "later". A query without an index holds its connection for as long as the scan takes, which puts you straight back into the equation.
On high-write tables we build it with CREATE INDEX CONCURRENTLY. It's slower and can't run inside a transaction, but it doesn't block writes while it builds.
Why write any of this down
None of these rules is clever, and most backend engineers have heard all of them. They're written down on tunyt because a team of four, building a mobile app, a web app and the AI infrastructure at the same time, doesn't get to remember everything.
The fix was never more hardware. It was to stop wasting the one thing we can't simply buy more of.