Let's fix the startup lag in AI-driven applications by shifting JSON-based generative UI creation out of the critical path and onto the backend.
What you'll learn
- Why runtime LLM calls destroy your app's initial launch performance.
- How to set up background database triggers to pre-bake A2UI layouts.
- How to ingest cached JSON payloads directly into client-side transport layers.
- Strategies for managing message synchronization without breaking conversation history.
The Cost of Runtime Generative UI
When you build applications driven by Generative UI (GenUI), your AI agent dynamically pieces together components using JSON payloads. It sounds amazing in practice until a user opens your app and stares at a loading spinner.
[User Opens App] ---> [Call LLM] ---> [Fetch DB] ---> [Construct JSON] ---> [Render UI]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
User is waiting 2-5 seconds just to see the screen.
If your client app has to query an LLM, parse system prompts, fetch database records, and compile UI layouts on startup, your users are waiting seconds just to see their initial dashboard. That ruins the native app feel. The fix is simple: stop generating UI when the user opens the app. Generate it when the underlying data changes, and cache the result.
1. Triggering Background UI Generation on the Backend
Instead of making the client wait, let the backend handle the heavy lifting asynchronously. Whenever a core record changes in your database, kick off a function to query your LLM and store the resulting A2UI payload.
Here is how you handle document writes in a Dart-based server function:
// functions/bin/server.dart
import 'package:commis_functions/src/content_generator.dart';
import 'package:firebase_admin_sdk/firebase_admin_sdk.dart';
import 'package:firebase_functions/firebase_functions.dart';
void main() {
final adminApp = FirebaseApp.initializeApp();
final firestore = adminApp.firestore();
runFunctions((firebase) {
firebase.firestore.onDocumentWritten((event) async {
final apiKey = StringParam('GEMINI_API_KEY', ParamOptions(defaultValue: '')).value();
final jobId = event.params['jobId'];
final jobData = event.data?.after?.data();
if (jobData == null) return;
try {
final generator = ContentGenerator(apiKey);
final message = await generator.generateFeed(jobData.toString());
if (message == noUiSentinel) {
await firestore.collection('feed').doc(jobId).delete();
return;
}
// Cache the compiled payload for instant retrieval later
await firestore.collection('feed').doc(jobId).set({
'message': message,
});
} catch (e) {
print('Error generating UI payload: $e');
}
}, document: 'jobs/{jobId}');
});
}
Gotcha: Keep your system instructions strict. Tell the model to output only the A2UI JSON payload. If the LLM includes conversational filler or markdown code blocks in the cache, your client transport layer will crash when it tries to parse the chunk.
2. Enforcing Strict Output Guards on the LLM
When pre-generating layouts, your prompt design needs guardrails. You are building structural payloads, not chatting with a user. Pass explicit constraints so the model understands it is rendering a component catalog, not writing an email.
// Inside ContentGenerator class definition
const String systemInstruction = '''
RESPOND ONLY WITH A2UI MESSAGES FOR A NavigationCard OR "$noUiSentinel".
DO NOT INCLUDE ANY CONVERSATIONAL TEXT WHATSOEVER.
DO NOT INCLUDE ANY EXPLANATIONS OF WHAT YOU ARE DOING.
''';
Speed note: Use a fast, lightweight model variant like gemini-3.1-flash-lite for these background tasks. You don't need heavy reasoning models to map basic database fields into a predefined card schema.
3. Hydrating the Client Transport Layer Instantly
On the client side, your initialization logic changes completely. Instead of triggering a chat session right away, you fetch your active records, grab their pre-compiled feed strings, and inject them straight into the UI transport layer.
Future<void> _initAgent() async {
final repository = context.read<FirestoreRepository>();
String? cachedFeeds;
try {
final jobs = await repository.getJobs();
final feedFutures = jobs.map((job) => repository.getFeedMessage(job.id));
final results = await Future.wait(feedFutures);
// Combine cached A2UI payloads into a single chunk stream
cachedFeeds = results
.map((r) => r?.trim() ?? '')
.where((r) => r.isNotEmpty)
.join('\n\n');
} catch (e) {
debugPrint('Error fetching cached UI feeds: $e');
}
_agentService = FirebaseAILogicService(
repository: repository,
catalog: _catalog,
cachedMessages: cachedFeeds,
);
// Push straight to the transport layer without waiting for network LLM calls
if (cachedFeeds != null && cachedFeeds.isNotEmpty) {
_transport.addChunk(cachedFeeds);
}
setState(() => _isWaiting = false);
}
Accessibility & UX note: Because the layout renders immediately from local cache, users experience zero layout shift on cold boot. Screen readers and layout trees compute the elements instantly.
4. Keeping Conversation Context Synced
Pre-loading UI feeds doesn't mean your chat is isolated. By passing those cached strings into your agent service initialization, you establish a shared state.
When a user looks at a pre-generated card and says, "Change the date on that assignment to next Monday," the agent already knows what UI surface is active on screen. It references the existing context history rather than asking the user what item they are talking about.
// The agent receives the cached strings as baseline system memory
_agentService = FirebaseAILogicService(
repository: repository,
catalog: _catalog,
cachedMessages: cachedFeeds, // Agent knows what user sees
);
Summary
Moving GenUI generation out of the user's critical path transforms your application's perceived performance. By capturing database mutations in background functions, caching strict JSON UI payloads, and hydrating your client transport layer on startup, you eliminate network lag entirely.
Next Steps
- Try replacing heavy database tables with a fast key-value store or CDN-hosted JSON files for even faster read times.
- Implement cache invalidation rules to clear out expired UI payloads when background records are archived.
- Explore client-side functions to let your agent trigger local operations directly inside your framework.