DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our maintenance switch is a boolean in the repository, because the database is the thing that is down

PubTrivia is a live quiz product. A host launches a question, sixty phones in a pub answer it, and the leaderboard moves. All of that needs Postgres and an auth provider, which means there is a class of maintenance, a migration, a provider incident, a region move, where the honest thing to show is a page that says so.

So we added a maintenance switch. The whole thing is three exported values:

export const MAINTENANCE = false

export const MAINTENANCE_MESSAGE =
    'PubTrivia is down for maintenance right now. Please try again in a little while.'

export const MAINTENANCE_RETRY_AFTER_S = 300
Enter fullscreen mode Exit fullscreen mode

Not an environment variable. Not a row in a settings table. Not a flag service. A constant in lib/maintenance.ts, flipped by hand and deployed.

Why a constant and not a flag

Every other option reads the flag from somewhere at request time, and the somewhere is the problem.

A database row is the worst case, because the database is what is down. An environment variable is better, but on our platform changing one is a redeploy anyway, so it buys nothing over a constant and costs you a value that is invisible in the repository. A hosted flag service is a third-party dependency on the path of every request, added so that we can tell users a different third-party dependency is unavailable.

The cost of the constant is real and worth stating: turning maintenance on is a deploy, and turning it off is another one. For a product where the planned outages are migrations I schedule myself, that is a cost I can pay. If I needed to flip it from a phone in ninety seconds, this would be the wrong design.

The switch does less work, not more

The interesting property is what the flag removes. Normally every matched request runs a rate limit check, then refreshes the Supabase session, then decides whether the path is public. With the flag on, the session refresh never happens:

if (MAINTENANCE) return maintenanceResponse(request)

return await updateSession(request)
Enter fullscreen mode Exit fullscreen mode

So while we are telling people the auth provider is unavailable, we are not calling the auth provider on every request to say so. One of the nine tests exists only to assert that, by mocking updateSession and checking it was never called for any path, including the home page.

Three groups of request, three different answers

maintenanceResponse splits requests three ways.

Pages that work without a session or a database are served untouched. That is the marketing site: the home page, the feature pages, the hosting guide, the legal documents. Those are static. They have no reason to go down because Postgres is unreachable, and they are also the pages a search crawler is most likely to be fetching while you are mid-migration.

Everything else gets the maintenance page, rewritten rather than redirected, so it appears at the URL that was asked for. That covers the dashboard, the player route, and the auth callback links. A rewrite rather than a redirect matters for a reason that only shows up with a tab that was already open: a client side navigation inside the app hits the proxy for the new path, and a rewrite answers it in place.

API routes and webhooks get neither. They get JSON:

return NextResponse.json(
    { error: MAINTENANCE_MESSAGE },
    { status: 503, headers: { 'Retry-After': String(MAINTENANCE_RETRY_AFTER_S) } }
)
Enter fullscreen mode Exit fullscreen mode

A 503 with Retry-After is the answer a machine can act on. Stripe retries a 503. Stripe does not usefully retry an HTML page that says "down for maintenance" with a 200 on it.

The matcher cannot follow the flag

This is the part that cost me the most time, and it is specific to Next.js middleware, which in Next 16 lives in proxy.ts.

The matcher is static. It is read at build time, it has to be a literal, and it cannot contain a conditional. Our /api and /webhook paths used to be excluded from it entirely, which was correct: route handlers do their own auth, and a gate in front of a Stripe webhook can only turn a valid delivery into a redirect to a login form.

But excluded means the proxy never runs, and maintenance needs it to run so it can refuse. So those paths are now matched on purpose, with the first lines of the function putting them back exactly where they were when the flag is off:

if (isApiRoute(request.nextUrl.pathname)) {
    return MAINTENANCE ? maintenanceUnavailable() : NextResponse.next()
}
Enter fullscreen mode Exit fullscreen mode

Past the limiter, past the session refresh, straight on. The flag is the only reason they are matched at all.

A POST from a tab that was already open

A page that was loaded five minutes ago still has its form on it, and its server action id. Someone presses the button.

Rewriting that POST to the maintenance page sends it to a page that does not define that action, and the failure mode is a framework error rather than a message. So the method is checked before the rewrite:

if (request.method !== 'GET' && request.method !== 'HEAD') return maintenanceUnavailable()
Enter fullscreen mode Exit fullscreen mode

The action never runs, and the caller gets the same 503 an API client would.

Sign in stays up, and says why

The one place I changed my mind twice. /login, /signup and /forgot-password are deliberately not sent to the maintenance page. They stay up, and their layouts render the message where the form would be:

export default function LoginLayout({ children }: { children: React.ReactNode }) {
    if (MAINTENANCE) return <AuthPageMaintenance />
    return <>{children}</>
}
Enter fullscreen mode Exit fullscreen mode

The reason is that these three URLs are where people and password managers go by habit. A redirect away from /login is a worse experience than /login telling you what is happening, with the logo and a link home still on it. And because the form is gone from the layout rather than disabled in the component, there is nothing to submit.

Belt and braces anyway: the five server actions behind those forms each start with the same two words.

if (MAINTENANCE) return { message: MAINTENANCE_MESSAGE }
Enter fullscreen mode Exit fullscreen mode

The maintenance page itself carries noIndex, because the proxy serves it at whatever URL was requested, and a crawler that fetched our pricing page mid-migration should not record "Down for maintenance" as the content of that URL. With the flag off, opening /maintenance directly redirects to sign in, since there is nothing to show.

What the tests actually pin down

Nine tests, 126 lines. Two of them are the ones I would keep if I could only keep two.

The first iterates the sitemap. Every page we ask Google to index must still be up while maintenance is on, which is currently all 72 URLs in the live sitemap:

for (const path of INDEXABLE_ROUTES) {
    expect(isUpDuringMaintenance(path || '/'), `${path || '/'} goes down for maintenance`).toBe(true)
}
Enter fullscreen mode Exit fullscreen mode

That one test is why adding a public page that needs the database is a decision rather than an accident. If you add it to the sitemap and it goes down with the dashboard, the suite tells you.

The second is a detail about testing a constant. You cannot reassign an imported const, so toggling the flag per test means mocking the module with a getter:

vi.mock('@/lib/maintenance', () => ({
    get MAINTENANCE() {
        return mocks.maintenance
    },
    MAINTENANCE_MESSAGE: 'Down for maintenance.',
    MAINTENANCE_RETRY_AFTER_S: 300,
}))
Enter fullscreen mode Exit fullscreen mode

A plain object property would be read once at import and frozen at whatever it was. The getter is read on every access, so mocks.maintenance = true in a beforeEach actually changes what the proxy sees.

What I would tell anyone adding one of these

Decide first what is down. Then decide where the flag lives, because those two answers are the same answer. If your outages are "the database is unreachable", a flag in the database is not a flag. Ours is a constant precisely because the thing it describes is the layer underneath everything that could read it.

And decide which pages are allowed to survive it, as a list, with a test. The dashboard and the live scoring go down because they cannot work. A page explaining how to run a quiz night has no such excuse.

Top comments (0)