Skip to content
Chapter 6Lesson 3

Top-level await vs. lazy init

How an ES module decides to do its setup work at load time with top-level await or defer it behind a lazy cached getter.

Sometimes a module has to do work before it can export anything: validate environment variables, open a database connection, or load a signing key from a secret manager. You have two patterns to choose from. The first is top-level await, where the module’s evaluation completes only after a promise resolves. The second is a lazy getter that does the work on its first call and caches the result. This lesson covers when each pattern is the right choice.

The previous lesson covered how the runtime walks the module graph depth-first. This one covers what each node does at the moment it is evaluated, and how that choice affects every consumer above it. That is the third part of the chapter’s mental model, after edges and traversal.

ES modules permit await at the top level, outside any async function. The runtime guarantees that the module’s exports become observable only after the awaited promise resolves. The feature has shipped everywhere this course’s stack runs, including Node 24 LTS, Next.js 16, and every modern browser, so you can reach for it without checking compatibility tables.

The key idea is that adding await at the top of a leaf module is not a local code change; it changes the whole graph. A module with a top-level await becomes a deferred node, which means the runtime will not declare it evaluated until the promise settles. Every module that statically imports it inherits that wait, because none of them can finish their own top-level code until this module finishes. The wait propagates upward along static-import edges, all the way to the entry. The course calls this kind of upstream module implicitly async .

The code itself is unremarkable:

feature-flags.ts
import 'server-only';
const flags = await loadFlagsFromService();
export const isFeatureEnabled = (key: string) => flags[key] === true;

The await on a const at module scope is the part that matters. Treat loadFlagsFromService as a stand-in for a real feature-flag SDK. The 'server-only' first line is the discipline from the previous lesson: this module is a server seam, and the build refuses to ship it to the browser.

Three properties make top-level await the right choice here. The work has to be cheap, meaning sub-second and deterministic. It has to be mandatory for every consumer, since no consumer of feature-flags.ts can function without resolved flags. And it has to be needed at module load, not lazily on first use. Keep those three properties in mind, because the decision rule at the end of the lesson brings them together.

The cascade: how one await propagates upward

Section titled “The cascade: how one await propagates upward”

That convenience has a cost. If feature-flags.ts has a top-level await, every module that imports it implicitly awaits too, and so does every module above those, all the way up the chain. The page render does not begin until the entire chain completes.

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
A top-level `await` on a leaf node propagates a wait to every importer above it; the page cannot render until the chain completes.

In a Next.js Server Component, the page render sits upstream of every module the page imports, so a slow top-level await in any leaf, however far down the chain, delays the whole render. The page cannot return JSX until each module above the leaf has finished its top-level work, and each of those modules is in turn waiting on the one below it.

This is fine when the wait is environment-variable validation that should crash startup anyway. It becomes a serious problem when the wait is a two-second cross-region call to a feature-flag service, because then every page on the site pays that two-second cost on every cold start. The right tool for a per-component async fetch is Suspense and streaming, which Unit 4 on Next.js covers in depth: Server Components can be async, the page can stream around the slow part, and the user sees something while the work happens. So top-level await is the wrong tool when the wait is long or per-request.

The rule in one sentence: top-level await is for fast, deterministic, mandatory work, and anything else belongs in a function call. Work that does qualify becomes a render-blocker , and here that is exactly what you want: if env validation fails, you would rather the whole server crash than serve a broken request.

env.ts: the reference shape for cheap, mandatory, synchronous work

Section titled “env.ts: the reference shape for cheap, mandatory, synchronous work”

The cleanest example of “cheap, mandatory, deterministic” module-load work in a 2026 SaaS app doesn’t use await at all. It is the env.ts file that validates the environment variables at startup. You will author this file in Unit 5 on databases and Drizzle, and the snippet below is the shape every project lands on.

env.ts
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 about that snippet are worth flagging. They aren’t the point of the lesson, but the wrong version of each is everywhere on the web. First, the Zod 4 idiom is z.url() at the top level; the legacy z.string().url() chain is a Zod 3 shape and doesn’t appear in 2026 code. Second, the runtimeEnv field is destructured by hand on purpose: t3-env documents this as the bundler-safe way to keep Next.js from stripping unused variables at build time, and shortening it to runtimeEnv: process.env defeats that.

The important point for this lesson is that createEnv runs synchronously at module evaluation, with no await. Zod parses, process.env is already populated, and the env export is ready before any consumer reads it. The validation is cheap, just microseconds of Zod parsing. It is mandatory, since every consumer that reads a secret depends on it. And it is deterministic: the same inputs give the same result, with no I/O. The absence of await does not mean the module isn’t doing top-level work; createEnv is itself that work. It belongs in the same category as top-level await, just without the I/O.

If DATABASE_URL is missing, createEnv throws at module load and the evaluation of env.ts fails. The previous lesson covered what happens next: the error short-circuits upward through the graph, the process crashes before a single request lands, and the fail-closed contract holds. This is exactly the shape you want for any work that satisfies the three properties.

Lazy init: the alternative for expensive or conditional work

Section titled “Lazy init: the alternative for expensive or conditional work”

