Skip to content

Commit 1ffb43f

Browse files
ralyodioclaude
andcommitted
fix: stop Supabase client leak causing runtime OOM
getSupabaseAdmin/getSupabaseClient created a new @supabase/supabase-js client on every call (183+42 call sites, several per request). Each client starts a GoTrue auth client (autoRefreshToken setInterval) and a realtime client, so under steady traffic the heap grew until it hit the --max-old-space-size cap and crashed (~18h to OOM on prod). - Memoize both clients as module-level singletons; disable persistSession/autoRefreshToken (no server session, kills the timer). - Apply the same memoization to the per-request service-role clients in the contact/waitlist/whitepaper routes. - Bump Dockerfile heap cap 384->512MB for SSR headroom. Session-mutating auth routes keep their own local clients, so the shared singleton is safe. Build + 209 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d4582dd commit 1ffb43f

6 files changed

Lines changed: 59 additions & 17 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ ENV NODE_ENV=production
3030
ENV NEXT_TELEMETRY_DISABLED=1
3131
ENV HOSTNAME=0.0.0.0
3232
ENV PORT=3000
33-
ENV NODE_OPTIONS=--max-old-space-size=384
33+
ENV NODE_OPTIONS=--max-old-space-size=512
3434

3535
WORKDIR /app
3636

apps/web/src/app/api/contact/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { createClient } from "@supabase/supabase-js";
2+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
33
import { Resend } from "resend";
44

55
const CONTACT_EMAIL = "hello@threatcrush.com";
66
const FROM_EMAIL = `ThreatCrush <${CONTACT_EMAIL}>`;
77

8+
let supabase: SupabaseClient | undefined;
89
function getSupabase() {
10+
if (supabase) return supabase;
911
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
1012
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
1113
if (!url || !key) throw new Error("Supabase not configured");
12-
return createClient(url, key);
14+
// Memoized: a new client per request leaks auth/realtime timers → OOM.
15+
supabase = createClient(url, key, {
16+
auth: { persistSession: false, autoRefreshToken: false },
17+
});
18+
return supabase;
1319
}
1420

1521
async function sendNotification(input: {

apps/web/src/app/api/waitlist/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { createClient } from "@supabase/supabase-js";
2+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
33

4+
let supabase: SupabaseClient | undefined;
45
function getSupabase() {
6+
if (supabase) return supabase;
57
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
68
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
79
if (!url || !key) throw new Error("Supabase not configured");
8-
return createClient(url, key);
10+
// Memoized: a new client per request leaks auth/realtime timers → OOM.
11+
supabase = createClient(url, key, {
12+
auth: { persistSession: false, autoRefreshToken: false },
13+
});
14+
return supabase;
915
}
1016

1117
const COINPAY_API = "https://coinpayportal.com/api";

apps/web/src/app/api/whitepaper/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { createClient } from "@supabase/supabase-js";
2+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
33
import { Resend } from "resend";
44

55
const FROM_EMAIL = "ThreatCrush <hello@threatcrush.com>";
66
const DEFAULT_SLUG = "ctem-guide";
77
const PDF_PATH = "/whitepaper/threatcrush-ctem-guide.pdf";
88

9+
let supabase: SupabaseClient | undefined;
910
function getSupabase() {
11+
if (supabase) return supabase;
1012
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
1113
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
1214
if (!url || !key) throw new Error("Supabase not configured");
13-
return createClient(url, key);
15+
// Memoized: a new client per request leaks auth/realtime timers → OOM.
16+
supabase = createClient(url, key, {
17+
auth: { persistSession: false, autoRefreshToken: false },
18+
});
19+
return supabase;
1420
}
1521

1622
function appUrl() {

apps/web/src/lib/__tests__/supabase.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,16 @@ describe("getSupabaseClient", () => {
5555
expect(client).toBeDefined();
5656
expect(createClient).toHaveBeenCalledWith(
5757
expect.any(String),
58-
expect.any(String)
58+
expect.any(String),
59+
expect.objectContaining({ auth: expect.any(Object) })
5960
);
6061
});
62+
63+
it("memoizes the client across calls", () => {
64+
const a = getSupabaseClient();
65+
const b = getSupabaseClient();
66+
expect(a).toBe(b);
67+
});
6168
});
6269

6370
describe("getSupabaseAdmin", () => {

apps/web/src/lib/supabase.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import "server-only";
2-
import { createClient } from "@supabase/supabase-js";
2+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
33

44
function requireEnv(name: string): string {
55
const value = process.env[name];
@@ -12,18 +12,35 @@ function requireEnv(name: string): string {
1212
return value;
1313
}
1414

15+
// Memoized singletons. Creating a Supabase client per call leaks memory: each
16+
// client spins up a GoTrue auth client (with an autoRefreshToken setInterval)
17+
// and a realtime client. With hundreds of calls per request across the app,
18+
// these accumulate until the Node heap OOMs. Reuse one client per role instead.
19+
let adminClient: SupabaseClient | undefined;
20+
let anonClient: SupabaseClient | undefined;
21+
1522
/** Browser/client-side Supabase client (anon key) */
16-
export function getSupabaseClient() {
17-
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
18-
const anonKey = requireEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY");
19-
return createClient(url, anonKey);
23+
export function getSupabaseClient(): SupabaseClient {
24+
if (!anonClient) {
25+
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
26+
const anonKey = requireEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY");
27+
anonClient = createClient(url, anonKey, {
28+
auth: { persistSession: false, autoRefreshToken: false },
29+
});
30+
}
31+
return anonClient;
2032
}
2133

2234
/** Server-side Supabase client (service role key — full access) */
23-
export function getSupabaseAdmin() {
24-
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
25-
const serviceKey = requireEnv("SUPABASE_SERVICE_ROLE_KEY");
26-
return createClient(url, serviceKey);
35+
export function getSupabaseAdmin(): SupabaseClient {
36+
if (!adminClient) {
37+
const url = requireEnv("NEXT_PUBLIC_SUPABASE_URL");
38+
const serviceKey = requireEnv("SUPABASE_SERVICE_ROLE_KEY");
39+
adminClient = createClient(url, serviceKey, {
40+
auth: { persistSession: false, autoRefreshToken: false },
41+
});
42+
}
43+
return adminClient;
2744
}
2845

2946
/** Helper to slugify a module name */

0 commit comments

Comments
 (0)