|
| 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