Now consider the other branch. A lazy getter exports a function, such as getDb(), getStripe(), or getRedis(), that does the setup work the first time it is called and caches the result in a module-scoped variable. Importing the module costs nothing: no connection opened, no SDK instantiated, no remote call. The cost moves from “every consumer pays at import time” to “the first consumer to call pays, and everyone after it reuses the cache.”

The canonical shape for a database client is the central example of this lesson. This small file makes four distinct choices, and each one is deliberate.

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. The previous lesson named this seam-enforcement rule; every server-only module, including env.ts, db.ts, and the auth handler, opens with this line.

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. This is the claim the previous lesson made good on, that a module-level let really is one slot shared across every consumer of the module. The let is a deliberate exception to the “default to const” rule, because this variable is meant to mutate 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. This is 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 subsequent 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 actual cost is paid. postgres(...) opens the connection pool, and drizzle({...}) wires the ORM on top. These run on the first call, never at import. A file that imports db.ts but never calls getDb() opens zero connections.

1 / 1

The Drizzle wiring in your real project, whose full shape arrives in Unit 5, is more sophisticated than this. It has a schema-aware return type, a tenantDb(orgId) factory layered on top for row-level tenancy, and a separate unpooled client for migrations. What you are reading here is the decision shape, not the final wiring. The ReturnType<typeof drizzle> cache type is deliberately plain, because the lesson teaches the pattern and pulling in a schema you haven’t authored yet would only distract from it. The @/env import points at the same env.ts from the section above. You’ll author that file in Unit 5, but it’s already the file the rest of the course imports from.

The wider rule is this: stateful singletons that need setup are exposed through getters, not as top-level exports. The same shape appears at getStripe() in Unit 11 on billing, at getRedis() in Unit 14 on caching, and at getS3Client() in Unit 12 on object storage. Every SDK adapter in the course’s lib/ folder follows this pattern. The names change, the shape doesn’t.

One serverless detail is worth fixing in your mind before you move on. 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, while warm requests on the same instance reuse the cached client. The module-level singleton lives per-instance, not per-region or per-deployment, so it is not a shared cache the way Redis is. Connection-pool tuning for serverless Postgres has its own lesson in Unit 5. For now, knowing that the cache is per-instance is enough to follow what’s happening.

The two patterns come down to a single rule. Use top-level await when the work is cheap, mandatory for every consumer, and async by necessity, and use plain synchronous module-load work when that same work doesn’t need to be async at all. Use lazy init via a cached getter when the work is expensive, conditional, or only needed by a subset of consumers.

Three questions resolve any new boundary file in your codebase.

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
Three questions pick the shape. Any 'expensive' or 'conditional' answer routes to a lazy getter; only cheap, deterministic, universally-required work earns top-level work.

Two common mistakes are worth naming explicitly.

The first is top-level await for a database connection. The SDK constructor returns a promise, so a developer reaches for top-level await as the obvious shape. The cost stays invisible until you measure it: every consumer of the module pays the connection cost at import, including tests that never query, scripts that never read data, and build-time imports during page generation. The right choice is getDb(). A database connection is expensive, conditional (most code paths only need a subset of queries), and per-instance, which is three out of three lazy-init triggers.

The second is lazy init for env validation. Hiding createEnv behind a getEnv() getter looks safer at first, on the reasoning that you’ll only validate when someone actually needs an env var. But it defers the failure: the bug doesn’t surface until the first request that touches a missing variable. Env validation is the canonical fail-closed-at-startup case precisely because it runs synchronously at module load, which crashes the server before it can serve a single broken request.

Before moving on, confirm the pattern with a quick sort.

Sort each setup task into the shape that earns its weight. Drag each item into the bucket it belongs to, then press Check.

Top-level await (or sync at load) Cheap, mandatory, deterministic.
Lazy init via cached getter Expensive, conditional, or partial.
Env-var validation with Zod
Postgres connection pool
Feature-flag client fetched from a remote service at startup
Stripe SDK instance with API key
Redis cache client
Signing-key load from a secret manager, used by every request
S3 client, used only by the file-upload route
PostHog analytics client, initialized only when POSTHOG_KEY is set

Rewriting an eager db.ts into the lazy shape

Section titled “Rewriting an eager db.ts into the lazy shape”

The recognition sort is one half of the practice. The other half is rewriting code. Below is a wrongly eager db.ts: it opens a connection at the top of the module, the moment any consumer imports it. Rewrite it to the lazy shape from the previous section.

The runner uses stand-in postgres and drizzle functions inlined in the file so the tests can verify that nothing happens at import time. After your rewrite, the file should export getDb instead of db. The tests then check that no connection opens until the first call, and that subsequent calls reuse the cache.

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 });

    The let cached slot starts as null, so importing the file runs neither postgres nor drizzle. The first getDb() call hits the if (cached) return cached; guard with null, falls through, opens the connection, stores the instance, and returns it. Every subsequent call short-circuits on the guard and hands back the cached value: the same reference, with no extra work. In a real TypeScript module the cache slot would be typed as let cached: ReturnType<typeof drizzle> | null = null, and getDb would be exported alongside the file’s other named exports.