Skip to content

perf: run functions in Dublin and stop re-querying tenant data per render - #5

Merged
Asapteejo merged 1 commit into
mainfrom
perf/eu-region-and-query-dedupe
Sep 15, 2026
Merged

Asapteejo merged 1 commit into
mainfrom
perf/eu-region-and-query-dedupe

Conversation

@Asapteejo

Copy link
Copy Markdown
Owner

Problem

Production functions ran in iad1 (Washington) while Supabase is in eu-west-1 (Ireland), so every query crossed the Atlantic. x-vercel-id on every production response read cpt1::iad1::….

Measured warm TTFB on tenant pages was 7.2–9.3s, against 21–29 queries per request — roughly 300ms per query. A US↔Ireland round trip is ~75ms; the rest comes from queries running one at a time, because DATABASE_URL uses connection_limit=1, which serializes every Promise.all in the app.

Page Prod TTFB (warm, 3 runs) Queries/request
tenant / 9.2–9.3s 29
tenant /properties 8.1–8.3s 26
tenant /contact 7.5–7.6s 21
tenant /about 7.2–7.5s 22
estateos.tech/ 2.3s 5

Client-side delivery was never the problem: all JS/CSS/fonts/images finish within ~1.3s, while the HTML document alone took ~9.7s.

Changes

  • Pin functions to dub1 (vercel.json) — same region as the database.
  • Per-request dedupe with React cache(): the layout, generateMetadata, the public shell and the page each resolved tenant context, branding and published site content separately (the company lookup ran 4x per render, branding 3x, site content 2x).
  • Cross-request caching for published branding and published site content (60s) and the marketer leaderboard (5min), keyed and tagged by companyId. Both publish routes already call revalidateTag('tenant-presentation:<companyId>').

Measured prod-equivalent queries per request (local Prisma trace, dev-only queries excluded):

Page Before After dedupe After caching
tenant / 29 20 10
tenant /about 20 10 5
tenant /properties 26 17 7
tenant /contact 21 9 6

Measured result (preview in dub1 vs production in iad1, same database)

Page Before (iad1) After (dub1) Change
/ platform 2.20s median 0.85s median −61%
/api/readyz 1.63s median 0.83s median −50%

Tenant pages could not be measured on the preview: tenant resolution is host-based and a preview URL cannot claim a tenant's custom domain. They are expected to improve far more than the platform page (29→10 queries plus the region change), to be confirmed against production after merge.

Tenant isolation

Cache keys and tags all include companyId, and React cache() is request-scoped.

The cached leaderboard is computed with a context carrying only companyId — never the viewer's. findManyForTenant skips the company filter entirely when isSuperAdmin is true, so a super admin browsing a tenant's public site previously got a leaderboard aggregated across all companies; caching the viewer's result would have stored that under the tenant's key and served it to every visitor. This also fixes that pre-existing leak.

The admin draft branding read stays uncached across requests: saving a draft does not revalidate the tag, so a shared cache would show operators stale drafts.

Notes

  • unstable_cache rather than "use cache": the latter requires Cache Components enabled app-wide, which changes how every dynamic page renders. The bundled Next 16 docs name unstable_cache as the supported path without it, and dashboard-shell.tsx already uses this pattern.
  • These pages cannot be made static per tenant — tenant resolution reads headers()/cookies(), so they are dynamic by construction. Data-layer caching is the available lever.
  • Still to do, not in this PR: production DATABASE_URL uses connection_limit=1. Raising it to 5 should compound with this change by letting parallel queries actually run in parallel.
  • This branch also carries the previously uncommitted site-content-service.ts migration-drift handling (the unavailable fallback), as it lives in the same file.

Verified: npm run check green (535 tests, typecheck, lint, build). Preview deployment healthy, no cache-related runtime errors.

🤖 Generated with Claude Code

…nder

Production functions ran in iad1 (Washington) while Supabase is in
eu-west-1 (Ireland), so every query crossed the Atlantic. Measured warm
TTFB on tenant pages was 7.2-9.3s against 21-29 queries per request —
roughly 300ms per query, because DATABASE_URL uses connection_limit=1
and the queries therefore run one at a time.

- Pin functions to dub1 (same region as the database).
- Memoize tenant context, branding and published site content per render
  with React cache(): the layout, generateMetadata, the public shell and
  the page each resolved them separately (3-5 duplicate queries/render).
- Cache published branding, published site content (60s) and the marketer
  leaderboard (5min) across requests, keyed and tagged by companyId. The
  branding/site-content publish routes already revalidate that tag.

Measured prod-equivalent queries per request (local trace):
  tenant /           29 -> 10
  tenant /about      20 -> 5
  tenant /properties 26 -> 7
  tenant /contact    21 -> 6

The admin draft branding read stays uncached across requests: saving a
draft does not revalidate the tag, so a shared cache would show operators
stale drafts.

Tenant isolation: cache keys and tags include companyId, and React
cache() is request-scoped. The cached leaderboard is computed with a
context carrying only companyId — findManyForTenant skips the company
filter for super admins, so passing a viewer context through could have
cached one tenant's cross-company aggregate and served it to all of that
tenant's visitors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
estate-os Ready Ready Preview Sep 15, 2026 11:07pm UTC

@Asapteejo
Asapteejo merged commit db5cfe4 into main Sep 15, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — 0a2b1816 Deployed Sep 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant