GEO-Butler Backend Architecture: A Deep Dive into Serverless Search Analytics
Introduction
GEO-Butler is a serverless SEO analytics platform that tracks website visibility across AI-powered search engines like ChatGPT, Gemini, and Perplexity. This post explores the backend architecture that powers real-time search operations, subscription billing, and AI-driven insights.
Tech Stack:
- Backend: Python 3.12 Cloud Functions (90+ endpoints)
- Database: Firebase Firestore (20 collections)
- APIs: DataForSEO, OpenAI, Gemini, Perplexity, Stripe
- Async Processing: Cloud Pub/Sub
- Region: europe-west2
System Architecture Overview
The platform is built entirely on serverless infrastructure, with Firebase Cloud Functions handling all API logic. Here's how the pieces fit together:
React 18 + TypeScript] end subgraph "CDN & Hosting" HOSTING[Firebase Hosting
Static Assets + SSR] end subgraph "Authentication" AUTH[Firebase Auth
Google OAuth] end subgraph "API Gateway" CF[Cloud Functions
Python 3.12
90+ Endpoints] end subgraph "External APIs" GOOGLE[Google Search
DataForSEO API] GPT[OpenAI GPT
GPT-5-nano] PERP[Perplexity AI] GEMINI[Google Gemini
2.5 Flash] STRIPE[Stripe
Payment Processing] end subgraph "Database" FS[(Firestore
20 Collections)] end subgraph "Storage" R2[Cloudflare R2
Schema Storage] end subgraph "Messaging" PUBSUB[Cloud Pub/Sub
Async Processing] end subgraph "Email" EMAIL[SendGrid/Mailgun
Email Delivery] end WEB -->|HTTPS| HOSTING WEB -->|OAuth| AUTH WEB -->|API Calls| CF CF -->|Verify Token| AUTH CF -->|Read/Write| FS CF -->|Search| GOOGLE CF -->|AI Search| GPT CF -->|AI Search| PERP CF -->|Schema Gen| GEMINI CF -->|Payments| STRIPE CF -->|Upload Schema| R2 CF -->|Publish Tasks| PUBSUB CF -->|Send Email| EMAIL PUBSUB -->|Trigger| CF STRIPE -->|Webhooks| CF style WEB fill:#4A90E2 style CF fill:#E85D75 style FS fill:#F39C12 style AUTH fill:#27AE60 style STRIPE fill:#635BFF
Key Architectural Decisions
- Serverless-First: All backend logic runs in Cloud Functions that scale to zero when idle
- Event-Driven: Pub/Sub decouples long-running operations from API responses
- Multi-Engine: Abstract search interface supports 5 different search engines
- Atomic Credits: Credit system uses atomic operations (not transactions) to prevent contention
- Firestore-Native: NoSQL structure optimized for real-time updates and concurrent writes
Backend Service Breakdown
Our Python backend is organized into 12 core modules powering 90+ API endpoints:
get_onboarding_stage
increment_onboarding_stage
subscription_status] end subgraph "Billing & Credits" BILLING_EP[get_wallet
update_tokens
adjust_customer_credits] end subgraph "Stripe Integration" STRIPE_EP[create_checkout_session
session_status
stripe_webhook] end subgraph "Data Management" SAVES_EP[new_save
get_saves
update_save
delete_save] TEMPLATES_EP[create_template
get_templates
update_template
delete_template] end subgraph "Search Operations" SEARCH_EP[search
search_template
process_single_search
ranked_keywords] end subgraph "Reports" REPORTS_EP[get_reports
get_report_status
get_report_summary
generate_sentiment_analysis] end subgraph "AI Features" AI_EP[generate_weekly_check
summarise_for_email
site_chatbot
generate_onboarding_keywords
parse_business_info] end subgraph "Schema Generation" SCHEMA_EP[generate_schema
generate_schema_async
get_schemas
upload_schema_to_r2
verify_domain] end subgraph "Email Operations" EMAIL_EP[send_email
send_template_email
process_email_queue
contact_form] end subgraph "Admin & Monitoring" ADMIN_EP[get_customers
get_errors
report_frontend_error
get_tickets] end end subgraph "Function Modules" direction LR BILLING_MOD[billing_functions.py
366 lines
Atomic credit operations] SEARCH_MOD[search_functions.py
~2500 lines
Multi-engine search] REPORT_MOD[report_functions_v2.py
~800 lines
Report management] KEYWORD_MOD[keyword_functions.py
431 lines
Keyword rankings] SCHEMA_MOD[schema_functions.py
518 lines
Schema generation] EMAIL_MOD[email_functions.py
917 lines
Email delivery] SCREENSHOT_MOD[screenshot_functions.py
302 lines
Playwright screenshots] ERROR_MOD[error_logging.py
238 lines
Error tracking] AI_REG[ai_function_registry.py
382 lines
AI function calling] end AUTH_EP --> BILLING_MOD BILLING_EP --> BILLING_MOD STRIPE_EP --> BILLING_MOD SEARCH_EP --> SEARCH_MOD SEARCH_MOD --> BILLING_MOD REPORTS_EP --> REPORT_MOD AI_EP --> BILLING_MOD SCHEMA_EP --> SCHEMA_MOD EMAIL_EP --> EMAIL_MOD style AUTH_EP fill:#27AE60 style BILLING_EP fill:#F39C12 style STRIPE_EP fill:#635BFF style SEARCH_EP fill:#E74C3C style AI_EP fill:#9B59B6 style SCHEMA_EP fill:#3498DB
Module Responsibilities
- billing_functions.py: Atomic credit operations with exponential backoff
- search_functions.py: Multi-engine abstraction (Google, GPT, Perplexity, Gemini)
- report_functions_v2.py: Lightweight reports with separate query_status for concurrency
- schema_functions.py: AI-powered schema.org generation with Gemini vision
- email_functions.py: Queue-based email delivery with template support
Data Flow #1: Single Search Operation
Let's trace a single search request from user input to final results:
(query, engine, user_id, token) SearchEndpoint->>BillingFunctions: Check credit balance BillingFunctions->>Firestore: Query wallets collection Firestore-->>BillingFunctions: current_balance: 100 alt Sufficient Credits BillingFunctions-->>SearchEndpoint: Balance OK (100 >= 6) SearchEndpoint->>SearchFunctions: Execute search SearchFunctions->>ExternalAPI: API call (GPT/Google/etc) ExternalAPI-->>SearchFunctions: Raw results SearchFunctions->>SearchFunctions: Parse & normalize results SearchFunctions->>SearchFunctions: Calculate domain position SearchFunctions->>Firestore: Store in queries collection SearchFunctions-->>SearchEndpoint: Query ID + results SearchEndpoint->>BillingFunctions: Deduct 6 credits BillingFunctions->>Firestore: Atomic update wallet (100-6=94) BillingFunctions->>Firestore: Log to token_transactions BillingFunctions-->>SearchEndpoint: Credits deducted SearchEndpoint-->>Frontend: 200 OK + results Frontend-->>User: Display results else Insufficient Credits BillingFunctions-->>SearchEndpoint: Insufficient credits SearchEndpoint-->>Frontend: 402 Payment Required Frontend-->>User: Show upgrade modal end
Key Implementation Details
Credit Check Before Execution:
# Check balance BEFORE running expensive operation
balance = get_wallet_balance(user_id)
if balance < SEARCH_WEIGHTS[search_engine]:
return Response("Insufficient credits", status=402)
# Execute search
results = execute_search(query, engine)
# Deduct credits AFTER successful execution
deduct_tokens_for_search(user_id, query_id, search_engine)
Atomic Wallet Updates:
def update_token_balance_atomic(user_id, amount, operation_type):
"""
Atomic operation (NOT transaction) to prevent contention.
Allows concurrent credit operations with retry logic.
"""
max_retries = 5
for attempt in range(max_retries):
try:
wallet_doc = get_wallet(user_id)
new_balance = wallet_doc['current_balance'] + amount
# Allow small negative balance (up to -100)
if new_balance < -100:
return False
# Atomic update (no transaction lock)
wallet_doc.reference.update({
'current_balance': new_balance,
'last_updated': SERVER_TIMESTAMP
})
# Log transaction
log_transaction(user_id, amount, operation_type, new_balance)
return True
except Exception:
# Exponential backoff with jitter
time.sleep(0.1 * (2 ** attempt) + random.uniform(0, 0.1))
return False
Why Atomic (Not Transactional)?
- Firestore transactions lock documents, causing contention under load
- Atomic operations allow concurrent credit operations
- Small negative balances (-100) act as buffer for race conditions
- Exponential backoff handles rare collision cases
Data Flow #2: Template Report Generation (Async)
Templates allow users to run multiple queries across multiple search engines. This generates 10s or 100s of individual searches that must run asynchronously:
(template_id, user_id, token) TemplateEndpoint->>Firestore: Get template doc Firestore-->>TemplateEndpoint: Template with 10 queries, 3 engines TemplateEndpoint->>ReportFunctions: Create report ReportFunctions->>Firestore: Create report doc (status: in_progress) ReportFunctions->>Firestore: Create 30 query_status docs
(10 queries × 3 engines) ReportFunctions-->>TemplateEndpoint: Report ID created TemplateEndpoint->>PubSub: Publish 30 search tasks TemplateEndpoint-->>Frontend: 202 Accepted + report_id Frontend->>Frontend: Start polling /get_report_status loop For each search task PubSub->>SearchWorker: Trigger process_single_search_pubsub SearchWorker->>Firestore: Update query_status (processing) SearchWorker->>BillingFunctions: Check & deduct credits SearchWorker->>SearchFunctions: Execute search SearchFunctions->>Firestore: Store query result SearchWorker->>Firestore: Update query_status (completed) SearchWorker->>Firestore: Increment report.completed_queries end Frontend->>TemplateEndpoint: Poll GET /get_report_status TemplateEndpoint->>Firestore: Query query_status collection Firestore-->>TemplateEndpoint: 28/30 completed TemplateEndpoint-->>Frontend: Status: in_progress (93%) Frontend->>TemplateEndpoint: Poll GET /get_report_status TemplateEndpoint->>Firestore: Query query_status collection Firestore-->>TemplateEndpoint: 30/30 completed TemplateEndpoint->>Firestore: Update report (status: completed) TemplateEndpoint-->>Frontend: Status: completed Frontend-->>User: Redirect to /report-overview
Architectural Patterns
CQRS (Command Query Responsibility Segregation):
- Separate
query_statuscollection prevents report document contention - 30 concurrent workers can update individual query_status docs
- Report doc only tracks counts, not full results
Pub/Sub for Scale:
def search_template(template_id, user_id):
"""
Publish search tasks to Pub/Sub instead of processing synchronously.
Prevents function timeout and enables parallel execution.
"""
template = get_template(template_id)
report_id = create_report(template_id)
# Create query_status docs (lightweight, no contention)
for query in template['queries']:
for engine in template['search_engines']:
create_query_status(report_id, query, engine)
# Publish to Pub/Sub for async processing
for query in template['queries']:
for engine in template['search_engines']:
publish_message('search-tasks', {
'report_id': report_id,
'query': query,
'engine': engine,
'user_id': user_id
})
return {'report_id': report_id, 'status': 'in_progress'}
Why This Works:
- 540-second function timeout allows ~60 seconds per search
- 30 queries × 3 engines = 90 individual tasks
- Sequential would take 90 × 5s = 450s (near timeout)
- Pub/Sub allows parallel execution: 30 functions × 5s = 5s elapsed
- Frontend polls status every 5 seconds for real-time updates
Data Flow #3: Subscription & Billing with Stripe
Payment processing is entirely webhook-driven, ensuring reliability even if the user closes their browser:
(price_index: 2, user_id) CreateCheckout->>Stripe: Create checkout session Stripe-->>CreateCheckout: session + client_secret CreateCheckout-->>Frontend: Return session data Frontend->>Frontend: Mount Stripe embedded checkout Frontend-->>User: Display payment form User->>Stripe: Enter card details & submit Stripe->>Stripe: Process payment Stripe->>StripeWebhook: POST checkout.session.completed StripeWebhook->>Firestore: Create subscription doc Firestore-->>StripeWebhook: Subscription created Stripe->>StripeWebhook: POST customer.subscription.created StripeWebhook->>Firestore: Update subscription IDs Firestore-->>StripeWebhook: Updated Stripe->>StripeWebhook: POST invoice.paid
(billing_reason: subscription_create) StripeWebhook->>Firestore: Get subscription doc Firestore-->>StripeWebhook: credits_per_cycle: 5000 StripeWebhook->>BillingFunctions: Add 5000 credits BillingFunctions->>Firestore: Atomic update wallet (+5000) BillingFunctions->>Firestore: Log to token_transactions BillingFunctions-->>StripeWebhook: Credits added StripeWebhook->>Firestore: Update subscription.last_invoice_paid StripeWebhook-->>Stripe: 200 OK Stripe-->>User: Redirect to success page User->>Frontend: Navigate to /classic-success Frontend->>Frontend: Fetch updated wallet balance Frontend-->>User: Show success + 5000 credits
Webhook Implementation
Signature Verification (Critical):
@https_fn.on_request()
def stripe_webhook(req):
"""Handle all Stripe webhook events with signature verification."""
payload = req.get_data()
sig_header = req.headers.get('Stripe-Signature')
try:
# Verify webhook signature (prevents spoofing)
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError:
return Response("Invalid payload", status=400)
except stripe.error.SignatureVerificationError:
return Response("Invalid signature", status=400)
# Route event to appropriate handler
if event['type'] == 'checkout.session.completed':
handle_checkout_completed(event['data']['object'])
elif event['type'] == 'invoice.paid':
handle_invoice_paid(event['data']['object'])
elif event['type'] == 'customer.subscription.updated':
handle_subscription_updated(event['data']['object'])
return Response("Success", status=200)
Idempotent Credit Allocation:
def handle_invoice_paid(invoice):
"""
Add credits when subscription invoice is paid.
Handles both initial subscription and monthly renewals.
"""
subscription_id = invoice['subscription']
billing_reason = invoice['billing_reason']
# Get subscription from Firestore
subscription = get_subscription_by_stripe_id(subscription_id)
user_id = subscription['user_id']
credits = subscription['credits_per_cycle']
# Determine operation type
if billing_reason == 'subscription_create':
operation_type = 'subscription_credit'
elif billing_reason == 'subscription_cycle':
operation_type = 'renewal_credit'
else:
operation_type = 'subscription_credit'
# Add credits atomically
success = update_token_balance_atomic(
user_id=user_id,
amount=credits,
operation_type=operation_type,
description=f"{operation_type}: {credits} credits"
)
if success:
# Update subscription metadata
update_subscription_payment_details(
subscription_id,
invoice['amount_paid'],
invoice['status']
)
Credit Cost Structure:
| Plan | Price | Credits/Month | Cost per Credit |
|---|---|---|---|
| Free | $0 | 100 (one-time) | N/A |
| Essentials | $29 | 1,000 | $0.029 |
| Classic | $99 | 5,000 | $0.020 |
| Select | $299 | 50,000 | $0.006 |
| Operation | Credits | $ (Classic) |
|---|---|---|
| Google Search | 2 | $0.04 |
| GPT Search | 6 | $0.12 |
| Gemini Search | 35 | $0.70 |
| Schema Generation | 14 | $0.28 |
Data Flow #4: AI Schema Generation with Gemini
One of our most complex features is AI-powered schema.org markup generation using multimodal analysis:
(domain, schema_type, user_id) SchemaEndpoint->>BillingFunctions: Check credits (14 needed) BillingFunctions-->>SchemaEndpoint: Balance OK SchemaEndpoint->>ScreenshotFunctions: Capture website screenshot ScreenshotFunctions->>ScreenshotFunctions: Launch Playwright ScreenshotFunctions->>ScreenshotFunctions: Navigate to domain ScreenshotFunctions->>ScreenshotFunctions: Remove cookie banners ScreenshotFunctions->>ScreenshotFunctions: Capture full-page PNG ScreenshotFunctions-->>SchemaEndpoint: screenshot_base64 + html_content SchemaEndpoint->>SchemaFunctions: Generate schema SchemaFunctions->>Gemini: Multimodal prompt
(screenshot + HTML + instructions) Gemini->>Gemini: Process with gemini-2.5-flash Gemini-->>SchemaFunctions: Generated schema JSON-LD SchemaFunctions->>SchemaFunctions: Validate against vocabulary SchemaFunctions->>SchemaFunctions: Calculate validation score SchemaFunctions->>Firestore: Save to schemas collection Firestore-->>SchemaFunctions: Schema ID SchemaFunctions->>BillingFunctions: Deduct 14 credits BillingFunctions->>Firestore: Update wallet & log transaction BillingFunctions-->>SchemaFunctions: Credits deducted alt User requests R2 upload SchemaFunctions->>R2Storage: Upload schema JSON R2Storage-->>SchemaFunctions: Public URL SchemaFunctions->>Firestore: Update schema with R2 URL end SchemaFunctions-->>SchemaEndpoint: Schema data + validation SchemaEndpoint-->>Frontend: Return schema Frontend-->>User: Display schema + validation results
Multimodal AI Implementation
Gemini Vision Prompt:
def generate_schema_with_gemini(domain, schema_type, screenshot_base64, html_content):
"""
Generate schema.org JSON-LD using Gemini's multimodal capabilities.
Analyzes both visual appearance and HTML structure.
"""
model = genai.GenerativeModel('gemini-2.5-flash')
# Prepare multimodal input
image_part = {
'mime_type': 'image/png',
'data': screenshot_base64
}
prompt = f"""
Analyze this website and generate comprehensive schema.org JSON-LD markup.
Domain: {domain}
Requested Schema Type: {schema_type}
Use the screenshot to understand:
- Visual branding and design
- Layout and structure
- Key content placement
Use the HTML to extract:
- Structured data
- Metadata
- Content hierarchy
Generate verbose, detailed schema with all relevant properties.
Follow schema.org vocabulary strictly.
HTML Content:
{html_content[:10000]} # First 10KB
"""
# Call Gemini with multimodal input
response = model.generate_content([prompt, image_part])
schema_json = extract_json_from_response(response.text)
# Validate against schema.org vocabulary
validation_results = validate_schema(schema_json)
return {
'schema_json': schema_json,
'validation_score': validation_results['score'],
'validation_errors': validation_results['errors']
}
Schema Validation:
def validate_schema_against_vocabulary(schema_json):
"""
Validate generated schema against official schema.org vocabulary.
Loaded from 1.5MB schemaorg-current.jsonld file.
"""
vocabulary = load_schema_org_vocabulary() # Cached in memory
errors = []
warnings = []
score = 100
# Check type exists
if schema_json['@type'] not in vocabulary['types']:
errors.append(f"Invalid type: {schema_json['@type']}")
score -= 50
# Check properties
for prop, value in schema_json.items():
if prop.startswith('@'):
continue
if prop not in vocabulary['properties']:
warnings.append(f"Non-standard property: {prop}")
score -= 5
return {
'score': max(0, score),
'errors': errors,
'warnings': warnings
}
Database Architecture
Our Firestore database uses 20 collections optimized for real-time updates and concurrent access:
Key Design Decisions
Separate query_status Collection:
- Prevents report document contention during concurrent updates
- Each query×engine combination gets its own lightweight document
- Workers update individual status docs in parallel
- Report doc only tracks aggregate counts
User-Scoped user_data Maps:
interface Site {
domain: string;
public_data: {
title: string;
screenshot_base64: string;
// Shared across all users
};
user_data: {
[user_id: string]: {
private_data: {
competitors: string[];
custom_notes: string;
// Private to each user
}
}
}
}
Atomic Wallet Updates:
- No transactions = no lock contention
- Small negative balance buffer (-100 credits) handles race conditions
- Every operation logged in token_transactions for audit trail
External API Integration
GEO-Butler integrates with 8 external services:
Google Search
Keyword Rankings] OA[OpenAI
GPT-5-nano
AI Search & Summaries] PA[Perplexity AI
AI Search] GM[Google Gemini
2.5 Flash
Schema Generation] end subgraph "Infrastructure APIs" ST[Stripe
Payment Processing
Subscription Management] SG[SendGrid/Mailgun
Email Delivery] R2[Cloudflare R2
Schema Storage] end subgraph "Google Cloud" FA[Firebase Auth
OAuth & JWT] FS[(Firestore
NoSQL Database)] PS[Cloud Pub/Sub
Async Messaging] end SF -->|Search Requests| DF SF -->|AI Search| OA SF -->|AI Search| PA SF -->|AI Search| GM KF -->|Ranking Data| DF SCF -->|Schema Generation| GM SCF -->|Upload Schema| R2 EF -->|Send Email| SG BF -->|Payments| ST BF -->|Webhooks| ST SF -->|Read/Write| FS KF -->|Read/Write| FS SCF -->|Read/Write| FS EF -->|Read/Write| FS BF -->|Read/Write| FS SF -->|Publish Tasks| PS SCF -->|Publish Tasks| PS PS -->|Trigger Functions| SF PS -->|Trigger Functions| SCF SF -->|Verify Tokens| FA KF -->|Verify Tokens| FA SCF -->|Verify Tokens| FA style DF fill:#4285F4 style OA fill:#412991 style PA fill:#20808D style GM fill:#4285F4 style ST fill:#635BFF style SG fill:#1A82E2 style R2 fill:#F38020 style FA fill:#FFCA28 style FS fill:#F57C00 style PS fill:#4285F4
Multi-Engine Search Abstraction
class SearchEngine:
"""Abstract base class for all search engines."""
@abstractmethod
def search(self, query: str, location: str, language: str) -> dict:
pass
@abstractmethod
def get_cost(self) -> int:
pass
class GoogleSearchEngine(SearchEngine):
"""Google Search via DataForSEO API."""
def search(self, query, location, language):
response = requests.post(
'https://api.dataforseo.com/v3/serp/google/organic/live/advanced',
auth=(DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD),
json=[{
'keyword': query,
'location_code': location,
'language_code': language
}]
)
return self.parse_results(response.json())
def get_cost(self):
return 2 # credits
class GPTSearchEngine(SearchEngine):
"""GPT-powered search (Google + AI analysis)."""
def search(self, query, location, language):
# First get Google results
google_results = GoogleSearchEngine().search(query, location, language)
# Then analyze with GPT
analysis = openai.chat.completions.create(
model='gpt-5-nano',
messages=[{
'role': 'system',
'content': 'Analyze these search results and provide insights.'
}, {
'role': 'user',
'content': f"Query: {query}\n\nResults: {google_results}"
}]
)
return {
'google_results': google_results,
'ai_analysis': analysis.choices[0].message.content
}
def get_cost(self):
return 6 # credits (2 for Google + 4 for GPT)
Security Architecture
Four layers of security protect every API call:
Google OAuth] A2[ID Token Generation
JWT with 1hr expiry] A3[Auto Token Refresh] end subgraph "2. Authorization" B1[Token Verification
Every API call] B2[User ID Validation
Token UID = Header user-id] B3[Resource Ownership
Check user_id in docs] B4[Admin Privilege Check
users.is_admin flag] end subgraph "3. API Security" C1[CORS Configuration
Allowed origins only] C2[Stripe Webhook Signature
Verify all webhooks] C3[Rate Limiting
Per user/endpoint] C4[Input Validation
All user inputs] end subgraph "4. Data Security" D1[Firestore Security Rules] D2[Private user_data Maps
Per-user isolation] D3[Public/Private Mode
Magic link tokens] D4[Sensitive Data
Environment variables] end end A1 --> A2 --> A3 A3 --> B1 --> B2 --> B3 --> B4 B4 --> C1 --> C2 --> C3 --> C4 C4 --> D1 --> D2 --> D3 --> D4 style A1 fill:#27AE60 style B2 fill:#E74C3C style C2 fill:#F39C12 style D1 fill:#3498DB
Token Verification Pattern
Every Cloud Function starts with this:
def verify_user(request):
"""Verify Firebase ID token and extract user_id."""
user_id = request.headers.get('user-id')
auth_header = request.headers.get('Authorization')
if not user_id or not auth_header:
raise ValueError("Missing authentication")
# Extract token from "Bearer <token>"
token = auth_header.split('Bearer ')[1]
try:
# Verify with Firebase Admin SDK
decoded_token = firebase_auth.verify_id_token(token)
# Ensure user_id matches token
if decoded_token['uid'] != user_id:
raise ValueError("User ID mismatch")
return user_id, decoded_token
except Exception as e:
raise ValueError(f"Invalid token: {e}")
@https_fn.on_request()
def my_endpoint(req):
try:
user_id, token = verify_user(req)
# Process request for verified user
data = get_user_data(user_id)
return Response(json.dumps(data), status=200)
except ValueError as e:
return Response(str(e), status=401)
Performance & Scalability
Current Configuration
Cloud Functions:
- Runtime: Python 3.12
- Memory: 256MB - 2048MB (function-specific)
- Timeout: 30s - 540s
- Concurrency: 20 per instance
- Max Instances: 50
- Min Instances: 0 (scale to zero)
Firestore:
- Write Rate: 10,000/sec per database
- Read Rate: 100,000/sec per database
- Document Limit: 1 MB
- Collections: 20
Scaling Strategies
- Horizontal Scaling: Functions auto-scale based on load
- Async Processing: Pub/Sub prevents function timeouts
- CQRS Pattern: Separate query_status reduces contention
- Atomic Operations: No transaction locks = better concurrency
- Efficient Indexing: Composite indexes for common queries
- Caching: Schema.org vocabulary cached in memory (1.5MB)
Cost Optimization
- Scale to zero when idle (no minimum instances)
- Efficient Firestore queries (indexed properly)
- Minimal data in report documents (references, not full results)
- Batch API calls where possible
Lessons Learned
1. Transactions vs. Atomic Operations
Problem: Initial implementation used Firestore transactions for credit updates, causing contention under load (concurrent template reports).
Solution: Switched to atomic operations with exponential backoff. Small negative balance buffer (-100 credits) handles rare race conditions.
Result: 10x improvement in concurrent report execution.
2. Report Document Contention
Problem: Storing all query results in report document caused timeouts when 30+ workers tried to update simultaneously.
Solution: Created separate query_status collection. Report doc only tracks counts, not full results.
Result: Reports with 100+ queries now complete reliably.
3. Function Timeouts on Large Templates
Problem: Processing 50 queries × 3 engines = 150 searches sequentially would timeout at 540 seconds.
Solution: Immediate Pub/Sub publish with 202 Accepted response. Frontend polls status.
Result: Templates of any size complete successfully.
4. Webhook Reliability
Problem: User closing browser during payment caused missed subscription credits.
Solution: 100% webhook-driven billing. Credits added asynchronously after payment confirmation.
Result: Zero missed credit allocations.
Conclusion
Building GEO-Butler taught us that serverless architecture requires different thinking than traditional backends:
- Embrace async: Don't try to complete everything in one request
- Avoid locks: Atomic operations > transactions for high concurrency
- Separate concerns: CQRS pattern prevents document contention
- Trust webhooks: Don't wait for synchronous responses from payment providers
- Monitor everything: Firestore operations, function execution times, error rates
The result is a platform that scales effortlessly from zero to thousands of concurrent searches, with sub-second API responses and reliable billing.
Project: GEO-Butler
Stack: Next.js + Firebase + Python
Architecture: Serverless Microservices
Database: Firestore (20 collections)
API Endpoints: 90+
External APIs: 8
Region: europe-west2
Documentation Repository: GitHub
Want to learn more? Check out the full architecture documentation for detailed API references, database schemas, and component architecture.