Skip to content

Commit 4fad761

Browse files
committed
docs(sync-client-initialization): add article on synchronous client initialization patterns in UI frameworks
1 parent c1f13f4 commit 4fad761

File tree

1 file changed

+300
-0
lines changed

1 file changed

+300
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
# Avoid Async Client Initialization in UI Frameworks: Synchronous Client Initialization with Deferred Sync
2+
3+
## The Problem with Async Initialization
4+
5+
In UI frameworks, we try our best to avoid async client initialization. Even clients whose operations are almost all async will often have their initialization be synchronous:
6+
7+
```typescript
8+
const db = drizzle(env.DB);
9+
const result = await db.select().from(users).all();
10+
```
11+
12+
The reason is that when you have an async client, you can't import it.
13+
14+
In a single-page application, you typically want to create a client once and use it everywhere. The ideal pattern looks like this:
15+
16+
```typescript
17+
// client.ts
18+
export const client = createClient();
19+
20+
// component-a.svelte
21+
import { client } from '$lib/client';
22+
client.save(data);
23+
24+
// component-b.svelte
25+
import { client } from '$lib/client';
26+
client.load(id);
27+
```
28+
29+
Simple imports, synchronous access. But what if `createClient()` is async? You can't write:
30+
31+
```typescript
32+
// client.ts
33+
export const client = await createClient(); // Top-level await doesn't work in most bundlers
34+
```
35+
36+
Top-level await is either unsupported or creates problems with module loading order. You end up with a client that can't be imported like a normal value. This forces everyone into workarounds.
37+
38+
You end up with some strange workarounds. Here are patterns I see all the time in UI frameworks:
39+
40+
### Workaround 1: The Getter Function
41+
42+
```typescript
43+
// db.ts
44+
let client: DatabaseClient | null = null;
45+
46+
export async function getClient() {
47+
if (!client) {
48+
client = await createDatabaseClient();
49+
}
50+
return client;
51+
}
52+
53+
// component.svelte
54+
<script>
55+
import { getClient } from '$lib/db';
56+
57+
async function saveData() {
58+
const client = await getClient();
59+
await client.save(data);
60+
}
61+
</script>
62+
```
63+
64+
Every function that needs the client has to call and await the getter. You can't just import the client; you have to go through this ceremony every time.
65+
66+
### Workaround 2: Context Provider Pattern
67+
68+
```svelte
69+
<!-- DatabaseProvider.svelte -->
70+
<script module>
71+
import { createContext } from 'svelte';
72+
73+
const [getDatabaseClient, setDatabaseClient] = createContext<DatabaseClient>();
74+
75+
export { getDatabaseClient };
76+
</script>
77+
78+
<script>
79+
import { onMount } from 'svelte';
80+
import { createDatabaseClient } from '$lib/db';
81+
82+
let client: DatabaseClient | null = $state(null);
83+
84+
onMount(async () => {
85+
client = await createDatabaseClient();
86+
setDatabaseClient(client);
87+
});
88+
89+
let { children } = $props();
90+
</script>
91+
92+
{#if client}
93+
{@render children?.()}
94+
{:else}
95+
<LoadingSpinner />
96+
{/if}
97+
98+
<!-- +layout.svelte -->
99+
<script>
100+
import DatabaseProvider from '$lib/DatabaseProvider.svelte';
101+
</script>
102+
103+
<DatabaseProvider>
104+
{@render children?.()}
105+
</DatabaseProvider>
106+
107+
<!-- SomeComponent.svelte -->
108+
<script>
109+
import { getDatabaseClient } from '$lib/DatabaseProvider.svelte';
110+
111+
const client = getDatabaseClient();
112+
// Use client - guaranteed to be initialized because provider only renders children after init
113+
</script>
114+
```
115+
116+
You wrap the app in a provider that initializes the client, waits for it to be ready, and provides it via context. Child components can safely access the client via the exported `getDatabaseClient()` function. Svelte 5's `createContext` returns a type-safe `[get, set]` tuple, so you don't need string keys or manual type annotations. This is a solid pattern and widely used in Svelte apps. The downside: you need the provider wrapper, and accessing the client requires the context getter instead of a direct import.
117+
118+
### Workaround 3: Await in Every Method
119+
120+
```typescript
121+
// db.ts
122+
function createDatabaseClient() {
123+
const initPromise = initializeConnection(); // Kicks off async work immediately
124+
125+
return {
126+
async save(data: Data) {
127+
const db = await initPromise; // Waits for init to complete
128+
return db.save(data);
129+
},
130+
async load(id: string) {
131+
const db = await initPromise;
132+
return db.load(id);
133+
},
134+
};
135+
}
136+
137+
export const client = createDatabaseClient();
138+
139+
// component.svelte
140+
import { client } from '$lib/db';
141+
142+
// Internally awaits the init promise that was kicked off on construction
143+
await client.save(data);
144+
```
145+
146+
You make construction synchronous by kicking off the initialization promise immediately, then every method awaits it before doing work. This is actually a pretty good pattern, and I'd recommend it in many cases. It lets you export a synchronous client that can be imported anywhere. The downside is verbosity: you have to remember to await the init promise in every single method you add.
147+
148+
### The Core Issue
149+
150+
UI frameworks want synchronous access to things. You can't easily export and import an awaited value. Components need to access clients immediately, not after an await. All these workarounds are trying to paper over that fundamental mismatch.
151+
152+
## The `whenSynced` Pattern
153+
154+
[y-indexeddb](https://github.com/yjs/y-indexeddb) (the IndexedDB persistence layer for Yjs) solves this brilliantly. Here's how it works:
155+
156+
```typescript
157+
const provider = new IndexeddbPersistence('my-db', doc);
158+
159+
// Constructor returns immediately - you can use it right away
160+
provider.on('update', () => {
161+
// Handle updates
162+
});
163+
164+
// But if you need to wait for initial sync:
165+
await provider.whenSynced;
166+
// Now the document is fully loaded from IndexedDB
167+
```
168+
169+
The constructor returns immediately. The async work (loading from IndexedDB) happens in the background. When you need to wait for it, you have `whenSynced`.
170+
171+
This is the pattern: synchronous construction, deferred sync.
172+
173+
The reason is that asynchronous construction is painful.
174+
175+
Using this pattern, you can export and import it like any other value:
176+
177+
```typescript
178+
// client.ts
179+
export const client = createClient();
180+
181+
// anywhere.ts
182+
import { client } from './client';
183+
184+
// Use it synchronously
185+
client.on('update', handleUpdate);
186+
187+
// Or wait for it to be ready
188+
await client.whenSynced;
189+
```
190+
191+
## Await Once at the Root
192+
193+
Once you have a client with this structure, the cleanest approach is to await once at the root of your application:
194+
195+
```svelte
196+
<!-- +layout.svelte -->
197+
<script>
198+
import { client } from '$lib/client';
199+
</script>
200+
201+
{#await client.whenSynced}
202+
<LoadingSpinner />
203+
{:then}
204+
{@render children?.()}
205+
{/await}
206+
```
207+
208+
Wait once at the root. After that, the entire app can assume the client is ready. This gives you:
209+
210+
1. **Single await point**: You wait once, then you're done
211+
2. **Guaranteed readiness**: When the UI renders, the client is ready
212+
3. **Simpler mental model**: No need to think "is this ready?" at every call site
213+
4. **Better UX**: One loading state for the whole app, not random delays throughout
214+
215+
## The Export Pattern
216+
217+
This is the key insight: you can synchronously export the client, but asynchronously ensure it's ready:
218+
219+
```typescript
220+
// client.ts
221+
export const client = createClient();
222+
223+
// +layout.svelte
224+
{#await client.whenSynced}
225+
Loading...
226+
{:then}
227+
<App />
228+
{/await}
229+
230+
// any-component.svelte
231+
<script>
232+
import { client } from '$lib/client';
233+
</script>
234+
235+
<button onclick={() => client.saveData(data)}>
236+
<!-- No await needed - this component only renders after client.whenSynced resolves -->
237+
Save
238+
</button>
239+
```
240+
241+
No `await getClient()` everywhere. No wondering if the client is initialized. Just import it and use it. The component won't even render until the root `{#await}` block resolves, so by the time this button is clickable, the client is guaranteed to be ready.
242+
243+
## Comparison: Before and After
244+
245+
**Before (Async Everywhere)**
246+
247+
```typescript
248+
// db.ts
249+
let client: Client | null = null;
250+
251+
export async function getClient() {
252+
if (!client) {
253+
client = await Client.create();
254+
}
255+
return client;
256+
}
257+
258+
// component.svelte
259+
async function handleSave() {
260+
const client = await getClient();
261+
await client.save(data);
262+
}
263+
264+
async function handleLoad() {
265+
const client = await getClient();
266+
return await client.load(id);
267+
}
268+
```
269+
270+
**After (Sync Construction)**
271+
272+
```typescript
273+
// db.ts
274+
export const client = createClient();
275+
276+
// +layout.svelte
277+
{#await client.whenSynced}Loading...{:then}<App />{/await}
278+
279+
// component.svelte
280+
function handleSave() {
281+
client.save(data); // Just use it
282+
}
283+
284+
function handleLoad() {
285+
return client.load(id); // No await needed at call site
286+
}
287+
```
288+
289+
The difference is clarity. You still have async work (the methods themselves are async), but you don't have async initialization. The client exists from the moment you import it.
290+
291+
## The Lesson
292+
293+
Not every async operation needs to block construction. When you're building clients that UI frameworks will consume, consider this pattern:
294+
295+
1. Construct synchronously
296+
2. Initialize asynchronously in the background
297+
3. Expose a `whenSynced` promise for consumers who need to wait
298+
4. In most cases, wait once at the root and forget about it
299+
300+
This is what Yjs figured out with IndexedDB persistence. It's a pattern worth adopting anywhere you have clients that UI code needs to access.

0 commit comments

Comments
 (0)