launch-quickly

Scaling

What this stack will hit first, roughly when, and what to do about it — with the honest note about which parts are measured and which are reasoned.

No product built on this template is carrying production load yet, so this page is reasoning about the architecture rather than reporting from it. Every claim is derived from a design decision you can go read; none is derived from a graph. Where a number appears it is an order of magnitude, not a benchmark. Treated as a map of what to watch, it is useful. Treated as a promise, it is not.

What you will hit, in order

Roughly the order these become a problem for a normal B2B product. Each one has a trigger you can measure rather than a feeling you can have.

#limittrigger
1Offset pagination degradesa list routinely past a few thousand rows per tenant, or count(*) in slow queries
2ilike search stops being adequatesearch feels slow, or you want ranking
3Connection limits under serverlessintermittent "too many connections"
4Row-level security costs a plana query is fast in psql as owner and slow in the app
5One database for everythinganalytics queries interfering with request latency

1. Offset pagination

The decision was made knowingly: offset gives jumpable page numbers, shareable URLs and a total count, all of which a starting product needs more than it needs keyset stability.

It degrades because OFFSET n makes the database walk and discard n rows, and because count(*) over a large filtered set is not free.

When to change it: page 3 visibly skipping or repeating rows under active insertion, or count(*) appearing in your slow query log. Not before.

What changing costs: Paginated<T> is the only thing callers see, so the strategy can change without touching call sites. That was the point of the type.

The template ships ilike matching. It is honest about being a starting point: it cannot rank, and it scans.

When to change it: as soon as search results need an order more meaningful than "whatever the index returned", or a search on a large table shows up as slow. Postgres full-text with a tsvector column and a GIN index is the next step and stays inside the same database.

3. Connections

This is the one most likely to surprise you, because it is a deployment property rather than a code property.

Serverless functions scale horizontally, each holding connections, and Postgres has a hard connection ceiling. The template expects a pooler in front — and the tenancy design depends on the pooler's mode.

set_config(..., true) is transaction-scoped. Under session pooling it is not scoped the way the policies assume, and one tenant's setting can leak into another tenant's query. That makes pooler mode part of the security model, not a performance tuning knob.

What to do: use transaction-mode pooling. Verify it rather than assume it — this is the single most consequential deployment setting in the stack.

4. Row-level security and query plans

RLS applies a policy predicate to every query, and the planner has to account for it. The template's tables carry a composite index on (organization_id, created_at) and the generated queries still write the explicit tenant filter even though the policy would enforce it anyway — not for safety, for the index.

The symptom to recognise: a query that is fast in psql as the owner role and slow through the app. That is almost always the policy predicate, and the fix is usually an index that leads with organization_id.

If you add a table by hand rather than through the generator, this is the part to copy carefully.

5. One database

Everything lives in one Postgres — application tables, audit log, AI usage events, job records. That is correct for a long time and stops being correct when analytics-shaped queries start competing with request-path queries.

When to change it: long-running reporting queries visibly affecting request latency. A read replica is the usual next step, and the withTenant seam is where you would route to it.

What the template already does about scale

Worth knowing so you do not solve these twice:

  • Audit writes are fire-and-forget, deliberately. An audit-write failure must not turn a succeeded action into a reported failure. The gap that leaves is one call's worth.
  • AI usage metering happens after the stream ends, for the same reason.
  • Jobs run outside the request through Inngest, with retries and idempotency as documented patterns rather than as things you invent under pressure.
  • Rate limiting is on every public action by type, because .public() does not compile without .rateLimit().
  • Caching is a typed registry, so a stale surface after a mutation is a type error rather than a silent bug.

What this page cannot tell you

Where your product breaks. The five limits above are properties of the stack; the one that actually bites will be a property of your access pattern.

The most useful thing to do early is unrelated to any of them: make sure the request-id propagation and structured logging that ship here are actually going somewhere you can query. Every item on this page is easier to diagnose with logs you can filter and considerably harder without.

When a real product has crossed one of these thresholds, this page should be rewritten from the graphs. Until then it says what it is.

On this page