sync-construction-async-property-ui-render-gate-pattern

$npx mdskill add EpicenterHQ/epicenter/sync-construction-async-property-ui-render-gate-pattern

Enables synchronous client construction with async initialization for UI and module exports

  • Solves async initialization in environments requiring synchronous module exports
  • Leverages async properties to defer rendering or execution until ready
  • Uses a gate pattern to control UI rendering based on client readiness
  • Provides a stable client reference while async operations complete

SKILL.md

.github/skills/sync-construction-async-property-ui-render-gate-patternView on GitHub ↗
---
name: sync-construction-async-property-ui-render-gate-pattern
description: 'Sync construction with async property for module-exportable clients. Use when: "async init", "module-level async", or creating clients needing async init but synchronous UI use.'
metadata:
  author: epicenter
  version: '1.0'
---

# Sync Construction, Async Property

> The initialization of the client is synchronous. The async work is stored as a property you can await, while passing the reference around.

## When to Apply This Pattern

Use this when you have:

- Async client initialization (IndexedDB, server connection, file system)
- Module exports that need to be importable without `await`
- UI components that want sync access to the client
- SvelteKit apps where you want to gate rendering on readiness

Signals you're fighting async construction:

- `await getX()` patterns everywhere
- Top-level await complaints from bundlers
- Getter functions wrapping singleton access
- Components that can't import a client directly

## The Problem

Async constructors can't be exported:

```typescript
// This doesn't work
export const client = await createClient(); // Top-level await breaks bundlers
```

So you end up with getter patterns:

```typescript
let client: Client | null = null;

export async function getClient() {
	if (!client) {
		client = await createClient();
	}
	return client;
}

// Every consumer must await
const client = await getClient();
```

Every call site needs `await`. You're passing promises around instead of objects.

## The Pattern

Make construction synchronous. Attach async work to the object:

```typescript
// client.ts
export const client = createClient();

// Sync access works immediately
client.save(data);
client.load(id);

// Await the async work when you need to
await client.whenSynced;
```

Construction returns immediately. The async initialization (loading from disk, connecting to servers) happens in the background and is tracked via `whenSynced`.

## The UI Render Gate

In Svelte, gate once at the root using `@epicenter/ui/spinner` for the loading state and `@epicenter/ui/empty` for error recovery:

```svelte
<!-- +layout.svelte -->
<script>
	import * as Empty from '@epicenter/ui/empty';
	import { Spinner } from '@epicenter/ui/spinner';
	import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
	import { client } from '$lib/client';
</script>

{#await client.whenSynced}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<Spinner class="size-5 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Loading…</Empty.Title>
	</Empty.Root>
{:then}
	{@render children?.()}
{:catch}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<TriangleAlertIcon class="size-8 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Failed to load</Empty.Title>
		<Empty.Description>
			Something went wrong during initialization. Try reloading.
		</Empty.Description>
	</Empty.Root>
```

The gate guarantees: by the time any child component's script runs, the async work is complete. Children use sync access without checking readiness.

**Always include `{:catch}`** : if the async seed fails (e.g. `browser.windows.getAll` throws), the user sees an actionable error instead of an infinite spinner.

## Implementation

The `withCapabilities()` fluent builder attaches async work to a sync-constructed object:

```typescript
function createClient() {
	const state = initializeSyncState();

	return {
		save(data) {
			/* sync method */
		},
		load(id) {
			/* sync method */
		},

		withCapabilities({ persistence }) {
			const whenSynced = persistence(state);
			return Object.assign(this, { whenSynced });
		},
	};
}

// Usage
export const client = createClient().withCapabilities({
	persistence: (state) => loadFromIndexedDB(state),
});
```

## Before and After

| Aspect         | Async Construction        | Sync + whenSynced       |
| -------------- | ------------------------- | ----------------------- |
| Module export  | Can't export directly     | Export the object       |
| Consumer code  | `await getX()` everywhere | Direct import, sync use |
| UI integration | Awkward promise handling  | Single `{#await}` gate  |
| Type signature | `Promise<X>`              | `X` with `.whenSynced`  |

## Real-World Example: y-indexeddb

The Yjs ecosystem uses this pattern everywhere:

```typescript
const provider = new IndexeddbPersistence('my-db', doc);
// Constructor returns immediately

provider.on('update', handleUpdate); // Sync access works

await provider.whenSynced; // Wait when you need to
```

They never block construction. The async work is always deferred to a property you can await.

## Alternate Pattern: Await in Every Method

Alternatively, you can skip the `whenReady` property entirely and hide the initialization await inside each method. The canonical example is [idb](https://github.com/jakearchibald/idb):

```typescript
const dbPromise = openDB('keyval-store', 1, { upgrade(db) { db.createObjectStore('keyval') } });

export async function get(key) { return (await dbPromise).get('keyval', key); }
export async function set(key, val) { return (await dbPromise).put('keyval', val, key); }
```

Use `whenReady` when your client has sync methods that depend on initialized state. Use await-in-every-method when every method is async anyway (like database access). See the [idb await-in-every-method article](/docs/articles/idb-await-every-method-pattern.md) for a deeper comparison.

## Related Patterns

- [Lazy Singleton](../lazy-singleton/SKILL.md) : when you need race-condition-safe lazy initialization
- [Don't Use Parallel Maps](../../docs/articles/instance-state-attachment-pattern.md) : attach state to instances instead of tracking separately

## References

- [Full article](/docs/articles/sync-construction-async-property-ui-render-gate-pattern.md) : detailed explanation with diagrams
- [Comprehensive guide](/docs/articles/sync-client-initialization.md) : 480-line deep dive with idb example
- [idb await-in-every-method](/docs/articles/idb-await-every-method-pattern.md) : the sibling pattern for purely async APIs

More from EpicenterHQ/epicenter

SkillDescription
agent-goalWrite `/goal` prompts for long-running agent work in Codex or Claude Code. Use for slash goal, agent goal, durable objective, autonomous coding run.
approachability-auditReview code as a new TypeScript developer. Use when code feels indirect, clever, hard to follow, or needs a pass on abstractions, names, first-read clarity.
arktypeArktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
attach-primitiveContract and invariants for `attach*` composition primitives in `packages/workspace` (side-effectful building blocks like attachIndexedDb, attachSqlite, attachBroadcastChannel, attachEncryption, attachTable, openCollaboration), and when to use `create*` (pure construction) instead. Use when writing or reviewing an `attach*` or `create*` function, naming a new workspace primitive, composing inside a workspace builder, or deciding whether a primitive registers listeners at call time.
authEpicenter auth packages: `@epicenter/auth`, `@epicenter/auth-svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
autumnAutumn billing in Epicenter: `autumn.config.ts`, `autumn-js` credit checks, `atmn` CLI, plan gates, and metered AI usage. Use when changing billing, pricing, credits, plan access, refunds, or usage events.
better-auth-best-practicesBetter Auth server/client setup: `auth.ts`, generated schema, DB adapters, sessions, cookies, env vars, and plugins. Use when mentioning Better Auth, betterauth, auth handlers, OAuth, email/password, or session configuration.
better-auth-security-best-practicesBetter Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.
change-proposalPresent proposed code changes visually before implementing. Use when: "show me options", "compare approaches", "what should we do", or when changes need before/after comparison.
claude-code-consultUse this skill when the user asks to consult Claude, ask Claude Code, get another model's take, run a taste check, find cleaner options, or prepare a Claude prompt. Create a bounded second-opinion prompt or run a read-only Claude Code consult, then verify Claude's claims against local files.