Top-level await vs. lazy init
Whether an ES module runs its setup at load time with top-level await or defers it behind a lazy cached getter.
Some modules must do work before they can export anything: validate environment variables, open a database connection, or load a signing key from a secret manager.
Top-level await holds the module’s evaluation until a promise resolves; a lazy getter does the work on its first call and caches the result.
Which one is correct depends on the work.
Top-level await is a graph-level change
Section titled “Top-level await is a graph-level change”ES modules permit await at the top level, outside any async function.
A module with one becomes a deferred node: the runtime won’t mark it evaluated, and its exports stay unobservable, until the promise settles.
Adding that await to a leaf module is not a local change.
Every module that statically imports it inherits the wait, since none can finish its own top-level code first; the wait propagates upward along static-import edges to the entry.
An upstream module pulled into waiting this way is implicitly async .
The triggering code is ordinary:
import 'server-only';
const flags = await loadFlagsFromService();
export const isFeatureEnabled = (key: string) => flags[key] === true;The part that matters is the await on a const at module scope; treat loadFlagsFromService as a stand-in for a real feature-flag SDK.
The 'server-only' line, from the previous lesson, keeps the build from shipping this server module to the browser.
Three properties make top-level await right here.
The work is cheap, sub-second and deterministic; it is mandatory for every consumer of feature-flags.ts, none of which can function without resolved flags; and it is needed at module load, not lazily on first use.
A slow leaf await blocks the whole page render
Section titled “A slow leaf await blocks the whole page render”flowchart LR page["page.tsx<br/>⏳ awaits"] --> dashboard["dashboard.tsx<br/>⏳ awaits"] dashboard --> flagsHook["use-flags.ts<br/>⏳ awaits"] flagsHook --> flags["feature-flags.ts<br/>⏳ await explicit"] classDef leaf fill:#fde68a,stroke:#b45309,color:#111 classDef upstream fill:#fef3c7,stroke:#a16207,color:#111 class flags leaf class page,dashboard,flagsHook upstream
In a Next.js Server Component the page render sits at the top of the graph, so a slow top-level await in any leaf, however deep, holds the whole render until that leaf settles.
That is fine when the wait is environment-variable validation that should crash startup anyway.
It is a serious problem when the wait is a two-second cross-region call to a feature-flag service, because then every page pays that cost on every cold start.
A per-component async fetch belongs in Suspense and streaming, where the page renders around the slow part while the work runs.
Work that earns a top-level await becomes a render-blocker , which is what you want here: if env validation fails, you would rather the server crash than serve a broken request.
env.ts: synchronous module-load work without await
Section titled “env.ts: synchronous module-load work without await”The cleanest example of cheap, mandatory, deterministic module-load work uses no await at all: env.ts, the file that validates your environment variables at startup.
import 'server-only';
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
export const env = createEnv({ server: { DATABASE_URL: z.url(), STRIPE_SECRET_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, },});Two details are easy to get wrong.
z.url() at the top level is the Zod 4 idiom; the chained z.string().url() is the older Zod 3 shape.
And runtimeEnv is destructured by hand on purpose: t3-env relies on this to stop Next.js from stripping variables it thinks are unused, and runtimeEnv: process.env defeats that.
createEnv runs synchronously at module evaluation, with no await: Zod parses an already-populated process.env, and the env export is ready before any consumer reads it.
The missing await doesn’t mean the module skips top-level work.
createEnv is that work, in the same category as top-level await, minus the I/O, and it carries all three properties: cheap parsing, a mandatory dependency for every secret, and deterministic with no I/O.
If DATABASE_URL is missing, createEnv throws, env.ts fails to evaluate, and that error crashes the process before a single request lands: failing closed at startup, exactly what work with these three properties wants.
Lazy init: build on first call, cache for the rest
Section titled “Lazy init: build on first call, cache for the rest”The other branch defers the work.
A lazy getter exports a function, such as getDb(), that does the setup on its first call and caches the result in a module-scoped variable.
Importing the module costs nothing: the first caller pays, and everyone after reuses the cache.
The canonical shape for a database client makes four deliberate choices.
import 'server-only';
import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
import { env } from '@/env';
let cached: ReturnType<typeof drizzle> | null = null;
export const getDb = () => { if (cached) return cached; const client = postgres(env.DATABASE_URL); cached = drizzle({ client, casing: 'snake_case' }); return cached;};'server-only' as the first line. The database client and the connection it holds must never reach a client bundle. Every server-only module, including env.ts, db.ts, and the auth handler, opens with the seam-enforcement line from the previous lesson.
import 'server-only';
import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
import { env } from '@/env';
let cached: ReturnType<typeof drizzle> | null = null;
export const getDb = () => { if (cached) return cached; const client = postgres(env.DATABASE_URL); cached = drizzle({ client, casing: 'snake_case' }); return cached;};The module-scoped cache: one slot, one process, shared across every consumer of the module. The let is a deliberate exception to the “default to const” rule, because this variable mutates exactly once, from null to a built client.
import 'server-only';
import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
import { env } from '@/env';
let cached: ReturnType<typeof drizzle> | null = null;
export const getDb = () => { if (cached) return cached; const client = postgres(env.DATABASE_URL); cached = drizzle({ client, casing: 'snake_case' }); return cached;};The getter body, the single source of truth for whether a connection exists yet. If cached is set, return it; otherwise build the client, store it, and return it. Every later call hits the early return, so the construction code runs exactly once per process.
import 'server-only';
import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
import { env } from '@/env';
let cached: ReturnType<typeof drizzle> | null = null;
export const getDb = () => { if (cached) return cached; const client = postgres(env.DATABASE_URL); cached = drizzle({ client, casing: 'snake_case' }); return cached;};Where the cost is paid. postgres(...) opens the connection pool and drizzle({...}) wires the ORM on top. These run on the first call, never at import, so a file that imports db.ts but never calls getDb() opens zero connections.
This is the decision shape, not your final wiring.
Your real project’s Drizzle setup, built in Unit 5, adds a schema-aware return type and a tenancy layer; the ReturnType<typeof drizzle> cache type stays plain here so an unauthored schema doesn’t distract from the pattern.
The wider rule: stateful singletons that need setup are exposed through getters, not as top-level exports.
The same shape returns as getStripe() for billing, getRedis() for caching, and getS3Client() for object storage; every SDK adapter in the course’s lib/ folder follows it.
On a serverless platform such as Vercel functions or Cloudflare Workers, each instance runs its own module evaluation and holds its own cached slot.
A cold start pays the connection cost; warm requests on the same instance reuse the cached client.
The module-level singleton lives per-instance, so it is not a shared cache the way Redis is.
The decision rule
Section titled “The decision rule”Three questions resolve any new boundary file: is the work expensive, is it conditional, and is it mandatory for every consumer? Expensive or conditional routes to lazy init; only cheap, deterministic, universally-required work earns top-level await or synchronous module-load work.
flowchart LR
start([New boundary file does setup work]) --> q1{Expensive?<br/>I/O or large deps}
q1 -- "Yes" --> lazy[Lazy init<br/>getDb-style getter]
q1 -- "No" --> q2{Conditional?<br/>env-gated or optional}
q2 -- "Yes" --> lazy
q2 -- "No" --> q3{Mandatory for<br/>every consumer?}
q3 -- "Yes" --> tla[Top-level await<br/>or sync at module load]
q3 -- "No" --> lazy
classDef leafLazy fill:#bae6fd,stroke:#0369a1,color:#111
classDef leafTla fill:#bbf7d0,stroke:#15803d,color:#111
class lazy leafLazy
class tla leafTla Two mistakes are common.
The first is top-level await for a database connection.
The SDK constructor returns a promise, so top-level await looks like the obvious shape.
The cost stays invisible until you measure it: every consumer pays the connection cost at import, including tests that never query, scripts that never read data, and build-time imports during page generation.
A database connection is expensive, conditional (most paths need only a subset of queries), and per-instance: three out of three lazy-init triggers, so reach for getDb().
The second is lazy init for env validation.
Hiding createEnv behind a getEnv() getter looks safer: you only validate when someone reads an env var.
But that defers the failure to the first request that touches a missing variable.
Env validation is the canonical fail-closed-at-startup case because it runs synchronously at module load and crashes the server before it serves a single broken request.
Sort each setup task into the shape that earns its weight. Drag each item into the bucket it belongs to, then press Check.
POSTHOG_KEY is setRewriting an eager db.ts into the lazy shape
Section titled “Rewriting an eager db.ts into the lazy shape”The db.ts below is eager: it opens the connection at module scope, the moment any file imports it.
Rewrite it into the lazy getDb() shape from the previous section.
Rewrite db.js so the connection is only opened on the first call to getDb(). The tests verify nothing happens at import time and that repeated calls return the same instance. (Real code would be in TypeScript; the runner uses JavaScript so the test harness can stay simple.)
Reference solution
// Stand-ins for the real drizzle/postgres APIs so the test runner// doesn't need a real database. Treat them as already-imported.let postgresCalls = 0;let drizzleCalls = 0;const postgres = (_url) => { postgresCalls += 1; return { __client: true };};const drizzle = (_config) => { drizzleCalls += 1; return { __db: true };};
let cached = null;
const getDb = () => { if (cached) return cached; const client = postgres('postgres://localhost/app'); cached = drizzle({ client }); return cached;};
const __counts = () => ({ postgresCalls, drizzleCalls });Because cached starts as null, importing the file calls neither postgres nor drizzle.
The first getDb() falls past the guard, opens the connection, caches the instance, and returns it; every later call hits the guard and returns that same reference.
In real TypeScript you would type the slot as let cached: ReturnType<typeof drizzle> | null = null.