Back to Blogs
#angular#firebase#architecture

Stop Calling getGenerativeModel() Everywhere! The Hidden Traps of Scaling Firebase AI to Multi-Model Angular Apps

Calling getGenerativeModel() directly inside VisionService and TextToSpeechService duplicates...

7 min read
Cover for Stop Calling getGenerativeModel() Everywhere! The Hidden Traps of Scaling Firebase AI to Multi-Model Angular Apps

Calling getGenerativeModel() directly inside VisionService and TextToSpeechService duplicates firebase/ai imports, safety settings, and thinking configurations while recreating client-side GenerativeModel objects on every request and triggering unnecessary garbage collection. In this post, we extract AiModelFactory to centralize and cache model instances for multi-model routing, and we resolve two hidden caching traps: request-level generationConfig overwrites and stale AI backends after Remote Config updates.


Architecture & Mental Model

VisionService and TextToSpeechService delegate model creation and lifecycle management to AiModelFactory:

  • On-Demand Model Requests: VisionService calls getModel(options) with AiModelOptions, while TextToSpeechService calls getTtsModel().
  • Backend Validation and Cache Eviction (getValidBackend()): Before returning a model, getValidBackend() compares ConfigService.getAiBackend() against #cachedBackend. If Remote Config refreshes the AI backend, it clears #models and resets #ttsModel to null.
  • Keyed Vision Model Cache (#models): getModel(options) builds a deterministic key via generateKey() and caches instances in #models (Map<string, GenerativeModel>).
  • Singleton TTS Model Cache (#ttsModel): getTtsModel() caches a single #ttsModel instance configured with responseModalities: [ResponseModality.AUDIO].
flowchart TD
    subgraph Callers["Domain Services"]
        VS["VisionService (1 model now -> 3 models with MediaPipe routing)"]
        TTS["TextToSpeechService (per-request voice speechConfig)"]
    end

    subgraph Factory["AiModelFactory (@/core/firebase)"]
        GVB["getValidBackend(): compare with #cachedBackend"]
        EVICT["Backend changed? #models.clear() & #ttsModel = null"]
        GM["getModel(options?: AiModelOptions)"]
        KEY["generateKey(): model_thinking_instruction_schema_tools"]
        MAP[("#models: Map<string, GenerativeModel>")]
        GTTS["getTtsModel()"]
        TTS_CACHE[("#ttsModel: GenerativeModel | null")]
    end

    VS -- "getModel(options)" --> GVB
    TTS -- "getTtsModel()" --> GVB
    GVB -- "New AI instance" --> EVICT --> GM & GTTS
    GVB -- "Same AI instance" --> GM & GTTS
    GM --> KEY --> MAP
    GTTS --> TTS_CACHE

1. Defining AiModelOptions and Deterministic Cache Keys

1.1. The AiModelOptions Interface

The AiModelOptions interface defines the configuration needed to create a vision model and compute its cache key:

  • Optional Configuration Properties: systemInstruction, schema (SchemaRequest | TypedSchema), and the tools array (Tool[]) let callers configure structured JSON output, plain-text responses, or grounding tools.
// src/app/core/firebase/ai-model-options.interface.ts
import type { SchemaRequest, Tool, TypedSchema } from 'firebase/ai';

export interface AiModelOptions {
  systemInstruction?: string;
  schema?: SchemaRequest | TypedSchema;
  tools?: Tool[];
}
Enter fullscreen mode Exit fullscreen mode

1.2. Private State and generateKey() in AiModelFactory

  • Configuration Injection (#configService): AiModelFactory injects ConfigService into #configService to read Remote Config values and obtain the Firebase AI Logic backend.
  • Cached AI Backend (#cachedBackend): #cachedBackend stores the active AI backend reference used to create generative models.
  • Singleton TTS Cache (#ttsModel): #ttsModel holds the single-instance TTS model used to synthesize speech from English text.
  • Vision Model Map (#models and generateKey): #models is a Map<string, GenerativeModel> that stores vision models by the key returned from generateKey(). The key joins the model name (geminiModelName), thinkingLevel, systemInstruction, the structured-output schema, and tools with underscore (_) characters.
// src/app/core/firebase/ai-model-factory.service.ts (fields & key generation)
import type { AI, GenerationConfig, GenerativeModel, ModelParams, SafetySetting } from 'firebase/ai';

@Service()
export class AiModelFactory {
  readonly #configService = inject(ConfigService);
  readonly #models = new Map<string, GenerativeModel>();
  #ttsModel: GenerativeModel | null = null;
  #cachedBackend: AI | null = null;

  private generateKey(options?: AiModelOptions): string {
    const { geminiModelName, thinkingLevel } = this.#configService.appConfig;
    const instruction = options?.systemInstruction ?? '';
    const schemaKey = options?.schema ? JSON.stringify(options.schema) : '';
    const toolsKey = options?.tools?.length ? JSON.stringify(options.tools) : '';

    return `${geminiModelName}_${thinkingLevel}_${instruction}_${schemaKey}_${toolsKey}`;
  }
Enter fullscreen mode Exit fullscreen mode

2. Creating Models On Demand and Invalidating the Cache in AiModelFactory

2.1. Lazily Loading the SDK and Assembling Parameters (createModel, createModelParams, createGenerationConfig)

It is tempting to import the HarmBlockThreshold and HarmCategory enums at the top of the file, but doing so leaks firebase/ai runtime code into main.js and increases the initial bundle size:

  • Dynamic SDK Import (createModel): createModel() dynamically imports getGenerativeModel, HarmBlockThreshold, and HarmCategory from firebase/ai.
  • Shared Safety Settings (createModel): createModel() builds the SafetySetting[] array and calls getGenerativeModel(backend, modelParams).
  • Thinking Config (createGenerationConfig): createGenerationConfig() configures thinkingLevel with includeThoughts: true.
  • Structured Output Schema (createGenerationConfig): createGenerationConfig() attaches responseSchema and responseMimeType: 'application/json' when the caller provides options?.schema.
  • Model Parameter Assembly (createModelParams): createModelParams() combines the model name, generationConfig, and safetySettings, and conditionally adds tools and systemInstruction.
  // src/app/core/firebase/ai-model-factory.service.ts (model creation helpers)
  private async createModel(backend: AI, options?: AiModelOptions): Promise<GenerativeModel> {
    const { getGenerativeModel, HarmBlockThreshold, HarmCategory } = await import('firebase/ai');
    const safetySettings: SafetySetting[] = [
      { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH },
      { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH },
      { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH },
      { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH },
    ];

    const modelParams = this.createModelParams(safetySettings, options);
    return getGenerativeModel(backend, modelParams);
  }

  private createModelParams(safetySettings: SafetySetting[], options: AiModelOptions | undefined): ModelParams {
    const generationConfig = this.createGenerationConfig(options);

    const modelParams: ModelParams = {
      model: this.#configService.appConfig.geminiModelName,
      generationConfig,
      safetySettings,
    };

    if (options?.tools?.length) {
      modelParams.tools = options.tools;
    }

    if (options?.systemInstruction) {
      modelParams.systemInstruction = options.systemInstruction;
    }
    return modelParams;
  }

  private createGenerationConfig(options: AiModelOptions | undefined): GenerationConfig {
    const generationConfig: GenerationConfig = {
      thinkingConfig: {
        thinkingLevel: this.#configService.appConfig.thinkingLevel,
        includeThoughts: true,
      },
    };

    if (options?.schema) {
      generationConfig.responseSchema = options.schema;
      generationConfig.responseMimeType = 'application/json';
    }
    return generationConfig;
  }
Enter fullscreen mode Exit fullscreen mode

2.2. Reusing Models On Demand (getModel and getTtsModel)

Obtaining a vision model and a TTS model follows two different caching flows in AiModelFactory:

  • Vision Model Lookup (getModel): getModel() generates a deterministic cache key from AiModelOptions via generateKey(options) and checks this.#models.get(key).
  • Vision Model Cache Miss: When the key is not in #models, getModel() calls createModel() to instantiate the model and stores it in the map.
  • TTS Model Flow (getTtsModel): getTtsModel() calls createTtsModel() once to instantiate a singleton TTS model configured with responseModalities: [ResponseModality.AUDIO] and assigns it to #ttsModel.
  • Eliminating Per-Call Allocation and Garbage Collection: Reusing cached instances across requests avoids repeated getGenerativeModel() calls and unnecessary garbage collection.
  // src/app/core/firebase/ai-model-factory.service.ts (on-demand getters)
  async getModel(options?: AiModelOptions): Promise<GenerativeModel> {
    const backend = await this.getValidBackend();
    const key = this.generateKey(options);
    let model = this.#models.get(key);
    if (!model) {
      model = await this.createModel(backend, options);
      this.#models.set(key, model);
    }

    return model;
  }

  private async createTtsModel(ai: AI): Promise<GenerativeModel> {
    const { getGenerativeModel, ResponseModality } = await import('firebase/ai');

    return getGenerativeModel(ai, {
      model: this.#configService.appConfig.geminiTTSModelName,
      generationConfig: {
        responseModalities: [ResponseModality.AUDIO],
      },
    });
  }

  async getTtsModel(): Promise<GenerativeModel> {
    const backend = await this.getValidBackend();
    if (!this.#ttsModel) {
      this.#ttsModel = await this.createTtsModel(backend);
    }

    return this.#ttsModel;
  }
Enter fullscreen mode Exit fullscreen mode

2.3. Evicting Cached Models When Remote Config Rotates the Backend (getValidBackend)

  • Stale Backend on Remote Config Refresh: Constructing a GenerativeModel requires a valid Firebase AI Logic AI backend, which becomes outdated when dependent Firebase Remote Config parameters (such as useLimitedUseAppCheckTokens and vertexAILocation) receive updated values and ConfigService resets its AI instance.
  • Purging Cached Models (getValidBackend): When getValidBackend() detects that this.#configService.getAiBackend() no longer matches #cachedBackend, it updates #cachedBackend, clears the #models map, and resets #ttsModel to null.
  // src/app/core/firebase/ai-model-factory.service.ts (cache invalidation)
  private async getValidBackend(): Promise<AI> {
    const backend = await this.#configService.getAiBackend();
    if (backend !== this.#cachedBackend) {
      this.#cachedBackend = backend;
      this.#models.clear();
      this.#ttsModel = null;
    }

    return this.#cachedBackend;
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Refactoring VisionService and TextToSpeechService (and the Shallow-Spread Trap)

3.1. Simplifying VisionService for Future Multi-Model Routing

We refactored VisionService for extensibility and scalability by delegating model creation to AiModelFactory:

  • Removing SDK Setup Boilerplate: Introducing AiModelFactory removes SAFETY_SETTINGS, ConfigService, and the private getGenerativeAIModel() helper from VisionService.
  • Extracting altTextPrompt: Moving altTextPrompt to vision.prompt.ts keeps VisionService focused on orchestrating image analysis—generating tags, writing accessible alt text, and finding an obscure fact.
  • On-Demand Model Retrieval with Custom Tools: VisionService injects AiModelFactory into #aiModelFactory and calls this.#aiModelFactory.getModel({ schema: ImageAnalysisSchema, tools: [{ googleSearch: {} }] }). While the current model uses the Google Search grounding tool, VisionService can pass a Google Maps tool or custom function-calling tools through AiModelOptions to configure additional models.
  • Ready for MediaPipe Multi-Model Routing: Because getModel() caches instances by their configuration key, we can extend VisionService with an on-device MediaPipe classifier to request 3 specialized models (places, people and faces, and general objects) on demand.
// src/app/core/vision/vision.service.ts (refactored excerpt)
@Service()
export class VisionService {
  readonly #window = inject(WINDOW);
  readonly #aiModelFactory = inject(AiModelFactory);

  async analyzeImage(image: File): Promise<ImageAnalysisResponse> {
    const { data, mimeType, optimizationMetrics } = await preprocessImageForVision(image, this.#window);
    const imagePart: InlineDataPart = {
      type: 'inlineData',
      inlineData: { data, mimeType },
    };

    const aiModel = await this.#aiModelFactory.getModel({
      schema: ImageAnalysisSchema,
      tools: [{ googleSearch: {} }],
    });
    const result = await aiModel.generateContent([altTextPrompt, imagePart]);
    // ... parse response, tokenUsage, and grounding citations
  }
}
Enter fullscreen mode Exit fullscreen mode

3.2. Reusing #ttsModel in TextToSpeechService Without Wiping Out responseModalities

Similarly, TextToSpeechService delegates TTS model creation to AiModelFactory while passing the selected voice per request:

  • Shared TTS Model Lookup: TextToSpeechService injects AiModelFactory into #aiModelFactory, and createAIModelGenerationRequest() retrieves the cached TTS model via this.#aiModelFactory.getTtsModel() instead of instantiating a new model for every synthesis call.
  • Per-Request speechConfig Assembly: The createAIModelGenerationRequest(text, voiceName) helper builds the GenerateContentRequest (shared by synthesizeStream() and synthesize()) and configures speechConfig with the requested voiceName and languageCode: 'en-US'.
  • Preserving ResponseModality.AUDIO Against Shallow Overwrites: In @firebase/ai, passing a request-level generationConfig overwrites the model's default aiModel.generationConfig. Spreading ...aiModel.generationConfig into the request's generationConfig preserves responseModalities: [ResponseModality.AUDIO] alongside speechConfig.
// src/app/core/speech/text-to-speech.service.ts (refactored excerpt)
@Service()
export class TextToSpeechService {
  readonly #aiModelFactory = inject(AiModelFactory);

  private async createAIModelGenerationRequest(text: string, voiceName: string): Promise<AIModelGenerativeRequest> {
    const aiModel = await this.#aiModelFactory.getTtsModel();
    const contents: Content[] = [
      {
        role: 'user',
        parts: [{ type: 'text', text }],
      },
    ];
    const generationConfig: GenerationConfig = {
      ...aiModel.generationConfig,
      speechConfig: {
        voiceConfig: {
          prebuiltVoiceConfig: { voiceName },
        },
        languageCode: 'en-US',
      },
    };

    return {
      aiModel,
      generationRequest: { contents, generationConfig },
    };
  }

  async *synthesizeStream({ text, voice }: SpeechPrompt): AsyncGenerator<AudioStreamChunk> {
    const { aiModel, generationRequest } = await this.createAIModelGenerationRequest(text, voice);
    const responseStream = await aiModel.generateContentStream(generationRequest);

    for await (const chunk of responseStream.stream) {
      const chunkData = this.extractValidChunkData(chunk);
      if (chunkData) {
        yield decodeAudioChunk(chunkData.data, chunkData.mimeType);
      }
    }
  }

  async synthesize({ text, voice }: SpeechPrompt): Promise<Blob> {
    const { aiModel, generationRequest } = await this.createAIModelGenerationRequest(text, voice);
    const result = await aiModel.generateContent(generationRequest);

    const chunk = this.extractValidChunkData(result.response);
    if (!chunk) {
      throw new Error('No audio data received in response.');
    }
    return toWavBlob(chunk.data, chunk.mimeType);
  }
}
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Centralized Lazy Model Creation: AiModelFactory dynamically imports firebase/ai to construct both text-to-speech and image-analysis models. It centralizes safety settings and thinkingLevel for the image-analysis model while locking responseModalities to [ResponseModality.AUDIO] for the TTS model.
  • Instance Reuse Over Per-Request Allocation: AiModelFactory caches models in the #models map or the #ttsModel field and reuses them across requests, eliminating unnecessary per-request GenerativeModel instantiation and garbage collection.
  • Scalable Multi-Model Routing for VisionService: Keyed caching in the #models map makes VisionService scalable so an upcoming MediaPipe classifier can route images to specialized models (places, people and faces, and general objects) for analysis.
  • Preserving ResponseModality.AUDIO Across Per-Request Voices: Because text-to-speech synthesis accepts a voice per request, spreading ...aiModel.generationConfig into the request's generationConfig preserves ResponseModality.AUDIO alongside the selected voiceName.
  • Automatic Cache Invalidation on Remote Config Updates: When ConfigService fetches Remote Config values in the background and invalidates the cached AI backend, AiModelFactory purges all cached models and recreates fresh instances on the next call.

Resources