sync-construction-async-property-ui-render-gate-pattern
$
npx mdskill add EpicenterHQ/epicenter/sync-construction-async-property-ui-render-gate-patternEnables 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
- 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.